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; public TestTimeProvider(DateTimeOffset start) => _now = start; public override DateTimeOffset GetUtcNow() => _now; 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())); [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")); } [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")); } [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")); } [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")); } [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")); } [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")); } }