using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using MxGateway.Server.Configuration; using MxGateway.Server.Security.Authentication; namespace MxGateway.Tests.Security.Authentication; public sealed class ApiKeyVerifierTests { [Fact] public async Task VerifyAsync_ValidKey_ReturnsIdentityAndScopes() { ApiKeySecretHasher hasher = CreateHasher("pepper"); FakeApiKeyStore store = new(CreateRecord(hasher, revokedUtc: null)); ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store); ApiKeyVerificationResult result = await verifier.VerifyAsync( "Bearer mxgw_operator01_correct-secret", CancellationToken.None); Assert.True(result.Succeeded); Assert.NotNull(result.Identity); Assert.Equal("operator01", result.Identity.KeyId); Assert.Equal("Operator Key", result.Identity.DisplayName); Assert.Contains("session:open", result.Identity.Scopes); Assert.Contains("events:read", result.Identity.Scopes); Assert.True(store.MarkedUsed); } [Fact] public async Task VerifyAsync_ValidKey_DoesNotExposeRawSecretInResult() { ApiKeySecretHasher hasher = CreateHasher("pepper"); FakeApiKeyStore store = new(CreateRecord(hasher, revokedUtc: null)); ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store); ApiKeyVerificationResult result = await verifier.VerifyAsync( "Bearer mxgw_operator01_correct-secret", CancellationToken.None); string serialized = JsonSerializer.Serialize(result); Assert.DoesNotContain("correct-secret", serialized, StringComparison.Ordinal); } [Theory] [InlineData(null)] [InlineData("Bearer mxgw_operator01")] [InlineData("Bearer wrong")] public async Task VerifyAsync_MalformedKey_FailsUnauthenticated(string? authorizationHeader) { ApiKeyVerifier verifier = new( new ApiKeyParser(), CreateHasher("pepper"), new FakeApiKeyStore(storedKey: null)); ApiKeyVerificationResult result = await verifier.VerifyAsync( authorizationHeader, CancellationToken.None); Assert.False(result.Succeeded); Assert.Equal(ApiKeyVerificationFailure.MissingOrMalformedCredentials, result.Failure); } [Fact] public async Task VerifyAsync_UnknownKey_Fails() { ApiKeyVerifier verifier = new( new ApiKeyParser(), CreateHasher("pepper"), new FakeApiKeyStore(storedKey: null)); ApiKeyVerificationResult result = await verifier.VerifyAsync( "Bearer mxgw_missing_secret", CancellationToken.None); Assert.False(result.Succeeded); Assert.Equal(ApiKeyVerificationFailure.KeyNotFound, result.Failure); } [Fact] public async Task VerifyAsync_WrongSecret_Fails() { ApiKeySecretHasher hasher = CreateHasher("pepper"); FakeApiKeyStore store = new(CreateRecord(hasher, revokedUtc: null)); ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store); ApiKeyVerificationResult result = await verifier.VerifyAsync( "Bearer mxgw_operator01_wrong-secret", CancellationToken.None); Assert.False(result.Succeeded); Assert.Equal(ApiKeyVerificationFailure.SecretMismatch, result.Failure); Assert.False(store.MarkedUsed); } [Fact] public async Task VerifyAsync_RevokedKey_Fails() { ApiKeySecretHasher hasher = CreateHasher("pepper"); FakeApiKeyStore store = new(CreateRecord(hasher, DateTimeOffset.UtcNow)); ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store); ApiKeyVerificationResult result = await verifier.VerifyAsync( "Bearer mxgw_operator01_correct-secret", CancellationToken.None); Assert.False(result.Succeeded); Assert.Equal(ApiKeyVerificationFailure.KeyRevoked, result.Failure); Assert.False(store.MarkedUsed); } [Fact] public async Task VerifyAsync_MissingPepper_Fails() { FakeApiKeyStore store = new(CreateRecord(CreateHasher("pepper"), revokedUtc: null)); ApiKeyVerifier verifier = new(new ApiKeyParser(), CreateHasher(pepper: null), store); ApiKeyVerificationResult result = await verifier.VerifyAsync( "Bearer mxgw_operator01_correct-secret", CancellationToken.None); Assert.False(result.Succeeded); Assert.Equal(ApiKeyVerificationFailure.PepperUnavailable, result.Failure); } private static ApiKeyRecord CreateRecord(ApiKeySecretHasher hasher, DateTimeOffset? revokedUtc) { return new ApiKeyRecord( KeyId: "operator01", KeyPrefix: "mxgw_operator01", SecretHash: hasher.HashSecret("correct-secret"), DisplayName: "Operator Key", Scopes: new HashSet(StringComparer.Ordinal) { "session:open", "events:read" }, CreatedUtc: DateTimeOffset.UtcNow, LastUsedUtc: null, RevokedUtc: revokedUtc); } private static ApiKeySecretHasher CreateHasher(string? pepper) { Dictionary values = []; if (pepper is not null) { values["TestPepper"] = pepper; } IConfigurationRoot configuration = new ConfigurationBuilder() .AddInMemoryCollection(values) .Build(); GatewayOptions options = new() { Authentication = new AuthenticationOptions { PepperSecretName = "TestPepper" } }; return new ApiKeySecretHasher(configuration, Options.Create(options)); } private sealed class FakeApiKeyStore(ApiKeyRecord? storedKey) : IApiKeyStore { public bool MarkedUsed { get; private set; } public Task FindByKeyIdAsync(string keyId, CancellationToken cancellationToken) { return Task.FromResult(storedKey?.KeyId == keyId ? storedKey : null); } public Task FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken) { return Task.FromResult( storedKey?.KeyId == keyId && storedKey.RevokedUtc is null ? storedKey : null); } public Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken) { MarkedUsed = storedKey?.KeyId == keyId; return Task.CompletedTask; } } }