using ScadaLink.Transport.Import; namespace ScadaLink.Transport.Tests.Import; /// /// Transport-004: 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. /// 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( () => limiter.TryRegisterAttempt(key!, maxAttemptsPerWindow: 10)); } [Theory] [InlineData(0)] [InlineData(-1)] public void TryRegisterAttempt_NonPositiveLimit_Throws(int limit) { var limiter = new BundleUnlockRateLimiter(); Assert.Throws( () => limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: limit)); } }