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