using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.Auth.ApiKeys; namespace ZB.MOM.WW.Auth.ApiKeys.Tests; public class ApiKeyVerifierTests { private const string TokenPrefix = "mxgw"; private const string Pepper = "test-pepper"; private const string KeyId = "abc123"; private const string Secret = "supersecretvalue"; private const string DisplayName = "Test Key"; private const string ConstraintsJson = """{"ipAllow":["10.0.0.0/8"]}"""; private static readonly IReadOnlySet Scopes = new HashSet { "read", "write" }; private static string Header(string keyId, string secret) => $"{TokenPrefix}_{keyId}_{secret}"; private static ApiKeyRecord BuildRecord( byte[] secretHash, DateTimeOffset? revokedUtc = null) => new( KeyId: KeyId, KeyPrefix: TokenPrefix, SecretHash: secretHash, DisplayName: DisplayName, Scopes: Scopes, ConstraintsJson: ConstraintsJson, CreatedUtc: DateTimeOffset.UnixEpoch, LastUsedUtc: null, RevokedUtc: revokedUtc); private static ApiKeyVerifier BuildVerifier( FakeApiKeyStore store, FakePepperProvider pepperProvider) => new(new ApiKeyOptions { TokenPrefix = TokenPrefix }, store, pepperProvider); // --- MissingOrMalformed --- [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData("garbage")] [InlineData("wrongprefix_abc123_secret")] public async Task VerifyAsync_MissingOrMalformedHeader_ReturnsMissingOrMalformed(string? header) { var store = new FakeApiKeyStore(); var verifier = BuildVerifier(store, new FakePepperProvider(Pepper)); ApiKeyVerification result = await verifier.VerifyAsync(header!, CancellationToken.None); Assert.False(result.Succeeded); Assert.Equal(ApiKeyFailure.MissingOrMalformed, result.Failure); Assert.Null(result.Identity); Assert.False(store.MarkUsedCalled); } // --- PepperUnavailable --- [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public async Task VerifyAsync_PepperUnavailable_ReturnsPepperUnavailable(string? pepper) { var store = new FakeApiKeyStore(); var verifier = BuildVerifier(store, new FakePepperProvider(pepper)); ApiKeyVerification result = await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None); Assert.False(result.Succeeded); Assert.Equal(ApiKeyFailure.PepperUnavailable, result.Failure); Assert.Null(result.Identity); Assert.False(store.MarkUsedCalled); } [Fact] public async Task VerifyAsync_PepperUnavailable_DoesNotQueryStore() { var store = new FakeApiKeyStore(); var verifier = BuildVerifier(store, new FakePepperProvider(null)); await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None); Assert.False(store.FindByKeyIdCalled); } // --- KeyNotFound --- [Fact] public async Task VerifyAsync_KeyNotFound_ReturnsKeyNotFound() { var store = new FakeApiKeyStore { Record = null }; var verifier = BuildVerifier(store, new FakePepperProvider(Pepper)); ApiKeyVerification result = await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None); Assert.False(result.Succeeded); Assert.Equal(ApiKeyFailure.KeyNotFound, result.Failure); Assert.Null(result.Identity); Assert.False(store.MarkUsedCalled); } // --- KeyRevoked --- [Fact] public async Task VerifyAsync_RevokedKey_ReturnsKeyRevoked() { byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper); var store = new FakeApiKeyStore { Record = BuildRecord(hash, revokedUtc: DateTimeOffset.UtcNow), }; var verifier = BuildVerifier(store, new FakePepperProvider(Pepper)); ApiKeyVerification result = await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None); Assert.False(result.Succeeded); Assert.Equal(ApiKeyFailure.KeyRevoked, result.Failure); Assert.Null(result.Identity); Assert.False(store.MarkUsedCalled); } // --- SecretMismatch --- [Fact] public async Task VerifyAsync_WrongSecret_ReturnsSecretMismatch() { // Record's hash is built from a DIFFERENT secret with the test pepper. byte[] hash = ApiKeySecretHasher.Hash("a-different-secret", Pepper); var store = new FakeApiKeyStore { Record = BuildRecord(hash) }; var verifier = BuildVerifier(store, new FakePepperProvider(Pepper)); ApiKeyVerification result = await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None); Assert.False(result.Succeeded); Assert.Equal(ApiKeyFailure.SecretMismatch, result.Failure); Assert.Null(result.Identity); Assert.False(store.MarkUsedCalled); } // --- Success --- [Fact] public async Task VerifyAsync_ValidKey_ReturnsSuccessWithIdentity() { byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper); var store = new FakeApiKeyStore { Record = BuildRecord(hash) }; var verifier = BuildVerifier(store, new FakePepperProvider(Pepper)); ApiKeyVerification result = await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None); Assert.True(result.Succeeded); Assert.Null(result.Failure); Assert.NotNull(result.Identity); Assert.Equal(KeyId, result.Identity!.KeyId); Assert.Equal(DisplayName, result.Identity.DisplayName); Assert.Equal(Scopes, result.Identity.Scopes); Assert.Equal(ConstraintsJson, result.Identity.Constraints); } [Fact] public async Task VerifyAsync_ValidKey_MarksKeyUsed() { byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper); var store = new FakeApiKeyStore { Record = BuildRecord(hash) }; var verifier = BuildVerifier(store, new FakePepperProvider(Pepper)); await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None); Assert.True(store.MarkUsedCalled); Assert.Equal(KeyId, store.MarkUsedKeyId); } [Fact] public async Task VerifyAsync_ValidKey_UsesInjectedTimeProviderForMarkUsed() { byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper); var store = new FakeApiKeyStore { Record = BuildRecord(hash) }; var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero)); var verifier = new ApiKeyVerifier( new ApiKeyOptions { TokenPrefix = TokenPrefix }, store, new FakePepperProvider(Pepper), fakeTime); await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None); Assert.Equal(fakeTime.Now, store.MarkUsedWhenUtc); } [Fact] public async Task VerifyAsync_ValidKey_DoesNotLeakSecretInIdentity() { byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper); var store = new FakeApiKeyStore { Record = BuildRecord(hash) }; var verifier = BuildVerifier(store, new FakePepperProvider(Pepper)); ApiKeyVerification result = await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None); string identityText = result.Identity!.ToString(); Assert.DoesNotContain(Secret, identityText, StringComparison.Ordinal); Assert.DoesNotContain(Pepper, identityText, StringComparison.Ordinal); Assert.DoesNotContain(Convert.ToBase64String(hash), identityText, StringComparison.Ordinal); } // --- Cancellation --- [Fact] public async Task VerifyAsync_AlreadyCancelled_Throws() { var store = new FakeApiKeyStore(); var verifier = BuildVerifier(store, new FakePepperProvider(Pepper)); using var cts = new CancellationTokenSource(); cts.Cancel(); await Assert.ThrowsAnyAsync( () => verifier.VerifyAsync(Header(KeyId, Secret), cts.Token)); Assert.False(store.MarkUsedCalled); } // --- Bearer scheme acceptance (sanity) --- [Fact] public async Task VerifyAsync_BearerPrefixedValidKey_Succeeds() { byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper); var store = new FakeApiKeyStore { Record = BuildRecord(hash) }; var verifier = BuildVerifier(store, new FakePepperProvider(Pepper)); ApiKeyVerification result = await verifier.VerifyAsync($"Bearer {Header(KeyId, Secret)}", CancellationToken.None); Assert.True(result.Succeeded); } // --- Fakes --- private sealed class FakeApiKeyStore : IApiKeyStore { public ApiKeyRecord? Record { get; set; } public bool FindByKeyIdCalled { get; private set; } public bool MarkUsedCalled { get; private set; } public string? MarkUsedKeyId { get; private set; } public DateTimeOffset? MarkUsedWhenUtc { get; private set; } public Task FindByKeyIdAsync(string keyId, CancellationToken ct) { FindByKeyIdCalled = true; return Task.FromResult(Record); } public Task FindActiveByKeyIdAsync(string keyId, CancellationToken ct) => throw new NotSupportedException("Verifier must use FindByKeyIdAsync to discriminate revoked keys."); public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct) { MarkUsedCalled = true; MarkUsedKeyId = keyId; MarkUsedWhenUtc = whenUtc; return Task.CompletedTask; } } private sealed class FakePepperProvider(string? pepper) : IApiKeyPepperProvider { public string? GetPepper() => pepper; } private sealed class FakeTimeProvider(DateTimeOffset now) : TimeProvider { public DateTimeOffset Now { get; } = now; public override DateTimeOffset GetUtcNow() => Now; } }