Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleUnlockRateLimiterTests.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

182 lines
7.5 KiB
C#

using ZB.MOM.WW.ScadaBridge.Transport.Import;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
/// <summary>
/// <see cref="BundleUnlockRateLimiter"/> must enforce a per-key cap
/// over a trailing window — the design doc's "per-IP-per-hour" cap (§11). The
/// limiter accepts any opaque caller key (typically a remote IP); these tests use
/// IP-style strings to mirror the documented intent.
/// </summary>
public sealed class BundleUnlockRateLimiterTests
{
private sealed class TestClock : TimeProvider
{
private DateTimeOffset _now;
/// <summary>Initializes a new instance of the <c>TestClock</c> class.</summary>
/// <param name="start">The start.</param>
public TestClock(DateTimeOffset start) { _now = start; }
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => _now;
/// <summary>Advances the test clock by the given amount.</summary>
/// <param name="delta">The delta.</param>
public void Advance(TimeSpan delta) { _now += delta; }
}
/// <summary>Verifies that registering an attempt under the limit returns true.</summary>
/// <returns>The result.</returns>
[Fact]
public void TryRegisterAttempt_UnderLimit_ReturnsTrue()
{
// The first N attempts at the same key are permitted; the trailing-hour
// count tracks them.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
for (var i = 1; i <= 10; i++)
{
Assert.True(
limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10),
$"Attempt {i} should be allowed (under the cap).");
}
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
}
/// <summary>Verifies that registering an attempt once the limit is reached is rejected.</summary>
/// <returns>The result.</returns>
[Fact]
public void TryRegisterAttempt_AtLimit_RejectsNextAttempt()
{
// N attempts allowed, attempt N+1 rejected — the headline contract.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
for (var i = 0; i < 10; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
// 11th attempt within the hour exceeds the cap.
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// Subsequent attempts also rejected — the limiter does NOT silently let a
// 12th, 13th, ... attempt through (no leak past the cap).
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// And the recorded count never exceeds the cap (rejected attempts are not
// appended to the trailing-hour queue).
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
}
/// <summary>Verifies that recorded attempts expire after the window elapses.</summary>
/// <returns>The result.</returns>
[Fact]
public void TryRegisterAttempt_EntriesExpireAfterWindow()
{
// Once the trailing-hour window rolls past every recorded attempt the key
// is fully reset — a legitimate operator returning later is not penalised.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
for (var i = 0; i < 10; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// Roll just past the window's end. Every recorded timestamp is now
// strictly older than (now - window) and must be pruned.
clock.Advance(TimeSpan.FromHours(1) + TimeSpan.FromSeconds(1));
Assert.Equal(0, limiter.GetAttemptCount("10.0.0.1"));
// A fresh full budget is available.
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
/// <summary>Verifies that a partial expiry releases only the oldest slot.</summary>
/// <returns>The result.</returns>
[Fact]
public void TryRegisterAttempt_PartialExpiry_ReleasesOldestSlotOnly()
{
// Sliding window — when only some of the recorded entries have aged out,
// exactly that many slots are released.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
// Five attempts at t=0.
for (var i = 0; i < 5; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
// 30 minutes later, five more — saturates the budget.
clock.Advance(TimeSpan.FromMinutes(30));
for (var i = 0; i < 5; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// Roll just past the first batch's window. Only those five entries expire;
// the second batch (recorded at t=30) is still within window from t=61.
clock.Advance(TimeSpan.FromMinutes(31));
Assert.Equal(5, limiter.GetAttemptCount("10.0.0.1"));
// Five fresh slots are available.
for (var i = 0; i < 5; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
/// <summary>Verifies that attempts are counted independently per key.</summary>
/// <returns>The result.</returns>
[Fact]
public void TryRegisterAttempt_PerKeyIsolation()
{
// The cap is per key — saturating one IP does not affect another.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
for (var i = 0; i < 10; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// A different IP has its own untouched budget.
Assert.True(limiter.TryRegisterAttempt("10.0.0.2", maxAttemptsPerWindow: 10));
Assert.Equal(1, limiter.GetAttemptCount("10.0.0.2"));
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
}
/// <summary>Verifies that registering an attempt with a blank key throws.</summary>
/// <param name="key">The key.</param>
/// <returns>The result.</returns>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void TryRegisterAttempt_BlankKey_Throws(string? key)
{
var limiter = new BundleUnlockRateLimiter();
Assert.ThrowsAny<ArgumentException>(
() => limiter.TryRegisterAttempt(key!, maxAttemptsPerWindow: 10));
}
/// <summary>Verifies that registering an attempt with a non-positive limit throws.</summary>
/// <param name="limit">The limit.</param>
/// <returns>The result.</returns>
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void TryRegisterAttempt_NonPositiveLimit_Throws(int limit)
{
var limiter = new BundleUnlockRateLimiter();
Assert.Throws<ArgumentOutOfRangeException>(
() => limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: limit));
}
}