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); } }