From c5ccb1fd3c70264fb4efad81686378d19231a6da Mon Sep 17 00:00:00 2001 From: Dzmitryi Basau Date: Fri, 31 Jan 2025 16:31:15 +0100 Subject: [PATCH 1/3] Add rate limiting rules and tests, update dependencies Updated RateLimiter.Tests.csproj to include Moq for mocking in tests. Significantly refactored RateLimiterTest.cs by replacing the single RateLimiterTest class with multiple test classes: FixedWindowRuleTests, SlidingWindowRuleTests, RegionalRuleTests, RateLimiterTests, and ConcurrencyTests. Each class contains multiple test methods. Updated RateLimiter.csproj to include Microsoft.Extensions.Logging for logging capabilities. Added RateLimiter.cs with the following: - IRateLimitRule interface with IsAllowed and Cleanup methods. - FixedWindowRule class implementing IRateLimitRule using a fixed window algorithm. - SlidingWindowRule class implementing IRateLimitRule using a sliding window algorithm. - RegionalRule class implementing IRateLimitRule applying rules based on client's region. - RateLimiter class managing multiple rate limiting rules and logging blocked requests. --- RateLimiter.Tests/RateLimiter.Tests.csproj | 1 + RateLimiter.Tests/RateLimiterTest.cs | 231 ++++++++++++++++++++- RateLimiter/RateLimiter.cs | 167 +++++++++++++++ RateLimiter/RateLimiter.csproj | 3 + 4 files changed, 392 insertions(+), 10 deletions(-) create mode 100644 RateLimiter/RateLimiter.cs diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..ef10b84d 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..a60d95f9 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,13 +1,224 @@ -using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Microsoft.Extensions.Logging; +using System.Timers; -namespace RateLimiter.Tests; - -[TestFixture] -public class RateLimiterTest +namespace RateLimiter.Tests { - [Test] - public void Example() - { - Assert.That(true, Is.True); - } + [TestFixture] + public class FixedWindowRuleTests + { + [Test] + public void IsAllowed_WithinLimit_ReturnsTrue() + { + var rule = new FixedWindowRule(2, TimeSpan.FromMinutes(1)); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + } + + [Test] + public void IsAllowed_ExceedsLimit_ReturnsFalse() + { + var rule = new FixedWindowRule(2, TimeSpan.FromMinutes(1)); + rule.IsAllowed("client1", "res1"); + rule.IsAllowed("client1", "res1"); + Assert.That(rule.IsAllowed("client1", "res1"), Is.False); + } + + [Test] + public void IsAllowed_AfterWindowReset_AllowsAgain() + { + var rule = new FixedWindowRule(1, TimeSpan.FromMilliseconds(50)); + rule.IsAllowed("client1", "res1"); + Thread.Sleep(100); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + } + + [Test] + public void Cleanup_RemovesExpiredEntries() + { + var rule = new FixedWindowRule(1, TimeSpan.FromMilliseconds(50)); + rule.IsAllowed("client1", "res1"); + Thread.Sleep(100); + rule.Cleanup(); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + } + } + + [TestFixture] + public class SlidingWindowRuleTests + { + [Test] + public void IsAllowed_WithinLimit_ReturnsTrue() + { + var rule = new SlidingWindowRule(3, TimeSpan.FromMinutes(1)); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + } + + [Test] + public void IsAllowed_ExceedsLimit_ReturnsFalse() + { + var rule = new SlidingWindowRule(2, TimeSpan.FromMinutes(1)); + rule.IsAllowed("client1", "res1"); + rule.IsAllowed("client1", "res1"); + Assert.That(rule.IsAllowed("client1", "res1"), Is.False); + } + + [Test] + public void IsAllowed_AfterSlidingWindow_AllowsAgain() + { + var rule = new SlidingWindowRule(2, TimeSpan.FromMilliseconds(100)); + rule.IsAllowed("client1", "res1"); + Thread.Sleep(50); + rule.IsAllowed("client1", "res1"); + Thread.Sleep(60); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + } + + [Test] + public void Cleanup_RemovesOldRequests() + { + var rule = new SlidingWindowRule(2, TimeSpan.FromMilliseconds(50)); + rule.IsAllowed("client1", "res1"); + Thread.Sleep(100); + rule.Cleanup(); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + } + } + + [TestFixture] + public class RegionalRuleTests + { + [Test] + public void IsAllowed_UsesCorrectRegionRule() + { + var usRule = new Mock(); + usRule.Setup(r => r.IsAllowed("US-123", "res1")).Returns(true); + + var euRule = new Mock(); + euRule.Setup(r => r.IsAllowed("EU-456", "res1")).Returns(true); + + var rule = new RegionalRule(new Dictionary + { + ["US"] = usRule.Object, + ["EU"] = euRule.Object + }); + + rule.IsAllowed("US-123", "res1"); + usRule.Verify(r => r.IsAllowed("US-123", "res1"), Times.Once); + + rule.IsAllowed("EU-456", "res1"); + euRule.Verify(r => r.IsAllowed("EU-456", "res1"), Times.Once); + } + + [Test] + public void IsAllowed_UnknownRegion_ReturnsFalse() + { + var rule = new RegionalRule(new Dictionary()); + Assert.That(rule.IsAllowed("ASIA-789", "res1"), Is.False); + } + } + + [TestFixture] + public class RateLimiterTests + { + private Mock> _loggerMock; + + [SetUp] + public void Setup() + { + _loggerMock = new Mock>(); + } + + [Test] + public void IsRequestAllowed_NoRules_ReturnsTrue() + { + var limiter = new RateLimiter(_loggerMock.Object); + Assert.That(limiter.IsRequestAllowed("client1", "res1"), Is.True); + } + + [Test] + public void IsRequestAllowed_AllRulesAllow_ReturnsTrue() + { + var ruleMock = new Mock(); + ruleMock.Setup(r => r.IsAllowed("client1", "res1")).Returns(true); + + var limiter = new RateLimiter(_loggerMock.Object); + limiter.AddRule("res1", ruleMock.Object); + + Assert.That(limiter.IsRequestAllowed("client1", "res1"), Is.True); + } + + [Test] + public void IsRequestAllowed_AnyRuleDenies_ReturnsFalse() + { + var rule1 = new Mock(); + rule1.Setup(r => r.IsAllowed("client1", "res1")).Returns(true); + + var rule2 = new Mock(); + rule2.Setup(r => r.IsAllowed("client1", "res1")).Returns(false); + + var limiter = new RateLimiter(_loggerMock.Object); + limiter.AddRule("res1", rule1.Object); + limiter.AddRule("res1", rule2.Object); + + Assert.That(limiter.IsRequestAllowed("client1", "res1"), Is.False); + } + + [Test] + public void Cleanup_CallsAllRuleCleanups() + { + var ruleMock = new Mock(); + var limiter = new RateLimiter(_loggerMock.Object); + limiter.AddRule("res1", ruleMock.Object); + + limiter.Cleanup(); + + ruleMock.Verify(r => r.Cleanup(), Times.Once); + } + + [Test] + public void WhenRequestBlocked_LogsWarning() + { + var rule = new FixedWindowRule(0, TimeSpan.FromMinutes(1)); + var limiter = new RateLimiter(_loggerMock.Object); + limiter.AddRule("res1", rule); + + limiter.IsRequestAllowed("client1", "res1"); + + _loggerMock.Verify(log => log.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Request blocked: Client client1, Resource res1")), + null, + It.IsAny>()), + Times.Once); + } + } + + [TestFixture] + public class ConcurrencyTests + { + [Test] + public async Task FixedWindowRule_WithConcurrentRequests_EnforcesLimit() + { + var rule = new FixedWindowRule(10, TimeSpan.FromSeconds(1)); + var tasks = new List(); + + for (int i = 0; i < 15; i++) + { + tasks.Add(Task.Run(() => rule.IsAllowed("client1", "res1"))); + } + + await Task.WhenAll(tasks); + + Assert.That(rule.IsAllowed("client1", "res1"), Is.False); + } + } } \ No newline at end of file diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs new file mode 100644 index 00000000..b18e59c4 --- /dev/null +++ b/RateLimiter/RateLimiter.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; + +namespace RateLimiter +{ + public interface IRateLimitRule + { + bool IsAllowed(string clientId, string resource); + void Cleanup(); + } + + public class FixedWindowRule : IRateLimitRule + { + private readonly int _limit; + private readonly TimeSpan _window; + private readonly ConcurrentDictionary _requests = new(); + + public FixedWindowRule(int limit, TimeSpan window) + { + _limit = limit; + _window = window; + } + + public bool IsAllowed(string clientId, string resource) + { + var key = $"{clientId}:{resource}"; + var now = DateTime.UtcNow; + + _requests.AddOrUpdate(key, _ => (1, now + _window), (_, entry) => + { + if (entry.resetTime <= now) + return (1, now + _window); + return (entry.count + 1, entry.resetTime); + }); + + return _requests[key].count <= _limit; + } + + public void Cleanup() + { + var now = DateTime.UtcNow; + foreach (var key in _requests.Keys) + { + if (_requests.TryGetValue(key, out var entry) && entry.resetTime <= now) + { + _requests.TryRemove(key, out _); + } + } + } + } + + public class SlidingWindowRule : IRateLimitRule + { + private readonly int _limit; + private readonly TimeSpan _window; + private readonly ConcurrentDictionary> _requests = new(); + + public SlidingWindowRule(int limit, TimeSpan window) + { + _limit = limit; + _window = window; + } + + public bool IsAllowed(string clientId, string resource) + { + var key = $"{clientId}:{resource}"; + var now = DateTime.UtcNow; + + _requests.AddOrUpdate(key, _ => new Queue(new[] { now }), (_, queue) => + { + while (queue.Count > 0 && queue.Peek() <= now - _window) + queue.Dequeue(); + queue.Enqueue(now); + return queue; + }); + + return _requests[key].Count <= _limit; + } + + public void Cleanup() + { + var now = DateTime.UtcNow; + foreach (var key in _requests.Keys) + { + if (_requests.TryGetValue(key, out var queue)) + { + while (queue.Count > 0 && queue.Peek() <= now - _window) + queue.Dequeue(); + } + } + } + } + + public class RegionalRule : IRateLimitRule + { + private readonly Dictionary _regionRules; + + public RegionalRule(Dictionary regionRules) + { + _regionRules = regionRules; + } + + public bool IsAllowed(string clientId, string resource) + { + var region = GetRegionFromClient(clientId); + return _regionRules.TryGetValue(region, out var rule) && rule.IsAllowed(clientId, resource); + } + + private string GetRegionFromClient(string clientId) + { + return clientId.StartsWith("US-") ? "US" : "EU"; + } + + public void Cleanup() + { + foreach (var rule in _regionRules.Values) + { + rule.Cleanup(); + } + } + } + + public class RateLimiter + { + private readonly ConcurrentDictionary> _resourceRules = new(); + private readonly ILogger _logger; + + public RateLimiter(ILogger logger) + { + _logger = logger; + } + + public void AddRule(string resource, IRateLimitRule rule) + { + _resourceRules.AddOrUpdate(resource, _ => new List { rule }, (_, rules) => { rules.Add(rule); return rules; }); + } + + public bool IsRequestAllowed(string clientId, string resource) + { + if (!_resourceRules.TryGetValue(resource, out var rules)) + return true; + + foreach (var rule in rules) + { + if (!rule.IsAllowed(clientId, resource)) + { + _logger.LogWarning($"Request blocked: Client {clientId}, Resource {resource}"); + return false; + } + } + return true; + } + + public void Cleanup() + { + foreach (var rules in _resourceRules.Values) + { + foreach (var rule in rules) + { + rule.Cleanup(); + } + } + } + } +} diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..dac28970 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -4,4 +4,7 @@ latest enable + + + \ No newline at end of file From ff711731c5c3420db2fdedb3dea06e7bd405be94 Mon Sep 17 00:00:00 2001 From: Dzmitryi Basau Date: Mon, 3 Feb 2025 14:41:27 +0100 Subject: [PATCH 2/3] Refactor RateLimiter rules into separate files Refactored the `IRateLimitRule`, `FixedWindowRule`, `SlidingWindowRule`, and `RegionalRule` classes into their own files within the `RateLimiter.Rules` namespace to improve code organization and modularity. Updated `RateLimiterTest.cs` and `RateLimiter.cs` to reflect these changes. Added additional region checks for "ASIA" and a default "GLOBAL" region in the `RegionalRule` class. --- RateLimiter.Tests/RateLimiterTest.cs | 2 +- RateLimiter/RateLimiter.cs | 121 +------------------------ RateLimiter/Rules/FixedWindowRule.cs | 45 +++++++++ RateLimiter/Rules/IRateLimitRule.cs | 8 ++ RateLimiter/Rules/RegionalRule.cs | 36 ++++++++ RateLimiter/Rules/SlidingWindowRule.cs | 48 ++++++++++ 6 files changed, 140 insertions(+), 120 deletions(-) create mode 100644 RateLimiter/Rules/FixedWindowRule.cs create mode 100644 RateLimiter/Rules/IRateLimitRule.cs create mode 100644 RateLimiter/Rules/RegionalRule.cs create mode 100644 RateLimiter/Rules/SlidingWindowRule.cs diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index a60d95f9..e2c7a35f 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -5,7 +5,7 @@ using Moq; using NUnit.Framework; using Microsoft.Extensions.Logging; -using System.Timers; +using RateLimiter.Rules; namespace RateLimiter.Tests { diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index b18e59c4..575cc3fd 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -1,127 +1,10 @@ -using System; -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Collections.Generic; using Microsoft.Extensions.Logging; +using RateLimiter.Rules; namespace RateLimiter { - public interface IRateLimitRule - { - bool IsAllowed(string clientId, string resource); - void Cleanup(); - } - - public class FixedWindowRule : IRateLimitRule - { - private readonly int _limit; - private readonly TimeSpan _window; - private readonly ConcurrentDictionary _requests = new(); - - public FixedWindowRule(int limit, TimeSpan window) - { - _limit = limit; - _window = window; - } - - public bool IsAllowed(string clientId, string resource) - { - var key = $"{clientId}:{resource}"; - var now = DateTime.UtcNow; - - _requests.AddOrUpdate(key, _ => (1, now + _window), (_, entry) => - { - if (entry.resetTime <= now) - return (1, now + _window); - return (entry.count + 1, entry.resetTime); - }); - - return _requests[key].count <= _limit; - } - - public void Cleanup() - { - var now = DateTime.UtcNow; - foreach (var key in _requests.Keys) - { - if (_requests.TryGetValue(key, out var entry) && entry.resetTime <= now) - { - _requests.TryRemove(key, out _); - } - } - } - } - - public class SlidingWindowRule : IRateLimitRule - { - private readonly int _limit; - private readonly TimeSpan _window; - private readonly ConcurrentDictionary> _requests = new(); - - public SlidingWindowRule(int limit, TimeSpan window) - { - _limit = limit; - _window = window; - } - - public bool IsAllowed(string clientId, string resource) - { - var key = $"{clientId}:{resource}"; - var now = DateTime.UtcNow; - - _requests.AddOrUpdate(key, _ => new Queue(new[] { now }), (_, queue) => - { - while (queue.Count > 0 && queue.Peek() <= now - _window) - queue.Dequeue(); - queue.Enqueue(now); - return queue; - }); - - return _requests[key].Count <= _limit; - } - - public void Cleanup() - { - var now = DateTime.UtcNow; - foreach (var key in _requests.Keys) - { - if (_requests.TryGetValue(key, out var queue)) - { - while (queue.Count > 0 && queue.Peek() <= now - _window) - queue.Dequeue(); - } - } - } - } - - public class RegionalRule : IRateLimitRule - { - private readonly Dictionary _regionRules; - - public RegionalRule(Dictionary regionRules) - { - _regionRules = regionRules; - } - - public bool IsAllowed(string clientId, string resource) - { - var region = GetRegionFromClient(clientId); - return _regionRules.TryGetValue(region, out var rule) && rule.IsAllowed(clientId, resource); - } - - private string GetRegionFromClient(string clientId) - { - return clientId.StartsWith("US-") ? "US" : "EU"; - } - - public void Cleanup() - { - foreach (var rule in _regionRules.Values) - { - rule.Cleanup(); - } - } - } - public class RateLimiter { private readonly ConcurrentDictionary> _resourceRules = new(); diff --git a/RateLimiter/Rules/FixedWindowRule.cs b/RateLimiter/Rules/FixedWindowRule.cs new file mode 100644 index 00000000..01e4eb83 --- /dev/null +++ b/RateLimiter/Rules/FixedWindowRule.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Concurrent; + +namespace RateLimiter.Rules +{ + public class FixedWindowRule : IRateLimitRule + { + private readonly int _limit; + private readonly TimeSpan _window; + private readonly ConcurrentDictionary _requests = new(); + + public FixedWindowRule(int limit, TimeSpan window) + { + _limit = limit; + _window = window; + } + + public bool IsAllowed(string clientId, string resource) + { + var key = $"{clientId}:{resource}"; + var now = DateTime.UtcNow; + + _requests.AddOrUpdate(key, _ => (1, now + _window), (_, entry) => + { + if (entry.resetTime <= now) + return (1, now + _window); + return (entry.count + 1, entry.resetTime); + }); + + return _requests[key].count <= _limit; + } + + public void Cleanup() + { + var now = DateTime.UtcNow; + foreach (var key in _requests.Keys) + { + if (_requests.TryGetValue(key, out var entry) && entry.resetTime <= now) + { + _requests.TryRemove(key, out _); + } + } + } + } +} diff --git a/RateLimiter/Rules/IRateLimitRule.cs b/RateLimiter/Rules/IRateLimitRule.cs new file mode 100644 index 00000000..cea73c0c --- /dev/null +++ b/RateLimiter/Rules/IRateLimitRule.cs @@ -0,0 +1,8 @@ +namespace RateLimiter.Rules +{ + public interface IRateLimitRule + { + bool IsAllowed(string clientId, string resource); + void Cleanup(); + } +} diff --git a/RateLimiter/Rules/RegionalRule.cs b/RateLimiter/Rules/RegionalRule.cs new file mode 100644 index 00000000..e6c4a0a3 --- /dev/null +++ b/RateLimiter/Rules/RegionalRule.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; + +namespace RateLimiter.Rules +{ + public class RegionalRule : IRateLimitRule + { + private readonly Dictionary _regionRules; + + public RegionalRule(Dictionary regionRules) + { + _regionRules = regionRules; + } + + public bool IsAllowed(string clientId, string resource) + { + var region = GetRegionFromClient(clientId); + return _regionRules.TryGetValue(region, out var rule) && rule.IsAllowed(clientId, resource); + } + + private string GetRegionFromClient(string clientId) + { + if (clientId.StartsWith("US-")) return "US"; + if (clientId.StartsWith("EU-")) return "EU"; + if (clientId.StartsWith("ASIA-")) return "ASIA"; + return "GLOBAL"; + } + + public void Cleanup() + { + foreach (var rule in _regionRules.Values) + { + rule.Cleanup(); + } + } + } +} diff --git a/RateLimiter/Rules/SlidingWindowRule.cs b/RateLimiter/Rules/SlidingWindowRule.cs new file mode 100644 index 00000000..35bb0670 --- /dev/null +++ b/RateLimiter/Rules/SlidingWindowRule.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace RateLimiter.Rules +{ + public class SlidingWindowRule : IRateLimitRule + { + private readonly int _limit; + private readonly TimeSpan _window; + private readonly ConcurrentDictionary> _requests = new(); + + public SlidingWindowRule(int limit, TimeSpan window) + { + _limit = limit; + _window = window; + } + + public bool IsAllowed(string clientId, string resource) + { + var key = $"{clientId}:{resource}"; + var now = DateTime.UtcNow; + + _requests.AddOrUpdate(key, _ => new Queue(new[] { now }), (_, queue) => + { + while (queue.Count > 0 && queue.Peek() <= now - _window) + queue.Dequeue(); + queue.Enqueue(now); + return queue; + }); + + return _requests[key].Count <= _limit; + } + + public void Cleanup() + { + var now = DateTime.UtcNow; + foreach (var key in _requests.Keys) + { + if (_requests.TryGetValue(key, out var queue)) + { + while (queue.Count > 0 && queue.Peek() <= now - _window) + queue.Dequeue(); + } + } + } + } +} From b199329820f6f4a67559fc93969fbeccee340df7 Mon Sep 17 00:00:00 2001 From: Dzmitryi Basau Date: Mon, 3 Feb 2025 15:22:58 +0100 Subject: [PATCH 3/3] Refactor RateLimiter tests --- RateLimiter.Tests/RateLimiterTest.cs | 82 ++++++++++++++-------------- 1 file changed, 40 insertions(+), 42 deletions(-) diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index e2c7a35f..5c2a26fd 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -128,77 +128,75 @@ public void IsAllowed_UnknownRegion_ReturnsFalse() [TestFixture] public class RateLimiterTests { + private RateLimiter _rateLimiter; private Mock> _loggerMock; [SetUp] public void Setup() { _loggerMock = new Mock>(); + _rateLimiter = new RateLimiter(_loggerMock.Object); } [Test] - public void IsRequestAllowed_NoRules_ReturnsTrue() + public void ResourceWithSingleFixedWindowRule_ShouldAllowWithinLimit() { - var limiter = new RateLimiter(_loggerMock.Object); - Assert.That(limiter.IsRequestAllowed("client1", "res1"), Is.True); + var rule = new FixedWindowRule(3, TimeSpan.FromSeconds(10)); + _rateLimiter.AddRule("resourceA", rule); + + for (int i = 0; i < 3; i++) + { + Assert.IsTrue(_rateLimiter.IsRequestAllowed("client1", "resourceA")); + } + Assert.IsFalse(_rateLimiter.IsRequestAllowed("client1", "resourceA")); } [Test] - public void IsRequestAllowed_AllRulesAllow_ReturnsTrue() + public void ResourceWithSingleSlidingWindowRule_ShouldAllowWithinLimit() { - var ruleMock = new Mock(); - ruleMock.Setup(r => r.IsAllowed("client1", "res1")).Returns(true); + var rule = new SlidingWindowRule(2, TimeSpan.FromSeconds(5)); + _rateLimiter.AddRule("resourceB", rule); - var limiter = new RateLimiter(_loggerMock.Object); - limiter.AddRule("res1", ruleMock.Object); - - Assert.That(limiter.IsRequestAllowed("client1", "res1"), Is.True); + Assert.IsTrue(_rateLimiter.IsRequestAllowed("client2", "resourceB")); + Assert.IsTrue(_rateLimiter.IsRequestAllowed("client2", "resourceB")); + Assert.IsFalse(_rateLimiter.IsRequestAllowed("client2", "resourceB")); } [Test] - public void IsRequestAllowed_AnyRuleDenies_ReturnsFalse() + public void ResourceWithMultipleRules_ShouldDenyIfAnyRuleFails() { - var rule1 = new Mock(); - rule1.Setup(r => r.IsAllowed("client1", "res1")).Returns(true); - - var rule2 = new Mock(); - rule2.Setup(r => r.IsAllowed("client1", "res1")).Returns(false); - - var limiter = new RateLimiter(_loggerMock.Object); - limiter.AddRule("res1", rule1.Object); - limiter.AddRule("res1", rule2.Object); + var fixedRule = new FixedWindowRule(2, TimeSpan.FromSeconds(10)); + var slidingRule = new SlidingWindowRule(3, TimeSpan.FromSeconds(5)); - Assert.That(limiter.IsRequestAllowed("client1", "res1"), Is.False); - } + _rateLimiter.AddRule("resourceC", fixedRule); + _rateLimiter.AddRule("resourceC", slidingRule); - [Test] - public void Cleanup_CallsAllRuleCleanups() - { - var ruleMock = new Mock(); - var limiter = new RateLimiter(_loggerMock.Object); - limiter.AddRule("res1", ruleMock.Object); + Assert.IsTrue(_rateLimiter.IsRequestAllowed("client3", "resourceC")); + Assert.IsTrue(_rateLimiter.IsRequestAllowed("client3", "resourceC")); - limiter.Cleanup(); + Assert.IsFalse(_rateLimiter.IsRequestAllowed("client3", "resourceC")); - ruleMock.Verify(r => r.Cleanup(), Times.Once); + Thread.Sleep(10000); + Assert.IsTrue(_rateLimiter.IsRequestAllowed("client3", "resourceC")); } [Test] - public void WhenRequestBlocked_LogsWarning() + public void RegionalRule_ShouldApplyCorrectRegionalLimits() { - var rule = new FixedWindowRule(0, TimeSpan.FromMinutes(1)); - var limiter = new RateLimiter(_loggerMock.Object); - limiter.AddRule("res1", rule); + var regionalRule = new RegionalRule(new Dictionary + { + { "US", new FixedWindowRule(1, TimeSpan.FromSeconds(10)) }, + { "ASIA", new SlidingWindowRule(2, TimeSpan.FromSeconds(5)) } + }); + + _rateLimiter.AddRule("resourceD", regionalRule); - limiter.IsRequestAllowed("client1", "res1"); + Assert.IsTrue(_rateLimiter.IsRequestAllowed("US-client", "resourceD")); + Assert.IsFalse(_rateLimiter.IsRequestAllowed("US-client", "resourceD")); - _loggerMock.Verify(log => log.Log( - LogLevel.Warning, - It.IsAny(), - It.Is((v, t) => v.ToString().Contains("Request blocked: Client client1, Resource res1")), - null, - It.IsAny>()), - Times.Once); + Assert.IsTrue(_rateLimiter.IsRequestAllowed("ASIA-client", "resourceD")); + Assert.IsTrue(_rateLimiter.IsRequestAllowed("ASIA-client", "resourceD")); + Assert.IsFalse(_rateLimiter.IsRequestAllowed("ASIA-client", "resourceD")); } }