using NSubstitute;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Interfaces.Repositories;
namespace ScadaLink.InboundAPI.Tests;
///
/// WP-1: Tests for API key validation — X-API-Key header, enabled/disabled keys,
/// method approval.
///
public class ApiKeyValidatorTests
{
private readonly IInboundApiRepository _repository = Substitute.For();
private readonly ApiKeyValidator _validator;
public ApiKeyValidatorTests()
{
_validator = new ApiKeyValidator(_repository);
}
[Fact]
public async Task MissingApiKey_Returns401()
{
var result = await _validator.ValidateAsync(null, "testMethod");
Assert.False(result.IsValid);
Assert.Equal(401, result.StatusCode);
}
[Fact]
public async Task EmptyApiKey_Returns401()
{
var result = await _validator.ValidateAsync("", "testMethod");
Assert.False(result.IsValid);
Assert.Equal(401, result.StatusCode);
}
[Fact]
public async Task InvalidApiKey_Returns401()
{
_repository.GetAllApiKeysAsync().Returns(new List());
var result = await _validator.ValidateAsync("bad-key", "testMethod");
Assert.False(result.IsValid);
Assert.Equal(401, result.StatusCode);
}
[Fact]
public async Task DisabledApiKey_Returns401()
{
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = false };
_repository.GetAllApiKeysAsync().Returns(new List { key });
var result = await _validator.ValidateAsync("valid-key", "testMethod");
Assert.False(result.IsValid);
Assert.Equal(401, result.StatusCode);
}
[Fact]
public async Task ValidKey_MethodNotFound_IsIndistinguishableFromNotApproved()
{
// InboundAPI-011: a "method not found" response must not be observably
// different from a "key not approved" response, or a caller holding any
// valid key could enumerate which method names exist on the central API.
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
var method = new ApiMethod("realMethod", "return 1;") { Id = 10 };
_repository.GetAllApiKeysAsync().Returns(new List { key });
_repository.GetMethodByNameAsync("nonExistent").Returns((ApiMethod?)null);
_repository.GetMethodByNameAsync("realMethod").Returns(method);
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List());
var notFound = await _validator.ValidateAsync("valid-key", "nonExistent");
var notApproved = await _validator.ValidateAsync("valid-key", "realMethod");
Assert.False(notFound.IsValid);
Assert.False(notApproved.IsValid);
// Status code and error message must be identical so existence is not observable.
Assert.Equal(notApproved.StatusCode, notFound.StatusCode);
Assert.Equal(notApproved.ErrorMessage, notFound.ErrorMessage);
Assert.Equal(403, notFound.StatusCode);
}
[Fact]
public async Task ValidKey_MethodNotFound_ErrorMessageDoesNotEchoMethodName()
{
// InboundAPI-011: the error body must not echo the caller-supplied method
// name back verbatim (reflected-input) and must not confirm non-existence.
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
_repository.GetAllApiKeysAsync().Returns(new List { key });
_repository.GetMethodByNameAsync("probe-XYZ").Returns((ApiMethod?)null);
var result = await _validator.ValidateAsync("valid-key", "probe-XYZ");
Assert.False(result.IsValid);
Assert.DoesNotContain("probe-XYZ", result.ErrorMessage ?? string.Empty);
Assert.DoesNotContain("not found", result.ErrorMessage ?? string.Empty,
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ValidKey_NotApprovedForMethod_Returns403()
{
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
_repository.GetAllApiKeysAsync().Returns(new List { key });
_repository.GetMethodByNameAsync("testMethod").Returns(method);
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List());
var result = await _validator.ValidateAsync("valid-key", "testMethod");
Assert.False(result.IsValid);
Assert.Equal(403, result.StatusCode);
}
[Fact]
public async Task ValidKey_ApprovedForMethod_ReturnsValid()
{
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
_repository.GetAllApiKeysAsync().Returns(new List { key });
_repository.GetMethodByNameAsync("testMethod").Returns(method);
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List { key });
var result = await _validator.ValidateAsync("valid-key", "testMethod");
Assert.True(result.IsValid);
Assert.Equal(200, result.StatusCode);
Assert.Equal(key, result.ApiKey);
Assert.Equal(method, result.Method);
}
// --- InboundAPI-003: API key must not be matched with a non-constant-time
// (timing-oracle) secret-equality lookup. ---
[Fact]
public async Task ValidateAsync_DoesNotUseSecretEqualityLookup()
{
// GetApiKeyByValueAsync translates to a SQL "WHERE KeyValue = @secret" early-exit
// comparison — a timing side-channel. The validator must not call it.
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
_repository.GetAllApiKeysAsync().Returns(new List { key });
_repository.GetMethodByNameAsync("testMethod").Returns(method);
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List { key });
await _validator.ValidateAsync("valid-key", "testMethod");
await _repository.DidNotReceive()
.GetApiKeyByValueAsync(Arg.Any(), Arg.Any());
}
[Fact]
public async Task ValidateAsync_WrongKey_ConstantTimePath_Returns401()
{
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
_repository.GetAllApiKeysAsync().Returns(new List { key });
var result = await _validator.ValidateAsync("wrong-key", "testMethod");
Assert.False(result.IsValid);
Assert.Equal(401, result.StatusCode);
}
[Fact]
public async Task ValidateAsync_KeyOfDifferentLength_Returns401()
{
// FixedTimeEquals over UTF-8 bytes must reject length mismatches without leaking.
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
_repository.GetAllApiKeysAsync().Returns(new List { key });
var result = await _validator.ValidateAsync("valid-key-with-extra", "testMethod");
Assert.False(result.IsValid);
Assert.Equal(401, result.StatusCode);
}
}