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