Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/FailedCredentialLockoutTests.cs
T
Joseph Doherty 9aaab9ee42 test: #871 fix two hosted-CI races that failed master runs 1218-1220
- MeshPskProviderRefreshTests: GatedResolver.Entered was set by the priming
  refresh and never reset, so Wait() returned before the held refresh reached
  the store and the mid-refresh call-count snapshot raced its increment
  (expected 1, was 2). Hold() now resets it.
- FailedCredentialLockoutTests all-bad burst: the 15 waiters could queue
  before a slot holder ran the increment after BeginAsync (authenticated 0
  vs 5). The wait now covers both conditions; the security assertions are
  unchanged.

Both looped 20x locally, 0 failures.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-11 15:10:01 -05:00

521 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>lmxopcua#833 — the UserName-only lockout that replaces the SDK's all-token lockout.</summary>
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;
/// <summary>
/// #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.
/// </summary>
[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");
}
/// <summary>
/// #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.
/// </summary>
[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);
}
/// <summary>After a recorded failure the client has one slot fewer: capacity is MaxFailedAttempts − failures.</summary>
[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");
}
/// <summary>A cancelled waiter is dequeued and leaves nothing behind (no leaked slot, entry prunable).</summary>
[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<OperationCanceledException>(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);
}
/// <summary>A waiter that never gets a slot times out, uncounted — the client is not one failure closer.</summary>
[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 ----
/// <summary>
/// #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.
/// </summary>
[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");
}
/// <summary>
/// #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.
/// </summary>
[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();
}
/// <summary>The client-wide count also ages out through the failure window — it is not a lifetime count.</summary>
[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();
}
/// <summary>
/// #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.
/// </summary>
[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);
}
/// <summary>A parallel burst of guesses at one identity still reaches the authenticator only MaxFailedAttempts times.</summary>
[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();
}
/// <summary>Identities beyond the per-client bound share one overflow count — conservative, never laxer.</summary>
[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 ----
/// <summary>
/// #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.
/// </summary>
[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);
}
/// <summary>
/// #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.
/// </summary>
[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");
}
/// <summary>A release that frees an entry lets a full table prune again immediately.</summary>
[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<bool> condition)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (!condition())
{
DateTime.UtcNow.ShouldBeLessThan(deadline, "condition not reached in time");
await Task.Delay(5, Ct);
}
}
/// <summary>
/// #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.
/// </summary>
[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;
}
}