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; /// /// 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). /// 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, TestContext.Current.CancellationToken)).ShouldBeNull(); (await sut.FindAsync("k-guess-2", T0.UtcDateTime, TestContext.Current.CancellationToken)).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, TestContext.Current.CancellationToken)).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, TestContext.Current.CancellationToken)).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, TestContext.Current.CancellationToken)).ShouldBeNull(); inner.FindCalls.ShouldBe(0); clock.Advance(TimeSpan.FromSeconds(3)); (await sut.FindAsync("k-new", clock.GetUtcNow().UtcDateTime, TestContext.Current.CancellationToken)).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, TestContext.Current.CancellationToken)).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, TestContext.Current.CancellationToken)).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, TestContext.Current.CancellationToken)).ShouldBeNull(); inner.FindCalls.ShouldBe(1); } /// With no id set available, misses are rate-limited; hits are not charged; time refills. [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, TestContext.Current.CancellationToken)).ShouldBeNull(); inner.FindCalls.ShouldBe(3); // Budget exhausted: refused without a store read, as a throw the authenticator denies on. await Should.ThrowAsync(() => 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, TestContext.Current.CancellationToken)).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, TestContext.Current.CancellationToken)).ShouldNotBeNull(); (await sut.FindAsync("k-guess-11", clock.GetUtcNow().UtcDateTime, TestContext.Current.CancellationToken)).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, TestContext.Current.CancellationToken)).ShouldBeNull(); (await sut.FindAsync("k-guess", T0.UtcDateTime, TestContext.Current.CancellationToken)).ShouldBeNull(); inner.FindCalls.ShouldBe(2); inner.ListCalls.ShouldBe(0); } /// /// 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 KnownIdSet = false 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. /// [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.Instance, clock, guard); (await first.FindAsync("k-guess", T0.UtcDateTime, TestContext.Current.CancellationToken)).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.Instance, clock, guard); (await second.FindAsync("k-guess", T0.UtcDateTime, TestContext.Current.CancellationToken)).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.Instance, clock, new ApiKeyStoreGuardOptions { MissBurst = missBurst, MissRefillPerSecond = refillPerSecond }); private sealed class ListingStore(params string[] ids) : IApiKeyStore { private readonly HashSet _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 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> FindGrantsAsync(string principal, CancellationToken ct) => Task.FromResult>([]); public Task?> ListKeyIdsAsync(CancellationToken ct) { if (!SupportsListing) return Task.FromResult?>(null); Interlocked.Increment(ref _listCalls); if (ListThrows) throw new InvalidOperationException("ConfigDb unreachable (simulated)"); return Task.FromResult?>(_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); } }