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