using System.Net; 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; /// /// lmxopcua#751 — the pre-authentication miss budget was process-wide only, so one noisy client could drain /// it for everybody while the known-id set was unavailable. Misses are now charged to the client's own /// bucket first (keyed by IP address), with the process-wide bucket kept as the backstop. /// public sealed class BoundedApiKeyStorePerEndpointTests { private static readonly DateTimeOffset T0 = new(2026, 11, 9, 12, 0, 0, TimeSpan.Zero); private static readonly IPAddress Noisy = IPAddress.Parse("10.0.0.66"); private static readonly IPAddress Quiet = IPAddress.Parse("10.0.0.7"); /// /// THE property: a noisy client exhausts its OWN budget and is refused without a store read, while a /// different client's unknown id still reaches the store — the process-wide budget was not drained. /// [Fact] public async Task A_noisy_client_exhausts_its_own_budget_without_draining_everyone_elses() { var inner = new UnlistingStore("k-real"); var sut = Bounded(inner, new FakeClock(T0), missBurst: 100, perEndpointBurst: 3); for (var i = 0; i < 3; i++) (await sut.FindAsync($"k-guess-{i}", T0.UtcDateTime, Noisy, TestContext.Current.CancellationToken)).ShouldBeNull(); inner.FindCalls.ShouldBe(3); var refused = await Should.ThrowAsync( () => sut.FindAsync("k-guess-x", T0.UtcDateTime, Noisy, default)); refused.Message.ShouldContain("MissBurstPerEndpoint"); inner.FindCalls.ShouldBe(3, "the exhausted client is refused without a store read"); // Positive control: another client still reaches the store. (await sut.FindAsync("k-guess-q", T0.UtcDateTime, Quiet, TestContext.Current.CancellationToken)).ShouldBeNull(); inner.FindCalls.ShouldBe(4); } /// The process-wide bucket still bounds the total across many clients (and unknown-address callers). [Fact] public async Task The_process_wide_budget_stays_the_backstop() { var inner = new UnlistingStore("k-real"); var sut = Bounded(inner, new FakeClock(T0), missBurst: 2, perEndpointBurst: 10); (await sut.FindAsync("k-a", T0.UtcDateTime, Noisy, TestContext.Current.CancellationToken)).ShouldBeNull(); (await sut.FindAsync("k-b", T0.UtcDateTime, remoteAddress: null, TestContext.Current.CancellationToken)).ShouldBeNull(); var refused = await Should.ThrowAsync( () => sut.FindAsync("k-c", T0.UtcDateTime, Quiet, default)); refused.Message.ShouldContain("MissRefillPerSecond"); refused.Message.ShouldNotContain("PerEndpoint"); inner.FindCalls.ShouldBe(2); } /// A hit is not a miss: a client presenting a real key keeps its whole budget; time refills it. [Fact] public async Task A_hit_is_refunded_and_time_refills_a_clients_bucket() { var inner = new UnlistingStore("k-real"); var clock = new FakeClock(T0); var sut = Bounded(inner, clock, missBurst: 100, perEndpointBurst: 2, perEndpointRefill: 1); for (var i = 0; i < 5; i++) (await sut.FindAsync("k-real", T0.UtcDateTime, Noisy, TestContext.Current.CancellationToken)).ShouldNotBeNull(); (await sut.FindAsync("k-g1", T0.UtcDateTime, Noisy, TestContext.Current.CancellationToken)).ShouldBeNull(); (await sut.FindAsync("k-g2", T0.UtcDateTime, Noisy, TestContext.Current.CancellationToken)).ShouldBeNull(); await Should.ThrowAsync(() => sut.FindAsync("k-g3", T0.UtcDateTime, Noisy, default)); clock.Advance(TimeSpan.FromSeconds(1)); (await sut.FindAsync("k-g4", clock.GetUtcNow().UtcDateTime, Noisy, TestContext.Current.CancellationToken)).ShouldBeNull(); inner.FindCalls.ShouldBe(8); } /// MissBurstPerEndpoint = 0 restores process-wide-only accounting. [Fact] public async Task Per_client_accounting_can_be_switched_off() { var inner = new UnlistingStore("k-real"); var sut = Bounded(inner, new FakeClock(T0), missBurst: 100, perEndpointBurst: 0); for (var i = 0; i < 20; i++) (await sut.FindAsync($"k-guess-{i}", T0.UtcDateTime, Noisy, TestContext.Current.CancellationToken)).ShouldBeNull(); inner.FindCalls.ShouldBe(20); } /// /// The production authenticator forwards the client's address into the store — without it the per-client /// budget would never see an address and silently degrade to process-wide. /// [Fact] public async Task The_authenticator_hands_the_remote_address_to_the_store() { var store = new UnlistingStore("k-real"); var authenticator = new ApiKeyOpcUaUserAuthenticator( store, Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = "unit-test-pepper-xxxx" }), NullLogger.Instance); var result = await authenticator.AuthenticateUserNameAsync( "apikey", "otua_k-guess_secret", new IPEndPoint(Noisy, 50123), TestContext.Current.CancellationToken); result.Success.ShouldBeFalse(); store.LastAddress.ShouldBe(Noisy); } private static BoundedApiKeyStore Bounded( IApiKeyStore inner, TimeProvider clock, int missBurst, int perEndpointBurst, double perEndpointRefill = 0) => new( inner, Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = "unit-test-pepper-xxxx", StoreTimeoutMs = 0, StoreCacheSeconds = 0, }), NullLogger.Instance, clock, new ApiKeyStoreGuardOptions { MissBurst = missBurst, MissRefillPerSecond = 0, MissBurstPerEndpoint = perEndpointBurst, MissRefillPerSecondPerEndpoint = perEndpointRefill, }); /// A store that cannot list ids, so every lookup runs under the miss budget. private sealed class UnlistingStore(params string[] ids) : IApiKeyStore { private readonly HashSet _ids = ids.ToHashSet(StringComparer.Ordinal); private int _findCalls; public int FindCalls => Volatile.Read(ref _findCalls); /// The address the last four-argument lookup was made for. public IPAddress? LastAddress { get; private set; } 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 FindAsync( string apiKeyId, DateTime utcNow, IPAddress? remoteAddress, CancellationToken ct) { LastAddress = remoteAddress; return FindAsync(apiKeyId, utcNow, ct); } public Task> FindGrantsAsync(string principal, CancellationToken ct) => Task.FromResult>([]); } private sealed class FakeClock(DateTimeOffset utcNow) : TimeProvider { private DateTimeOffset _now = utcNow; public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan by) => _now += by; } }