Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/BoundedApiKeyStorePerEndpointTests.cs
T
Joseph Doherty b211911f98
v2-ci / unit-tests (push) Failing after 13m22s
test: migrate AdminUI/ControlPlane/Runtime/Host tests to xunit.v3 (#756)
Akka.TestKit.Xunit2 -> Akka.TestKit.Xunit 1.5.62 (xunit.v3 TestKit; needs xunit.v3 3.2.2, set via
VersionOverride on the four projects). Removes the xunit 2.9.3 pin. IAsyncLifetime returns ValueTask,
SqlServerFactAttribute gains the source-info ctor, and xUnit1051 call sites pass
TestContext.Current.CancellationToken. Test counts unchanged.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 08:57:33 -05:00

172 lines
7.7 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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");
/// <summary>
/// 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.
/// </summary>
[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<InvalidOperationException>(
() => 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);
}
/// <summary>The process-wide bucket still bounds the total across many clients (and unknown-address callers).</summary>
[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<InvalidOperationException>(
() => sut.FindAsync("k-c", T0.UtcDateTime, Quiet, default));
refused.Message.ShouldContain("MissRefillPerSecond");
refused.Message.ShouldNotContain("PerEndpoint");
inner.FindCalls.ShouldBe(2);
}
/// <summary>A hit is not a miss: a client presenting a real key keeps its whole budget; time refills it.</summary>
[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<InvalidOperationException>(() => 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);
}
/// <summary><c>MissBurstPerEndpoint = 0</c> restores process-wide-only accounting.</summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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<ApiKeyOpcUaUserAuthenticator>.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<BoundedApiKeyStore>.Instance,
clock,
new ApiKeyStoreGuardOptions
{
MissBurst = missBurst,
MissRefillPerSecond = 0,
MissBurstPerEndpoint = perEndpointBurst,
MissRefillPerSecondPerEndpoint = perEndpointRefill,
});
/// <summary>A store that cannot list ids, so every lookup runs under the miss budget.</summary>
private sealed class UnlistingStore(params string[] ids) : IApiKeyStore
{
private readonly HashSet<string> _ids = ids.ToHashSet(StringComparer.Ordinal);
private int _findCalls;
public int FindCalls => Volatile.Read(ref _findCalls);
/// <summary>The address the last four-argument lookup was made for.</summary>
public IPAddress? LastAddress { get; private set; }
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<ApiKeyLookup?> FindAsync(
string apiKeyId, DateTime utcNow, IPAddress? remoteAddress, CancellationToken ct)
{
LastAddress = remoteAddress;
return FindAsync(apiKeyId, utcNow, ct);
}
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<NodeAcl>>([]);
}
private sealed class FakeClock(DateTimeOffset utcNow) : TimeProvider
{
private DateTimeOffset _now = utcNow;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now += by;
}
}