using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Time.Testing; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity; namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication; /// /// Hot-path decorators. Covers all three mechanisms: /// (read/verification coalescing plus revoke/rotate invalidation), /// (the last_used write coalescing that keeps the /// per-RPC database write off the throughput ceiling), and the constraint-blob cache inside /// (which keeps the per-RPC constraints JSON parse off /// the authenticated path). /// public sealed class CachingApiKeyVerifierTests { private const string Header = "Bearer mxgw_operator01_super-secret"; /// A cache hit within the TTL returns the cached result and never calls the inner verifier. /// A task that represents the asynchronous operation. [Fact] public async Task VerifyAsync_RepeatedWithinTtl_CallsInnerOnce() { FakeVerifier inner = new(Success("operator01")); using MemoryCache cache = NewCache(); CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(15)); ApiKeyVerification first = await verifier.VerifyAsync(Header, CancellationToken.None); ApiKeyVerification second = await verifier.VerifyAsync(Header, CancellationToken.None); Assert.True(first.Succeeded); Assert.True(second.Succeeded); Assert.Equal(1, inner.CallCount); } /// Different presented secrets are cached under distinct keys (no cross-secret aliasing). /// A task that represents the asynchronous operation. [Fact] public async Task VerifyAsync_DifferentTokens_NotAliased() { FakeVerifier inner = new(Success("operator01")); using MemoryCache cache = NewCache(); CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(15)); await verifier.VerifyAsync("Bearer mxgw_operator01_secret-a", CancellationToken.None); await verifier.VerifyAsync("Bearer mxgw_operator01_secret-b", CancellationToken.None); Assert.Equal(2, inner.CallCount); } /// Failed verifications are never cached; every attempt reaches the inner verifier. /// A task that represents the asynchronous operation. [Fact] public async Task VerifyAsync_FailedVerification_NotCached() { FakeVerifier inner = new(Failure(ApiKeyFailure.SecretMismatch)); using MemoryCache cache = NewCache(); CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(15)); await verifier.VerifyAsync(Header, CancellationToken.None); await verifier.VerifyAsync(Header, CancellationToken.None); Assert.Equal(2, inner.CallCount); } /// A TTL of zero disables caching: the inner verifier is called on every request. /// A task that represents the asynchronous operation. [Fact] public async Task VerifyAsync_ZeroTtl_DisablesCache() { FakeVerifier inner = new(Success("operator01")); using MemoryCache cache = NewCache(); CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.Zero); await verifier.VerifyAsync(Header, CancellationToken.None); await verifier.VerifyAsync(Header, CancellationToken.None); Assert.Equal(2, inner.CallCount); } /// Invalidating a key id (revoke/rotate) drops its cached verification, forcing a re-verify. /// A task that represents the asynchronous operation. [Fact] public async Task Invalidate_DropsCachedEntry_ForcesReverify() { FakeVerifier inner = new(Success("operator01")); using MemoryCache cache = NewCache(); CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(30)); await verifier.VerifyAsync(Header, CancellationToken.None); Assert.Equal(1, inner.CallCount); ((IApiKeyCacheInvalidator)verifier).Invalidate("operator01"); await verifier.VerifyAsync(Header, CancellationToken.None); Assert.Equal(2, inner.CallCount); } /// /// SEC-34 window 3: an that lands while a /// verification is in flight in the inner library must discard that verification's repopulation, /// so the very next request re-verifies (revoke takes effect immediately, not after the TTL). /// /// A task that represents the asynchronous operation. [Fact] public async Task Invalidate_DuringInFlightVerification_DiscardsStaleRepopulation() { GatedVerifier inner = new(Success("operator01")); using MemoryCache cache = NewCache(); CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(30)); // Begin a verification and wait until it is parked inside the inner verifier. Task inFlight = verifier.VerifyAsync(Header, CancellationToken.None); await inner.Entered; // Revoke while the verify is still awaiting the inner library. ((IApiKeyCacheInvalidator)verifier).Invalidate("operator01"); // Release the inner verifier; the in-flight call completes but must NOT cache its result. inner.Release(); ApiKeyVerification result = await inFlight; Assert.True(result.Succeeded); // The follow-up request finds no cached entry and reaches the inner verifier again. await verifier.VerifyAsync(Header, CancellationToken.None); Assert.Equal(2, inner.CallCount); } /// /// The store decorator coalesces repeated MarkUsed writes for the same key inside the /// window down to a single forwarded write — the ≤1/min guarantee for last_used_utc. /// /// A task that represents the asynchronous operation. [Fact] public async Task CoalescingStore_RepeatedMarksWithinWindow_ForwardsOnce() { FakeStore inner = new(); FakeTimeProvider clock = new(DateTimeOffset.UtcNow); CoalescingMarkApiKeyStore store = new(inner, TimeSpan.FromMinutes(1), clock); // Ten rapid authenticated calls in the same minute. for (int i = 0; i < 10; i++) { await store.MarkUsedAsync("operator01", clock.GetUtcNow(), CancellationToken.None); clock.Advance(TimeSpan.FromSeconds(5)); } Assert.Equal(1, inner.MarkUsedCount); } /// After the window elapses the next mark is forwarded again (staleness is bounded, not frozen). /// A task that represents the asynchronous operation. [Fact] public async Task CoalescingStore_AfterWindow_ForwardsAgain() { FakeStore inner = new(); FakeTimeProvider clock = new(DateTimeOffset.UtcNow); CoalescingMarkApiKeyStore store = new(inner, TimeSpan.FromMinutes(1), clock); await store.MarkUsedAsync("operator01", clock.GetUtcNow(), CancellationToken.None); clock.Advance(TimeSpan.FromSeconds(61)); await store.MarkUsedAsync("operator01", clock.GetUtcNow(), CancellationToken.None); Assert.Equal(2, inner.MarkUsedCount); } /// Distinct keys are coalesced independently. /// A task that represents the asynchronous operation. [Fact] public async Task CoalescingStore_DistinctKeys_TrackedSeparately() { FakeStore inner = new(); FakeTimeProvider clock = new(DateTimeOffset.UtcNow); CoalescingMarkApiKeyStore store = new(inner, TimeSpan.FromMinutes(1), clock); await store.MarkUsedAsync("key-a", clock.GetUtcNow(), CancellationToken.None); await store.MarkUsedAsync("key-b", clock.GetUtcNow(), CancellationToken.None); await store.MarkUsedAsync("key-a", clock.GetUtcNow(), CancellationToken.None); Assert.Equal(2, inner.MarkUsedCount); } /// A zero window disables coalescing: every mark is forwarded. /// A task that represents the asynchronous operation. [Fact] public async Task CoalescingStore_ZeroWindow_ForwardsEveryMark() { FakeStore inner = new(); FakeTimeProvider clock = new(DateTimeOffset.UtcNow); CoalescingMarkApiKeyStore store = new(inner, TimeSpan.Zero, clock); await store.MarkUsedAsync("operator01", clock.GetUtcNow(), CancellationToken.None); await store.MarkUsedAsync("operator01", clock.GetUtcNow(), CancellationToken.None); Assert.Equal(2, inner.MarkUsedCount); } /// /// Pins the header parse that arms the revoke-vs-in-flight generation guard. It runs on every /// cache miss and is written to allocate only the returned key id; these cases hold it to the /// rules the original Split('_') form applied. Note this parse is deliberately laxer than /// the interceptor's partition parse — it applies no key-id length cap and does not require a /// non-empty third segment — because a wrong id here would disarm the guard, whereas /// an over-long one merely fails to match any generation. /// /// The presented header value. /// The key id the parse must yield, or . [Theory] [InlineData(null, null)] [InlineData("", null)] [InlineData(" ", null)] [InlineData("Bearer", null)] [InlineData("Bearer ", null)] [InlineData("Bearer mxgw_operator01_super-secret", "operator01")] [InlineData("bearer mxgw_operator01_super-secret", "operator01")] [InlineData(" Bearer mxgw_operator01_super-secret ", "operator01")] [InlineData("mxgw_operator01_super-secret", "operator01")] [InlineData("Bearer mxgw_abc_sec_ret", "abc")] [InlineData("Bearer mxgw_a_b_c", "a")] // Laxer than the interceptor: an empty or absent third segment still yields the key id. [InlineData("Bearer mxgw_abc_", "abc")] [InlineData("Bearer mxgw_abc__secret", "abc")] [InlineData("Bearer mxgw_abc", null)] [InlineData("Bearer mxgwabcsecret", null)] [InlineData("Bearer mxgw__secret", null)] [InlineData("Bearer _mxgw_abc_secret", null)] [InlineData("Bearer MXGW_abc_secret", null)] [InlineData("Bearer xmxgw_abc_secret", null)] [InlineData("Bearer mxgw", null)] [InlineData("Bearer mxgw_", null)] [InlineData("Bearer ___", null)] public void TryParseKeyId_MatchesTokenShapeRules(string? authorizationHeader, string? expected) { Assert.Equal(expected, CachingApiKeyVerifier.TryParseKeyId(authorizationHeader)); } /// The guard parse applies no key-id length cap: an over-long id is still returned whole. [Fact] public void TryParseKeyId_LongKeyId_ReturnedWhole() { string keyId = new('a', 65); Assert.Equal(keyId, CachingApiKeyVerifier.TryParseKeyId($"Bearer mxgw_{keyId}_secret")); } /// /// The mapper's constraint-blob cache is bounded by eviction, not by a hard stop at the cap: /// once it is full the oldest entry is dropped so a newly-seen blob is still cached. A cache /// that merely stopped accepting entries would re-parse every blob beyond the cap on every /// single RPC, forever. Asserted behaviourally through instance identity — a cached blob maps /// to the same instance, a re-parsed one does not. /// [Fact] public void ToGatewayIdentity_ConstraintCacheOverCapacity_EvictsOldestAndKeepsCaching() { string firstJson = ConstraintsJson("Area_FifoProbe"); ApiKeyConstraints first = MapConstraints(firstJson); Assert.Same(first, MapConstraints(firstJson)); // Push strictly more than the cap through the cache after the probe blob, so FIFO eviction // is guaranteed to have reached it however full the (process-wide) cache already was. for (int i = 0; i < GatewayApiKeyIdentityMapper.MaxCachedConstraintBlobs + 8; i++) { MapConstraints(ConstraintsJson($"Area_FifoFlood_{i}")); } Assert.True( GatewayApiKeyIdentityMapper.CurrentCacheSize <= GatewayApiKeyIdentityMapper.MaxCachedConstraintBlobs, $"cache grew to {GatewayApiKeyIdentityMapper.CurrentCacheSize} entries, past the {GatewayApiKeyIdentityMapper.MaxCachedConstraintBlobs} cap"); // Evicted, so the probe blob is parsed afresh... ApiKeyConstraints reparsed = MapConstraints(firstJson); Assert.NotSame(first, reparsed); Assert.Equal(first.ReadSubtrees, reparsed.ReadSubtrees); // ...and re-cached, rather than re-parsed on every later call. Assert.Same(reparsed, MapConstraints(firstJson)); } /// /// Stress: the constraint cache's bound is enforced by the inserting thread itself /// (GetOrAdd, then enqueue, then evict), so concurrent inserters can each land an /// entry before any of them reaches the eviction step. That overshoot is real but must be /// transient and proportional to the in-flight inserters — not unbounded growth — and once /// the churn stops the cache must be back at or under the cap. Hammered on both the /// converging path (every iteration maps the same blob, so all but one GetOrAdd /// loses) and the growth path (a distinct blob per iteration, which is what forces /// eviction). /// [Fact] public void ToGatewayIdentity_ConcurrentBlobs_OvershootIsTransientAndCacheSettlesUnderCap() { const int cap = GatewayApiKeyIdentityMapper.MaxCachedConstraintBlobs; // Overshoot is bounded by how many inserters can sit between their GetOrAdd and their own // EvictIfOverCapacity, so scale the allowance with the available parallelism rather than // pinning a magic number. Generous on purpose: the assertion under test is "bounded", not // "bounded by exactly this". int transientAllowance = (Environment.ProcessorCount * 8) + 64; string sharedJson = ConstraintsJson("Area_StressShared"); int peak = 0; Parallel.For(0, (cap * 2) + 64, index => { MapConstraints(sharedJson); MapConstraints(ConstraintsJson($"Area_Stress_{index}")); int size = GatewayApiKeyIdentityMapper.CurrentCacheSize; int seen = Volatile.Read(ref peak); while (size > seen && Interlocked.CompareExchange(ref peak, size, seen) != seen) { seen = Volatile.Read(ref peak); } }); Assert.True( peak <= cap + transientAllowance, $"cache peaked at {peak} entries, past the {cap} cap plus the {transientAllowance} transient allowance"); // The cache is process-wide static and other test classes in this assembly map identities // too, so poll rather than asserting on the instant the loop returns. Assert.True( SpinWait.SpinUntil( () => GatewayApiKeyIdentityMapper.CurrentCacheSize <= cap, TimeSpan.FromSeconds(5)), $"cache settled at {GatewayApiKeyIdentityMapper.CurrentCacheSize} entries, past the {cap} cap"); } private static ApiKeyConstraints MapConstraints(string constraintsJson) => GatewayApiKeyIdentityMapper.ToGatewayIdentity(new LibApiKeyIdentity( KeyId: "operator01", DisplayName: "Operator Key", Scopes: new HashSet(StringComparer.Ordinal), Constraints: constraintsJson)).EffectiveConstraints; private static string ConstraintsJson(string readSubtree) => ApiKeyConstraintSerializer.Serialize(ApiKeyConstraints.Empty with { ReadSubtrees = [readSubtree] })!; private static MemoryCache NewCache() => new(new MemoryCacheOptions()); private static ApiKeyVerification Success(string keyId) => new( Succeeded: true, Identity: new LibApiKeyIdentity( KeyId: keyId, DisplayName: "Operator Key", Scopes: new HashSet(StringComparer.Ordinal), Constraints: null), Failure: null); private static ApiKeyVerification Failure(ApiKeyFailure failure) => new(Succeeded: false, Identity: null, Failure: failure); private sealed class FakeVerifier(ApiKeyVerification result) : IApiKeyVerifier { /// Gets the number of times has been called. public int CallCount { get; private set; } /// Records the call and returns the fixed supplied at construction. /// The authorization header presented by the caller. /// A token to observe for cancellation. /// The fixed verification result. public Task VerifyAsync(string authorizationHeader, CancellationToken ct) { CallCount++; return Task.FromResult(result); } } // A verifier that parks inside VerifyAsync until Release() is called, so a test can interleave an // Invalidate with an in-flight verification. private sealed class GatedVerifier(ApiKeyVerification result) : IApiKeyVerifier { private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); /// Gets the number of times has been called. public int CallCount { get; private set; } /// Completes once the first call has entered and parked in the inner verifier. public Task Entered => _entered.Task; /// Unparks the first (gated) call. public void Release() => _release.TrySetResult(); /// Records the call; the first call parks on the gate, later calls return immediately. /// The authorization header presented by the caller. /// A token to observe for cancellation. /// The fixed verification result. public async Task VerifyAsync(string authorizationHeader, CancellationToken ct) { bool first = CallCount == 0; CallCount++; if (first) { _entered.TrySetResult(); await _release.Task.ConfigureAwait(false); } return result; } } private sealed class FakeStore : IApiKeyStore { /// Gets the number of times has been called. public int MarkUsedCount { get; private set; } /// Always returns ; not exercised by these tests. /// The key id to look up. /// A token to observe for cancellation. /// . public Task FindByKeyIdAsync(string keyId, CancellationToken ct) => Task.FromResult(null); /// Always returns ; not exercised by these tests. /// The key id to look up. /// A token to observe for cancellation. /// . public Task FindActiveByKeyIdAsync(string keyId, CancellationToken ct) => Task.FromResult(null); /// Records the call by incrementing . /// The key id that was used. /// The UTC timestamp of use. /// A token to observe for cancellation. /// A task that represents the asynchronous operation. public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct) { MarkUsedCount++; return Task.CompletedTask; } } }