dc3323fcca
BoundedApiKeyStore kept the set (volatile) and its DateTimeOffset stamp (non-volatile, 16 bytes) in separate fields, so a reader could pair a new set with an old stamp or read a torn stamp; they are now one immutable snapshot behind a volatile reference. An inner store that cannot list ids now switches the set off with a per-instance flag instead of writing KnownIdSet = false into the shared ApiKeyStoreGuardOptions. Refs #733 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
204 lines
9.5 KiB
C#
204 lines
9.5 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
|
|
|
/// <summary>
|
|
/// R03:3.3 — the negative cache was keyed by key id, so a flood of DISTINCT well-formed ids cost one
|
|
/// SQL query per attempt under the SessionManager lock, no credential needed. Two guards:
|
|
/// the known-id set (unknown ids refused in memory) and the miss budget (a token bucket on lookups
|
|
/// that reach the store and miss, for when the set is unavailable).
|
|
/// </summary>
|
|
public sealed class BoundedApiKeyStoreKnownIdTests
|
|
{
|
|
private static readonly DateTimeOffset T0 = new(2026, 10, 22, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
[Fact]
|
|
public async Task An_unknown_id_is_refused_without_reaching_the_store_and_a_known_one_still_does()
|
|
{
|
|
var inner = new ListingStore("k-real");
|
|
var clock = new FakeClock(T0);
|
|
var sut = Bounded(inner, clock, cacheSeconds: 5);
|
|
|
|
(await sut.FindAsync("k-guess-1", T0.UtcDateTime, default)).ShouldBeNull();
|
|
(await sut.FindAsync("k-guess-2", T0.UtcDateTime, default)).ShouldBeNull();
|
|
inner.FindCalls.ShouldBe(0, "unknown ids never reach the store");
|
|
inner.ListCalls.ShouldBe(1, "one set load serves every unknown id inside the TTL");
|
|
|
|
// Positive control: the real id reaches the store and is found.
|
|
(await sut.FindAsync("k-real", T0.UtcDateTime, default)).ShouldNotBeNull();
|
|
inner.FindCalls.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_set_is_refreshed_after_the_TTL_so_a_newly_minted_key_becomes_visible()
|
|
{
|
|
var inner = new ListingStore("k-old");
|
|
var clock = new FakeClock(T0);
|
|
var sut = Bounded(inner, clock, cacheSeconds: 5);
|
|
|
|
(await sut.FindAsync("k-new", T0.UtcDateTime, default)).ShouldBeNull();
|
|
inner.Mint("k-new");
|
|
// Still inside the TTL: the stale set refuses it (the documented window).
|
|
clock.Advance(TimeSpan.FromSeconds(3));
|
|
(await sut.FindAsync("k-new", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
|
|
inner.FindCalls.ShouldBe(0);
|
|
|
|
clock.Advance(TimeSpan.FromSeconds(3));
|
|
(await sut.FindAsync("k-new", clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
|
|
inner.ListCalls.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_failed_set_load_falls_back_to_the_per_id_lookup_and_keeps_an_older_set()
|
|
{
|
|
var inner = new ListingStore("k-real") { ListThrows = true };
|
|
var clock = new FakeClock(T0);
|
|
var sut = Bounded(inner, clock, cacheSeconds: 5);
|
|
|
|
// No set yet and the load fails: the per-id path runs (old behaviour), nothing is wedged.
|
|
(await sut.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
|
|
inner.FindCalls.ShouldBe(1);
|
|
sut.KnownKeyIds.ShouldBeNull();
|
|
|
|
inner.ListThrows = false;
|
|
clock.Advance(TimeSpan.FromSeconds(6));
|
|
(await sut.FindAsync("k-guess-2", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
|
|
sut.KnownKeyIds.ShouldNotBeNull();
|
|
inner.FindCalls.ShouldBe(1, "once the set loaded, the guess was refused in memory");
|
|
|
|
// A later failed refresh keeps the previous set in force.
|
|
inner.ListThrows = true;
|
|
clock.Advance(TimeSpan.FromSeconds(6));
|
|
(await sut.FindAsync("k-guess-3", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
|
|
inner.FindCalls.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>With no id set available, misses are rate-limited; hits are not charged; time refills.</summary>
|
|
[Fact]
|
|
public async Task The_miss_budget_bounds_store_misses_when_no_set_is_available()
|
|
{
|
|
var inner = new ListingStore("k-real") { SupportsListing = false };
|
|
var clock = new FakeClock(T0);
|
|
var sut = Bounded(inner, clock, cacheSeconds: 0, missBurst: 3, refillPerSecond: 1);
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
(await sut.FindAsync($"k-guess-{i}", T0.UtcDateTime, default)).ShouldBeNull();
|
|
inner.FindCalls.ShouldBe(3);
|
|
|
|
// Budget exhausted: refused without a store read, as a throw the authenticator denies on.
|
|
await Should.ThrowAsync<InvalidOperationException>(() => sut.FindAsync("k-guess-9", T0.UtcDateTime, default));
|
|
inner.FindCalls.ShouldBe(3);
|
|
|
|
// Refill: one second restores one token.
|
|
clock.Advance(TimeSpan.FromSeconds(1));
|
|
(await sut.FindAsync("k-guess-10", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
|
|
inner.FindCalls.ShouldBe(4);
|
|
|
|
// A hit is not a miss: it reaches the store even with the bucket empty? No — it needs a token to
|
|
// be attempted, but the token is refunded, so the next attempt still has it. Prove the refund.
|
|
clock.Advance(TimeSpan.FromSeconds(1));
|
|
(await sut.FindAsync("k-real", clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
|
|
(await sut.FindAsync("k-guess-11", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
|
|
inner.FindCalls.ShouldBe(6);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_guards_are_off_when_the_cache_is_off_restoring_a_read_per_lookup()
|
|
{
|
|
var inner = new ListingStore("k-real");
|
|
var sut = Bounded(inner, new FakeClock(T0), cacheSeconds: 0, missBurst: 0);
|
|
|
|
(await sut.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
|
|
(await sut.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
|
|
inner.FindCalls.ShouldBe(2);
|
|
inner.ListCalls.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix round 1 minor — an inner store that cannot list ids switches the known-id set off for THIS
|
|
/// decorator only. It used to do so by writing <c>KnownIdSet = false</c> into the guard options it was
|
|
/// handed, a shared options object, so one store's capability leaked into every other consumer.
|
|
/// Positive control: a second decorator over a listing store on the same options instance still uses
|
|
/// the set.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_unlisting_store_does_not_mutate_the_shared_guard_options()
|
|
{
|
|
var guard = new ApiKeyStoreGuardOptions { MissBurst = 100, MissRefillPerSecond = 20 };
|
|
var options = Options.Create(new ApiKeyOptions
|
|
{
|
|
Username = "apikey", Pepper = "unit-test-pepper-xxxx", StoreTimeoutMs = 0, StoreCacheSeconds = 5,
|
|
});
|
|
var clock = new FakeClock(T0);
|
|
var unlisting = new ListingStore("k-real") { SupportsListing = false };
|
|
var first = new BoundedApiKeyStore(unlisting, options, NullLogger<BoundedApiKeyStore>.Instance, clock, guard);
|
|
|
|
(await first.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
|
|
unlisting.FindCalls.ShouldBe(1, "no set: the per-id path ran");
|
|
guard.KnownIdSet.ShouldBeTrue("the shared options object is never written");
|
|
|
|
var listing = new ListingStore("k-real");
|
|
var second = new BoundedApiKeyStore(listing, options, NullLogger<BoundedApiKeyStore>.Instance, clock, guard);
|
|
(await second.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
|
|
listing.FindCalls.ShouldBe(0, "the second decorator still refuses unknown ids in memory");
|
|
listing.ListCalls.ShouldBe(1);
|
|
}
|
|
|
|
private static BoundedApiKeyStore Bounded(
|
|
IApiKeyStore inner, TimeProvider clock, int cacheSeconds, int missBurst = 100, double refillPerSecond = 20) => new(
|
|
inner,
|
|
Options.Create(new ApiKeyOptions
|
|
{
|
|
Username = "apikey", Pepper = "unit-test-pepper-xxxx", StoreTimeoutMs = 0, StoreCacheSeconds = cacheSeconds,
|
|
}),
|
|
NullLogger<BoundedApiKeyStore>.Instance,
|
|
clock,
|
|
new ApiKeyStoreGuardOptions { MissBurst = missBurst, MissRefillPerSecond = refillPerSecond });
|
|
|
|
private sealed class ListingStore(params string[] ids) : IApiKeyStore
|
|
{
|
|
private readonly HashSet<string> _ids = ids.ToHashSet(StringComparer.Ordinal);
|
|
private int _findCalls;
|
|
private int _listCalls;
|
|
|
|
public bool SupportsListing { get; set; } = true;
|
|
public bool ListThrows { get; set; }
|
|
public int FindCalls => _findCalls;
|
|
public int ListCalls => _listCalls;
|
|
|
|
public void Mint(string id) => _ids.Add(id);
|
|
|
|
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
|
|
{
|
|
Interlocked.Increment(ref _findCalls);
|
|
return Task.FromResult(_ids.Contains(apiKeyId)
|
|
? new ApiKeyLookup(new ApiKey { ApiKeyId = apiKeyId, Name = "k", SecretHash = "h", Principal = "p" }, [])
|
|
: null);
|
|
}
|
|
|
|
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<NodeAcl>>([]);
|
|
|
|
public Task<IReadOnlySet<string>?> ListKeyIdsAsync(CancellationToken ct)
|
|
{
|
|
if (!SupportsListing) return Task.FromResult<IReadOnlySet<string>?>(null);
|
|
Interlocked.Increment(ref _listCalls);
|
|
if (ListThrows) throw new InvalidOperationException("ConfigDb unreachable (simulated)");
|
|
return Task.FromResult<IReadOnlySet<string>?>(_ids.ToHashSet(StringComparer.Ordinal));
|
|
}
|
|
}
|
|
|
|
private sealed class FakeClock(DateTimeOffset utcNow) : TimeProvider
|
|
{
|
|
private DateTimeOffset _now = utcNow;
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
|
}
|
|
}
|