Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs
T

114 lines
4.3 KiB
C#

using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.Security.Tests;
/// <summary>
/// Exercises <see cref="LoginThrottle"/>: the fixed-window per-(username+IP)
/// LDAP-bind failure lockout. Time is driven by a hand-rolled controllable
/// <see cref="TimeProvider"/> (the central package list has no
/// Microsoft.Extensions.TimeProvider.Testing FakeTimeProvider — mirroring the
/// CookieSessionValidator + Transport tests, which hand-roll the same clock).
/// </summary>
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"));
}
}