b211911f98
v2-ci / unit-tests (push) Failing after 13m22s
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
172 lines
7.7 KiB
C#
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;
|
|
}
|
|
}
|