using System.Net;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// lmxopcua#833 — the UserName-only lockout that replaces the SDK's all-token lockout.
public sealed class FailedCredentialLockoutTests
{
private static readonly string Client = FailedCredentialLockout.KeyFor("THUMB", IPAddress.Parse("10.0.0.5"));
[Fact]
public void Locks_at_the_threshold_and_releases_after_the_lockout()
{
var clock = new ManualClock();
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client).ShouldBeFalse();
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
lockout.RecordFailure(Client).ShouldBeTrue("the fifth failure locks");
lockout.IsLockedOut(Client, out var remaining).ShouldBeTrue();
remaining.ShouldBe(TimeSpan.FromMinutes(5));
clock.Advance(TimeSpan.FromSeconds(299));
lockout.IsLockedOut(Client, out _).ShouldBeTrue();
clock.Advance(TimeSpan.FromSeconds(1));
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
lockout.RecordFailure(Client).ShouldBeFalse("a lockout that ran out starts a fresh count");
}
[Fact]
public void A_refusal_during_the_lockout_does_not_extend_it()
{
var clock = new ManualClock();
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, clock);
lockout.RecordFailure(Client).ShouldBeTrue();
clock.Advance(TimeSpan.FromSeconds(200));
lockout.RecordFailure(Client).ShouldBeFalse();
clock.Advance(TimeSpan.FromSeconds(100));
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
}
[Fact]
public void Failures_spaced_wider_than_the_window_never_accumulate()
{
var clock = new ManualClock();
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
for (var i = 0; i < 10; i++)
{
lockout.RecordFailure(Client).ShouldBeFalse();
clock.Advance(TimeSpan.FromSeconds(61));
}
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
}
[Fact]
public void A_success_clears_that_identitys_count()
{
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), new ManualClock());
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client);
lockout.RecordSuccess(Client);
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client).ShouldBeFalse();
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
}
[Fact]
public void The_key_needs_both_certificate_and_address_so_one_client_cannot_lock_out_another()
{
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, new ManualClock());
var attacker = FailedCredentialLockout.KeyFor("VICTIMTHUMB", IPAddress.Parse("10.0.0.99"));
var victim = FailedCredentialLockout.KeyFor("VICTIMTHUMB", IPAddress.Parse("10.0.0.5"));
var natNeighbour = FailedCredentialLockout.KeyFor("OTHERTHUMB", IPAddress.Parse("10.0.0.99"));
lockout.RecordFailure(attacker).ShouldBeTrue();
lockout.IsLockedOut(victim, out _).ShouldBeFalse("same certificate, different address");
lockout.IsLockedOut(natNeighbour, out _).ShouldBeFalse("same address, different certificate");
}
[Fact]
public void Zero_attempts_disables_it()
{
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 0 }, new ManualClock());
for (var i = 0; i < 50; i++) lockout.RecordFailure(Client).ShouldBeFalse();
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
lockout.TrackedCount.ShouldBe(0);
}
[Fact]
public void Tracking_is_bounded_and_prunes_expired_clients_first()
{
var clock = new ManualClock();
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
for (var i = 0; i < FailedCredentialLockout.MaxTrackedClients; i++) lockout.RecordFailure($"c{i}");
lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients);
lockout.RecordFailure("overflow");
lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients, "full and nothing expired ⇒ not tracked");
clock.Advance(TimeSpan.FromSeconds(61));
lockout.RecordFailure("overflow");
lockout.TrackedCount.ShouldBe(1, "expired entries are pruned to make room");
}
private static CancellationToken Ct => TestContext.Current.CancellationToken;
///
/// #833 review — a legitimate client opening many keyed sessions at once (a ScadaBridge site node behind one
/// NAT IP) must never be refused for parallelism: at most MaxFailedAttempts verify concurrently, the rest
/// WAIT for a slot, and all 20 succeed.
///
[Fact]
public async Task An_all_valid_parallel_burst_queues_for_slots_and_every_activation_succeeds()
{
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 5 }, new ManualClock());
var concurrent = 0;
var maxConcurrent = 0;
var proceeded = 0;
await Task.WhenAll(Enumerable.Range(0, 20).Select(attempt => Task.Run(async () =>
{
var decision = await lockout.BeginAsync(Client, Ct);
decision.Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
Interlocked.Increment(ref proceeded);
var now = Interlocked.Increment(ref concurrent);
InterlockedMax(ref maxConcurrent, now);
await Task.Delay(20, Ct); // the authenticate call
Interlocked.Decrement(ref concurrent);
lockout.Complete(Client, succeeded: true);
}, Ct)));
proceeded.ShouldBe(20);
maxConcurrent.ShouldBe(5, "exactly MaxFailedAttempts verify at once — no more, and the cap is actually reached");
lockout.TrackedCount.ShouldBe(0, "nothing left behind once the burst drains");
}
///
/// #833 review — the security half: a parallel burst of guesses that all arrive before any failure is
/// recorded still reaches the authenticator only MaxFailedAttempts times; once those fail, every waiter is
/// refused as locked out without being authenticated.
///
[Fact]
public async Task An_all_bad_parallel_burst_authenticates_only_max_attempts_and_refuses_the_waiters_as_locked_out()
{
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 5 }, new ManualClock());
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var authenticated = 0;
var lockedOut = 0;
var attempts = Enumerable.Range(0, 20).Select(attempt => Task.Run(async () =>
{
var decision = await lockout.BeginAsync(Client, Ct);
if (decision.Verdict == CredentialLockoutVerdict.LockedOut)
{
Interlocked.Increment(ref lockedOut);
return;
}
decision.Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
Interlocked.Increment(ref authenticated);
await release.Task; // hold the slots until all 15 others are queued behind them
lockout.Complete(Client, succeeded: false);
}, Ct)).ToArray();
// Wait for the slot holders' increments too: the 15 waiters can queue before a slot holder's thread has run
// the line after BeginAsync, which read authenticated == 0 on the 2-core hosted runner.
await WaitUntilAsync(() => lockout.WaitingCount(Client) == 15 && Volatile.Read(ref authenticated) == 5);
authenticated.ShouldBe(5);
release.SetResult();
await Task.WhenAll(attempts);
authenticated.ShouldBe(5, "no waiter may reach the authenticator once the key locks out");
lockedOut.ShouldBe(15);
lockout.IsLockedOut(Client, out _).ShouldBeTrue();
lockout.WaitingCount(Client).ShouldBe(0);
}
/// After a recorded failure the client has one slot fewer: capacity is MaxFailedAttempts − failures.
[Fact]
public async Task Recorded_failures_shrink_the_number_of_concurrent_verifications()
{
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 3 }, new ManualClock());
lockout.RecordFailure(Client);
lockout.RecordFailure(Client);
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
var second = lockout.BeginAsync(Client, Ct).AsTask();
await WaitUntilAsync(() => lockout.WaitingCount(Client) == 1);
second.IsCompleted.ShouldBeFalse("one failure short of the threshold leaves exactly one slot");
lockout.Complete(Client, succeeded: true); // a success clears the identity's count and hands the slot on
(await second).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
lockout.Complete(Client, succeeded: true);
lockout.TrackedCount.ShouldBe(1, "#844: the client-wide count survives a success until it ages out");
}
/// A cancelled waiter is dequeued and leaves nothing behind (no leaked slot, entry prunable).
[Fact]
public async Task A_cancelled_waiter_is_removed_and_leaks_nothing()
{
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, new ManualClock());
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(Ct);
var waiting = lockout.BeginAsync(Client, cts.Token).AsTask();
await WaitUntilAsync(() => lockout.WaitingCount(Client) == 1);
cts.Cancel();
await Should.ThrowAsync(waiting);
lockout.WaitingCount(Client).ShouldBe(0);
lockout.Complete(Client, succeeded: true);
lockout.TrackedCount.ShouldBe(0, "the held slot and the cancelled waiter are both gone");
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
lockout.Abandon(Client);
lockout.TrackedCount.ShouldBe(0);
}
/// A waiter that never gets a slot times out, uncounted — the client is not one failure closer.
[Fact]
public async Task A_slot_wait_timeout_fails_that_attempt_without_counting_it()
{
var lockout = new FailedCredentialLockout(
new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1, SlotWaitSeconds = 1 }, new ManualClock());
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.SlotWaitTimedOut);
lockout.WaitingCount(Client).ShouldBe(0);
lockout.Complete(Client, succeeded: true);
lockout.TrackedCount.ShouldBe(0, "the timed-out attempt recorded no failure");
}
[Fact]
public async Task Only_the_first_refusal_of_a_lockout_episode_is_flagged_for_Information_logging()
{
var clock = new ManualClock();
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1, LockoutSeconds = 10 }, clock);
lockout.RecordFailure(Client).ShouldBeTrue();
var r1 = await lockout.BeginAsync(Client, Ct);
r1.Verdict.ShouldBe(CredentialLockoutVerdict.LockedOut);
r1.FirstRefusal.ShouldBeTrue();
(await lockout.BeginAsync(Client, Ct)).FirstRefusal.ShouldBeFalse();
clock.Advance(TimeSpan.FromSeconds(11));
(await lockout.BeginAsync(Client, Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
lockout.Complete(Client, succeeded: false).ShouldNotBe(CredentialLockoutScope.None, "a new episode");
(await lockout.BeginAsync(Client, Ct)).FirstRefusal.ShouldBeTrue("each lockout episode logs its first refusal once");
}
// ---- lmxopcua#844: per-identity counts + a client-wide cap a success never resets ----
///
/// #844 — the laundering attack: four guesses at another key id, then a sign-in with the client's own valid
/// key, repeated. The success used to clear the whole per-client count, so this ran forever. Now the success
/// clears only the client's own identity: the fifth guess at the victim locks that identity.
///
[Fact]
public void A_success_with_one_key_does_not_launder_guesses_at_another()
{
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), new ManualClock());
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client, "key:victim").ShouldBeFalse();
lockout.RecordSuccess(Client, "key:own");
lockout.RecordFailure(Client, "key:victim").ShouldBeTrue("the own-key success did not touch the victim's count");
lockout.IsLockedOut(Client, "key:victim", out var remaining).ShouldBeTrue();
remaining.ShouldBe(TimeSpan.FromMinutes(5));
lockout.IsLockedOut(Client, "key:own", out _).ShouldBeFalse(
"a per-credential lockout leaves the client's other credentials usable");
}
///
/// #844 — spraying a few guesses at each of many key ids, with own-key successes in between, still hits the
/// client-wide cap (default 2 × MaxFailedAttempts), which a success never resets; then every credential —
/// the client's own valid key included — is refused.
///
[Fact]
public async Task Spraying_many_identities_with_successes_in_between_hits_the_client_wide_cap()
{
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), new ManualClock());
lockout.ClientThreshold.ShouldBe(10);
var locked = false;
var failures = 0;
for (var target = 0; !locked && target < 10; target++)
{
for (var guess = 0; guess < 4 && !locked; guess++)
{
locked = lockout.RecordFailure(Client, $"key:victim{target}");
failures++;
}
lockout.RecordSuccess(Client, "key:own");
}
locked.ShouldBeTrue();
failures.ShouldBe(10, "the tenth failure across identities locks the client");
lockout.IsLockedOut(Client, "key:own", out _).ShouldBeTrue("a client-wide lockout covers the client's own key too");
var refused = await lockout.BeginAsync(Client, "key:own", Ct);
refused.Verdict.ShouldBe(CredentialLockoutVerdict.LockedOut);
refused.Scope.ShouldBe(CredentialLockoutScope.Client);
}
[Fact]
public void An_explicit_per_client_cap_is_honoured()
{
var lockout = new FailedCredentialLockout(
new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 5, MaxFailedAttemptsPerClient = 6 }, new ManualClock());
for (var i = 0; i < 5; i++) lockout.RecordFailure(Client, $"key:k{i}").ShouldBeFalse();
lockout.RecordFailure(Client, "key:k5").ShouldBeTrue();
lockout.IsLockedOut(Client, "key:anything", out _).ShouldBeTrue();
}
/// The client-wide count also ages out through the failure window — it is not a lifetime count.
[Fact]
public void The_client_wide_count_ages_out_through_the_window()
{
var clock = new ManualClock();
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
for (var i = 0; i < 9; i++) lockout.RecordFailure(Client, $"key:k{i}");
clock.Advance(TimeSpan.FromSeconds(61));
for (var i = 0; i < 9; i++) lockout.RecordFailure(Client, $"key:j{i}").ShouldBeFalse();
lockout.IsLockedOut(Client, "key:own", out _).ShouldBeFalse();
}
///
/// #844 — a client with a stale (revoked) key and a good one: the stale key locks only itself; the good key's
/// activations keep getting slots and are not held behind the stale identity's waiter.
///
[Fact]
public async Task A_locked_identity_does_not_block_another_identitys_slots()
{
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2 }, new ManualClock());
lockout.RecordFailure(Client, "key:stale");
(await lockout.BeginAsync(Client, "key:stale", Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
var staleWaiter = lockout.BeginAsync(Client, "key:stale", Ct).AsTask(); // no stale slot left: waits
await WaitUntilAsync(() => lockout.WaitingCount(Client) == 1);
// The good key is not queued behind the stale waiter.
(await lockout.BeginAsync(Client, "key:good", Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
lockout.Complete(Client, "key:stale", succeeded: false).ShouldBe(CredentialLockoutScope.Credential);
var refused = await staleWaiter;
refused.Verdict.ShouldBe(CredentialLockoutVerdict.LockedOut, "the waiter's identity locked: refused unauthenticated");
refused.Scope.ShouldBe(CredentialLockoutScope.Credential);
lockout.Complete(Client, "key:good", succeeded: true).ShouldBe(CredentialLockoutScope.None);
(await lockout.BeginAsync(Client, "key:good", Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
lockout.Complete(Client, "key:good", succeeded: true);
}
/// A parallel burst of guesses at one identity still reaches the authenticator only MaxFailedAttempts times.
[Fact]
public async Task A_parallel_burst_at_one_identity_is_capped_at_the_per_credential_threshold()
{
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 3 }, new ManualClock());
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var authenticated = 0;
var attempts = Enumerable.Range(0, 10).Select(_ => Task.Run(async () =>
{
var decision = await lockout.BeginAsync(Client, "key:victim", Ct);
if (decision.Verdict != CredentialLockoutVerdict.Proceed) return;
Interlocked.Increment(ref authenticated);
await release.Task;
lockout.Complete(Client, "key:victim", succeeded: false);
}, Ct)).ToArray();
await WaitUntilAsync(() => lockout.WaitingCount(Client) == 7);
authenticated.ShouldBe(3);
release.SetResult();
await Task.WhenAll(attempts);
authenticated.ShouldBe(3);
lockout.IsLockedOut(Client, "key:victim", out _).ShouldBeTrue();
}
/// Identities beyond the per-client bound share one overflow count — conservative, never laxer.
[Fact]
public void Identities_beyond_the_per_client_bound_share_one_overflow_count()
{
var lockout = new FailedCredentialLockout(
new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2, MaxFailedAttemptsPerClient = 1000 }, new ManualClock());
for (var i = 0; i < FailedCredentialLockout.MaxTrackedIdentitiesPerClient - 1; i++)
lockout.RecordFailure(Client, $"key:k{i}").ShouldBeFalse();
lockout.RecordFailure(Client, "key:overflow-a").ShouldBeFalse();
lockout.RecordFailure(Client, "key:overflow-b").ShouldBeTrue("both untracked identities share the overflow count");
lockout.IsLockedOut(Client, "key:overflow-c", out _).ShouldBeTrue();
lockout.IsLockedOut(Client, "key:k0", out _).ShouldBeFalse("tracked identities keep their own counts");
}
[Fact]
public void Oversized_identities_are_bounded_but_stay_distinct()
{
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, new ManualClock());
var a = "key:" + new string('a', 1000) + "1";
var b = "key:" + new string('a', 1000) + "2";
lockout.RecordFailure(Client, a).ShouldBeTrue();
lockout.IsLockedOut(Client, a, out _).ShouldBeTrue();
lockout.IsLockedOut(Client, b, out _).ShouldBeFalse();
}
// ---- lmxopcua#845: transient outcomes are uncounted; a full table backs off its prune ----
///
/// #845 — a transient denial (store timeout, miss budget, authenticator fault) is released with Abandon and
/// never counts: a ConfigDb stall cannot lock the client out.
///
[Fact]
public async Task Abandoned_attempts_never_count_toward_either_threshold()
{
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2 }, new ManualClock());
for (var i = 0; i < 20; i++)
{
(await lockout.BeginAsync(Client, "key:k", Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
lockout.Abandon(Client, "key:k");
}
lockout.IsLockedOut(Client, "key:k", out _).ShouldBeFalse();
lockout.TrackedCount.ShouldBe(0);
}
///
/// #845 — at a full table with nothing expired, a new client key's failure used to rescan all 4096 entries
/// under the global lock every time. The prune now waits until the earliest entry could have expired.
///
[Fact]
public void A_full_table_does_not_rescan_until_the_earliest_entry_could_expire()
{
var clock = new ManualClock();
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
for (var i = 0; i < FailedCredentialLockout.MaxTrackedClients; i++)
{
lockout.RecordFailure($"c{i}");
if (i == 0) clock.Advance(TimeSpan.FromSeconds(10)); // c0 is the earliest to age out
}
for (var i = 0; i < 100; i++) lockout.RecordFailure($"new{i}");
lockout.PruneScanCount.ShouldBe(1, "one scan found nothing to free; the next 99 did not rescan");
lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients);
clock.Advance(TimeSpan.FromSeconds(51)); // c0's 60 s window has now passed; nobody else's has
lockout.RecordFailure("late").ShouldBeFalse();
lockout.PruneScanCount.ShouldBe(2);
lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients, "c0 pruned, 'late' took its place");
lockout.RecordFailure("later");
lockout.PruneScanCount.ShouldBe(3, "the table was full again: one scan…");
lockout.RecordFailure("latest");
lockout.PruneScanCount.ShouldBe(3, "…and then it backs off again");
}
/// A release that frees an entry lets a full table prune again immediately.
[Fact]
public async Task Freeing_an_entry_resets_the_prune_backoff()
{
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), new ManualClock());
(await lockout.BeginAsync("holder", Ct)).Verdict.ShouldBe(CredentialLockoutVerdict.Proceed);
for (var i = 1; i < FailedCredentialLockout.MaxTrackedClients; i++) lockout.RecordFailure($"c{i}");
lockout.RecordFailure("blocked");
lockout.PruneScanCount.ShouldBe(1);
lockout.Complete("holder", succeeded: true); // removes the holder's entry
for (var i = 0; i < 4; i++) lockout.RecordFailure("admitted").ShouldBeFalse();
lockout.RecordFailure("admitted").ShouldBeTrue("tracked now: its count reached the threshold");
}
private static void InterlockedMax(ref int target, int value)
{
int seen;
while ((seen = Volatile.Read(ref target)) < value && Interlocked.CompareExchange(ref target, value, seen) != seen)
{
}
}
private static async Task WaitUntilAsync(Func condition)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (!condition())
{
DateTime.UtcNow.ShouldBeLessThan(deadline, "condition not reached in time");
await Task.Delay(5, Ct);
}
}
///
/// #833 review — with LockoutSeconds shorter than FailureWindowSeconds, the count survived the expired lockout
/// (the last failure was still inside the window), so the very next failure relocked the client.
///
[Fact]
public void An_expired_lockout_starts_a_fresh_count_even_inside_the_failure_window()
{
var clock = new ManualClock();
var lockout = new FailedCredentialLockout(
new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2, FailureWindowSeconds = 600, LockoutSeconds = 10 }, clock);
lockout.RecordFailure(Client).ShouldBeFalse();
lockout.RecordFailure(Client).ShouldBeTrue();
clock.Advance(TimeSpan.FromSeconds(11));
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
lockout.RecordFailure(Client).ShouldBeFalse("one failure after the lockout is the first of a new run");
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
lockout.RecordFailure(Client).ShouldBeTrue();
}
[Fact]
public void The_host_switches_the_sdk_lockout_off()
{
var config = new Opc.Ua.ServerConfiguration();
config.MaxFailedAuthenticationAttempts.ShouldBe(5, "the SDK default this guards against");
OpcUaApplicationHost.DisableSdkAuthenticationLockout(config);
config.MaxFailedAuthenticationAttempts.ShouldBe(0);
}
private sealed class ManualClock : TimeProvider
{
private DateTimeOffset _now = new(2026, 11, 11, 0, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now += by;
}
}