7b0b9c7365
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
161 lines
6.3 KiB
C#
161 lines
6.3 KiB
C#
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
|
|
|
|
/// <summary>
|
|
/// Transport-004: <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;
|
|
public TestClock(DateTimeOffset start) { _now = start; }
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
public void Advance(TimeSpan delta) { _now += delta; }
|
|
}
|
|
|
|
[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"));
|
|
}
|
|
|
|
[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"));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
|
|
[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"));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public void TryRegisterAttempt_BlankKey_Throws(string? key)
|
|
{
|
|
var limiter = new BundleUnlockRateLimiter();
|
|
Assert.ThrowsAny<ArgumentException>(
|
|
() => limiter.TryRegisterAttempt(key!, maxAttemptsPerWindow: 10));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
}
|