Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

278 lines
13 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;
/// <summary>Creates a clock fixed at the given start time.</summary>
/// <param name="start">The initial time reported by the clock.</param>
public TestTimeProvider(DateTimeOffset start) => _now = start;
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => _now;
/// <summary>Advances the clock by the given amount.</summary>
/// <param name="by">The amount of time to advance.</param>
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);
}
/// <summary>Verifies that five failures within the window lock the user out until the window expires.</summary>
[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"));
}
/// <summary>Verifies that a successful login resets the failure count.</summary>
[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"));
}
/// <summary>Verifies that the throttle key is case-insensitive on the username.</summary>
[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"));
}
/// <summary>Verifies that failures outside the window do not accumulate.</summary>
[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"));
}
/// <summary>Verifies that the lockout persists until the configured lockout minutes elapse.</summary>
[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"));
}
/// <summary>Verifies that a maximum of zero failures disables throttling.</summary>
[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 ─────────────────────────────────────
/// <summary>Verifies that a key spray keeps the tracking map bounded at the hard cap.</summary>
[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);
}
/// <summary>Verifies that expired entries are eventually swept on the amortized cadence.</summary>
[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 ──────────────────────
/// <summary>Verifies that spraying one password across many usernames from one IP trips the per-IP ceiling.</summary>
[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"));
}
/// <summary>Verifies that distinct IPs are unaffected by one IP's lockout.</summary>
[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"));
}
/// <summary>Verifies that the per-pair lockout still fires before the per-IP ceiling so the normal flow is unchanged.</summary>
[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
}
/// <summary>Verifies that a few failures across a few users behind one address stay under the per-IP ceiling.</summary>
[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"));
}
/// <summary>Verifies that the per-IP lockout is released when the window expires.</summary>
[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"));
}
/// <summary>Verifies that recording a success does not reset the per-IP counter.</summary>
[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"));
}
}