Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/CachingApiKeyVerifierTests.cs
T
Joseph Doherty dc2df628e3 chore(followups): reviewer-recommended tests, comments, and hardening from the remediation reviews
The remediation reviews approved every task but left a tail of small notes.
This lands the gateway-side half of them.

Hardening (behavior changes, all narrow):

- BuildFilteredWriteBulkCommand's unreachable default case failed OPEN: a
  fifth bulk-write kind added upstream without a filter case here would have
  shipped the DENIED entries to the worker while reporting them denied to the
  caller. It now throws UnreachableException.
- SqliteCanonicalAuditStore.ListRecentAsync no longer throws on a row it
  cannot date. The retention sweep deliberately preserves such rows (SQLite's
  datetime() yields NULL, so the DELETE never matches), which guaranteed the
  dashboard's recent-audit view would meet one eventually and lose the whole
  page to it. The row is now reported at DateTimeOffset.MinValue with every
  other column intact, behind an optional logger.
- The audit drain loop's finally now completes the channel writer alongside
  detaching the drain, so a producer that raced past the attached check takes
  the write-through branch instead of stranding its event in a buffer nobody
  reads until shutdown. TryComplete is idempotent, so StopAsync is unaffected.

Tests:

- MapCommandReply ownership (Assert.Same on the inner reply), mirroring the
  existing MapEvent ownership test.
- Redactor key-id length boundary at exactly 64 and 65 characters, pinning
  which way it fails. Nothing validates key-id length at creation, so
  docs/Diagnostics.md's "which no issued key id does" is now stated as the
  heuristic it is.
- ApiKeyFailureLimiter.Reset with a PartitionResolution whose partition was
  evicted between the Check and the Reset: inert, and clears nobody else's
  block.
- Constraint-cache concurrency stress: the cap is enforced by the inserting
  thread, so overshoot must be transient and proportional to the in-flight
  inserters, and the cache must settle at or under the cap.
- ListRecentAsync against a raw-SQL undateable row.

Comment/doc accuracy:

- EventsHubViewerRegistry.ReleaseConnection records that it relies on
  SignalR's default sequential per-connection dispatch
  (MaximumParallelInvocationsPerClient = 1).
- A PERF(followup) note on Invoke's double session resolve and why removing it
  needs a SessionManager overload.
- SessionEventDistributor: the volatile-field comment named the pump as the
  lock-free reader, but the pump's single capture point is inside _replayLock;
  the genuinely lock-free reader is SubscriberCount. OnSubscriberOverflow's
  "cannot be observed here" now excepts the DisposeAsync abandon path. The
  churn test names its ConcurrentDictionary bucket-order assumption and that a
  violation surfaces as a read timeout, not a silent pass.
- The two "restores the sequential drain's behavior" claims (SessionManager,
  docs/Sessions.md) were wrong: the sequential drain leaked too, because
  KillWorkerAsync's entry ThrowIfCancellationRequested aborted the whole loop
  on the first session for zero kills. Reworded to "fixes a leak the
  sequential drain also had", with the sweep-bound/shutdown-unbound
  ParallelOptions asymmetry explained.
- ISessionManager.ShutdownAsync's token doc: it degrades the drain to a kill
  sweep rather than cancelling it, with the bounded overrun stated.
  SessionShutdownHostedService.StopAsync records that its cancellation-logging
  branch is now unreachable.
2026-08-15 17:54:31 -04:00

441 lines
21 KiB
C#

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;
/// <summary>
/// Hot-path decorators. Covers all three mechanisms: <see cref="CachingApiKeyVerifier"/>
/// (read/verification coalescing plus revoke/rotate invalidation),
/// <see cref="CoalescingMarkApiKeyStore"/> (the <c>last_used</c> write coalescing that keeps the
/// per-RPC database write off the throughput ceiling), and the constraint-blob cache inside
/// <see cref="GatewayApiKeyIdentityMapper"/> (which keeps the per-RPC constraints JSON parse off
/// the authenticated path).
/// </summary>
public sealed class CachingApiKeyVerifierTests
{
private const string Header = "Bearer mxgw_operator01_super-secret";
/// <summary>A cache hit within the TTL returns the cached result and never calls the inner verifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Different presented secrets are cached under distinct keys (no cross-secret aliasing).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Failed verifications are never cached; every attempt reaches the inner verifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>A TTL of zero disables caching: the inner verifier is called on every request.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Invalidating a key id (revoke/rotate) drops its cached verification, forcing a re-verify.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// SEC-34 window 3: an <see cref="IApiKeyCacheInvalidator.Invalidate"/> 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).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<ApiKeyVerification> 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);
}
/// <summary>
/// The store decorator coalesces repeated <c>MarkUsed</c> writes for the same key inside the
/// window down to a single forwarded write — the ≤1/min guarantee for <c>last_used_utc</c>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>After the window elapses the next mark is forwarded again (staleness is bounded, not frozen).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Distinct keys are coalesced independently.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>A zero window disables coalescing: every mark is forwarded.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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 <c>Split('_')</c> 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 <em>id</em> here would disarm the guard, whereas
/// an over-long one merely fails to match any generation.
/// </summary>
/// <param name="authorizationHeader">The presented header value.</param>
/// <param name="expected">The key id the parse must yield, or <see langword="null"/>.</param>
[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));
}
/// <summary>The guard parse applies no key-id length cap: an over-long id is still returned whole.</summary>
[Fact]
public void TryParseKeyId_LongKeyId_ReturnedWhole()
{
string keyId = new('a', 65);
Assert.Equal(keyId, CachingApiKeyVerifier.TryParseKeyId($"Bearer mxgw_{keyId}_secret"));
}
/// <summary>
/// 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 <see cref="ApiKeyConstraints"/> instance, a re-parsed one does not.
/// </summary>
[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));
}
/// <summary>
/// Stress: the constraint cache's bound is enforced by the inserting thread itself
/// (<c>GetOrAdd</c>, 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 <c>GetOrAdd</c>
/// loses) and the growth path (a distinct blob per iteration, which is what forces
/// eviction).
/// </summary>
[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<string>(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<string>(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
{
/// <summary>Gets the number of times <see cref="VerifyAsync"/> has been called.</summary>
public int CallCount { get; private set; }
/// <summary>Records the call and returns the fixed <paramref name="result"/> supplied at construction.</summary>
/// <param name="authorizationHeader">The authorization header presented by the caller.</param>
/// <param name="ct">A token to observe for cancellation.</param>
/// <returns>The fixed verification result.</returns>
public Task<ApiKeyVerification> 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);
/// <summary>Gets the number of times <see cref="VerifyAsync"/> has been called.</summary>
public int CallCount { get; private set; }
/// <summary>Completes once the first call has entered and parked in the inner verifier.</summary>
public Task Entered => _entered.Task;
/// <summary>Unparks the first (gated) call.</summary>
public void Release() => _release.TrySetResult();
/// <summary>Records the call; the first call parks on the gate, later calls return immediately.</summary>
/// <param name="authorizationHeader">The authorization header presented by the caller.</param>
/// <param name="ct">A token to observe for cancellation.</param>
/// <returns>The fixed verification result.</returns>
public async Task<ApiKeyVerification> 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
{
/// <summary>Gets the number of times <see cref="MarkUsedAsync"/> has been called.</summary>
public int MarkUsedCount { get; private set; }
/// <summary>Always returns <see langword="null"/>; not exercised by these tests.</summary>
/// <param name="keyId">The key id to look up.</param>
/// <param name="ct">A token to observe for cancellation.</param>
/// <returns><see langword="null"/>.</returns>
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
=> Task.FromResult<ApiKeyRecord?>(null);
/// <summary>Always returns <see langword="null"/>; not exercised by these tests.</summary>
/// <param name="keyId">The key id to look up.</param>
/// <param name="ct">A token to observe for cancellation.</param>
/// <returns><see langword="null"/>.</returns>
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct)
=> Task.FromResult<ApiKeyRecord?>(null);
/// <summary>Records the call by incrementing <see cref="MarkUsedCount"/>.</summary>
/// <param name="keyId">The key id that was used.</param>
/// <param name="whenUtc">The UTC timestamp of use.</param>
/// <param name="ct">A token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
{
MarkUsedCount++;
return Task.CompletedTask;
}
}
}