using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.Security.Tests; /// /// Exercises : the fixed-window per-(username+IP) /// LDAP-bind failure lockout. Time is driven by a hand-rolled controllable /// (the central package list has no /// Microsoft.Extensions.TimeProvider.Testing FakeTimeProvider — mirroring the /// CookieSessionValidator + Transport tests, which hand-roll the same clock). /// public class LoginThrottleTests { private static readonly DateTimeOffset Start = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero); private sealed class TestTimeProvider : TimeProvider { private DateTimeOffset _now; /// Creates a clock fixed at the given start time. /// The initial time reported by the clock. public TestTimeProvider(DateTimeOffset start) => _now = start; /// public override DateTimeOffset GetUtcNow() => _now; /// Advances the clock by the given amount. /// The amount of time to advance. public void Advance(TimeSpan by) => _now = _now.Add(by); } private static LoginThrottle Create(TimeProvider clock, SecurityOptions? options = null) => new(clock, Options.Create(options ?? new SecurityOptions())); private static (LoginThrottle throttle, TestTimeProvider clock) Create() { var clock = new TestTimeProvider(Start); return (Create(clock), clock); } /// Verifies that five failures within the window lock the user out until the window expires. [Fact] public void FiveFailuresInWindow_LocksOut_ThenWindowExpiryUnlocks() { var clock = new TestTimeProvider(Start); var throttle = Create(clock); for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "10.0.0.1"); Assert.True(throttle.IsLockedOut("alice", "10.0.0.1")); Assert.False(throttle.IsLockedOut("alice", "10.0.0.2")); // per username+IP key — real isolation in production requires the trusted-proxy ForwardedHeaders config (R2 N2); without it all clients share the proxy IP Assert.False(throttle.IsLockedOut("bob", "10.0.0.1")); clock.Advance(TimeSpan.FromMinutes(6)); // lockout window passed Assert.False(throttle.IsLockedOut("alice", "10.0.0.1")); } /// Verifies that a successful login resets the failure count. [Fact] public void SuccessResets() { var clock = new TestTimeProvider(Start); var throttle = Create(clock); for (var i = 0; i < 4; i++) throttle.RecordFailure("alice", "10.0.0.1"); Assert.False(throttle.IsLockedOut("alice", "10.0.0.1")); throttle.RecordSuccess("alice", "10.0.0.1"); // A single failure after the reset must NOT lock out — the counter restarted. throttle.RecordFailure("alice", "10.0.0.1"); Assert.False(throttle.IsLockedOut("alice", "10.0.0.1")); } /// Verifies that the throttle key is case-insensitive on the username. [Fact] public void KeyIsCaseInsensitiveOnUsername() { var clock = new TestTimeProvider(Start); var throttle = Create(clock); for (var i = 0; i < 5; i++) throttle.RecordFailure("Alice", "10.0.0.1"); Assert.True(throttle.IsLockedOut("alice", "10.0.0.1")); Assert.True(throttle.IsLockedOut("ALICE", "10.0.0.1")); } /// Verifies that failures outside the window do not accumulate. [Fact] public void FailuresOutsideWindow_DoNotAccumulate() { var clock = new TestTimeProvider(Start); var throttle = Create(clock); // Four failures, then let the failure window lapse before the fifth. for (var i = 0; i < 4; i++) throttle.RecordFailure("alice", "10.0.0.1"); clock.Advance(TimeSpan.FromMinutes(6)); // > LoginFailureWindowMinutes (5) throttle.RecordFailure("alice", "10.0.0.1"); // The window reset, so only one failure is counted — no lockout. Assert.False(throttle.IsLockedOut("alice", "10.0.0.1")); } /// Verifies that the lockout persists until the configured lockout minutes elapse. [Fact] public void LockoutPersistsUntilLockoutMinutesElapse() { var clock = new TestTimeProvider(Start); var throttle = Create(clock); for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "10.0.0.1"); Assert.True(throttle.IsLockedOut("alice", "10.0.0.1")); clock.Advance(TimeSpan.FromMinutes(4)); // still inside the 5-minute lockout Assert.True(throttle.IsLockedOut("alice", "10.0.0.1")); clock.Advance(TimeSpan.FromMinutes(2)); // now past the 5-minute lockout Assert.False(throttle.IsLockedOut("alice", "10.0.0.1")); } /// Verifies that a maximum of zero failures disables throttling. [Fact] public void ZeroMaxFailures_DisablesThrottling() { var clock = new TestTimeProvider(Start); var throttle = Create(clock, new SecurityOptions { MaxLoginFailuresPerWindow = 0 }); for (var i = 0; i < 100; i++) throttle.RecordFailure("alice", "10.0.0.1"); Assert.False(throttle.IsLockedOut("alice", "10.0.0.1")); } // ── arch-review R2 N8: amortized prune ───────────────────────────────────── /// Verifies that a key spray keeps the tracking map bounded at the hard cap. [Fact] public void KeySpray_MapStaysBounded_AtTheHardCap() { var (throttle, _) = Create(); // existing helper: throttle over a fake clock // Spray far past the cap: the over-cap eviction must run on EVERY write once // the cap is exceeded, regardless of the amortized cadence (arch-review R2 N8). for (var i = 0; i < LoginThrottle.MaxTrackedKeys + 500; i++) throttle.RecordFailure($"user{i}", "10.0.0.1"); Assert.True(throttle.TrackedKeyCount <= LoginThrottle.MaxTrackedKeys); } /// Verifies that expired entries are eventually swept on the amortized cadence. [Fact] public void ExpiredEntries_AreEventuallySwept_OnTheAmortizedCadence() { var (throttle, clock) = Create(); throttle.RecordFailure("stale", "10.0.0.1"); clock.Advance(TimeSpan.FromMinutes(10)); // window fully expired // Distinct IPs (not just distinct usernames) so this stays a pure per-pair prune-cadence // test — hammering PruneEveryNFailures (64) failures from a SINGLE IP would itself trip // the per-IP spray ceiling (default 25) and lock "10.0.0.1" out, which would make // the IsLockedOut("stale", "10.0.0.1") assertion below fail for an unrelated reason. for (var i = 0; i < LoginThrottle.PruneEveryNFailures; i++) throttle.RecordFailure($"fresh{i}", $"10.0.1.{i}"); // cadence tick fires within these Assert.False(throttle.IsLockedOut("stale", "10.0.0.1")); Assert.True(throttle.TrackedKeyCount <= LoginThrottle.PruneEveryNFailures); // "stale" swept } // ── Per-IP spray ceiling ────────────────────── /// Verifies that spraying one password across many usernames from one IP trips the per-IP ceiling. [Fact] public void Spray_OnePasswordAcrossManyUsernamesFromOneIp_TripsThePerIpCeiling() { // THE FAIL-UNFIXED PROOF: against the old per-{username}|{ip}-only throttle, one // failure each across PerIpMaxFailures distinct usernames from a single IP never // reaches MaxLoginFailuresPerWindow (5) on any one pair, so the next attempt from // that IP — even against a brand-new username — sails through un-throttled. With the // fix, the shared per-IP counter accumulates across usernames and locks the IP out. var clock = new TestTimeProvider(Start); var options = new SecurityOptions { PerIpMaxFailures = 10 }; var throttle = Create(clock, options); // One failure each across the first (ceiling - 1) usernames: no individual pair ever // gets near the per-pair ceiling (5), and the IP hasn't tipped over yet either. for (var i = 0; i < options.PerIpMaxFailures - 1; i++) { throttle.RecordFailure($"user{i}", "10.0.0.1"); Assert.False(throttle.IsLockedOut($"user{i}", "10.0.0.1")); } // The PerIpMaxFailures-th failure — against yet another brand-new username — tips the // shared IP counter over the ceiling. throttle.RecordFailure("user-last", "10.0.0.1"); // Now EVERY username from that IP is refused, including ones never tried before. Assert.True(throttle.IsLockedOut("brand-new-user", "10.0.0.1")); } /// Verifies that distinct IPs are unaffected by one IP's lockout. [Fact] public void Spray_DistinctIpsAreUnaffectedByOneIpsLockout() { var clock = new TestTimeProvider(Start); var options = new SecurityOptions { PerIpMaxFailures = 10 }; var throttle = Create(clock, options); for (var i = 0; i < options.PerIpMaxFailures; i++) throttle.RecordFailure($"user{i}", "10.0.0.1"); Assert.True(throttle.IsLockedOut("anyone", "10.0.0.1")); Assert.False(throttle.IsLockedOut("anyone", "10.0.0.2")); // untouched IP Assert.False(throttle.IsLockedOut("user0", "10.0.0.2")); } /// Verifies that the per-pair lockout still fires before the per-IP ceiling so the normal flow is unchanged. [Fact] public void PerPairLockout_StillFiresBeforeThePerIpCeiling_NormalFlowUnchanged() { // Byte-compatible per-pair behavior: five failures against ONE pair still locks that // pair out well below the (much higher) default per-IP ceiling. var clock = new TestTimeProvider(Start); var throttle = Create(clock); for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "10.0.0.1"); Assert.True(throttle.IsLockedOut("alice", "10.0.0.1")); Assert.False(throttle.IsLockedOut("bob", "10.0.0.1")); // IP itself is nowhere near its ceiling } /// Verifies that a few failures across a few users behind one address stay under the per-IP ceiling. [Fact] public void NatFriendly_AFewFailuresAcrossAFewUsers_StaysUnderThePerIpCeiling() { // A shared NAT gateway where a handful of users mistype their passwords must not be // punished: a couple of failures each across a few distinct usernames stays well under // the default PerIpMaxFailures (25). var clock = new TestTimeProvider(Start); var throttle = Create(clock); // default PerIpMaxFailures = 25 foreach (var user in new[] { "alice", "bob", "carol", "dave" }) for (var i = 0; i < 3; i++) // 4 users x 3 failures = 12 < 25, and < 5 per pair throttle.RecordFailure(user, "10.0.0.1"); foreach (var user in new[] { "alice", "bob", "carol", "dave" }) Assert.False(throttle.IsLockedOut(user, "10.0.0.1")); Assert.False(throttle.IsLockedOut("eve", "10.0.0.1")); } /// Verifies that the per-IP lockout is released when the window expires. [Fact] public void PerIpLockout_ReleasesOnWindowExpiry() { var clock = new TestTimeProvider(Start); var options = new SecurityOptions { PerIpMaxFailures = 10 }; var throttle = Create(clock, options); for (var i = 0; i < options.PerIpMaxFailures; i++) throttle.RecordFailure($"user{i}", "10.0.0.1"); Assert.True(throttle.IsLockedOut("brand-new-user", "10.0.0.1")); clock.Advance(TimeSpan.FromMinutes(6)); // > default LoginLockoutMinutes (5) Assert.False(throttle.IsLockedOut("brand-new-user", "10.0.0.1")); } /// Verifies that recording a success does not reset the per-IP counter. [Fact] public void RecordSuccess_DoesNotResetThePerIpCounter() { // A spray that stumbles onto (or already knows) one valid account on the sprayed IP // must not be able to reset the IP's failure budget via that success and keep spraying. var clock = new TestTimeProvider(Start); var options = new SecurityOptions { PerIpMaxFailures = 10 }; var throttle = Create(clock, options); for (var i = 0; i < options.PerIpMaxFailures - 1; i++) throttle.RecordFailure($"user{i}", "10.0.0.1"); // one short of the IP ceiling throttle.RecordSuccess("valid-user", "10.0.0.1"); // a hit mid-spray throttle.RecordFailure("user-last", "10.0.0.1"); // the IP ceiling is still reached Assert.True(throttle.IsLockedOut("another-new-user", "10.0.0.1")); } }