Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions RateLimiter.Tests/RateLimiter.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.4.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
215 changes: 204 additions & 11 deletions RateLimiter.Tests/RateLimiterTest.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,206 @@
using NUnit.Framework;
using Microsoft.AspNetCore.Http;
using Moq;
using RateLimiter.Rules;
using System.Text;
using System.Threading.Tasks;
using Xunit;
using Microsoft.Extensions.Caching.Distributed;
using System.Net;
using RateLimiter.Configuration;
using System.Collections.Generic;
using RateLimiter.Policy;
using System.Linq;

namespace RateLimiter.Tests;

[TestFixture]
public class RateLimiterTest
namespace RateLimiter.Tests
{
[Test]
public void Example()
{
Assert.That(true, Is.True);
}
}
public class RateLimiterTest
{
private readonly Mock<IDistributedCache> _cacheMock;

public RateLimiterTest()
{
_cacheMock = new Mock<IDistributedCache>();
}

#region Combined

[Fact]
public async Task EvaluateAll_ShouldAllow_WhenAllUnderLimit()
{
// Arrange
List<string> configIpBlocked = ["192.168.1.1", "192.168.1.2"];
FixedWindowConfig configFixed = new(5, 10);
string current = "2";
_cacheMock.Setup(c => c.GetAsync(It.IsAny<string>(), default))
.ReturnsAsync(Encoding.UTF8.GetBytes(current));

var ruleFixed = new FixedWindowRule(_cacheMock.Object, configFixed.Limit, configFixed.Seconds);
var ruleIp = new IpBlacklistRule(configIpBlocked);
var policy = new RateLimitPolicy();
policy.AddRule(ruleFixed);
policy.AddRule(ruleIp);

var httpContext = GetHttpContext();

// Act
bool result = await policy.EvaluateAllAsync(httpContext);

// Assert
Assert.True(result);
}

[Fact]
public async Task EvaluateAll_ShouldNotAllow_BlockedIp()
{
// Arrange
List<string> configIpBlocked = ["192.168.1.101"];
FixedWindowConfig configFixed = new(5, 10);
string current = "2";
_cacheMock.Setup(c => c.GetAsync(It.IsAny<string>(), default))
.ReturnsAsync(Encoding.UTF8.GetBytes(current));

var ruleFixed = new FixedWindowRule(_cacheMock.Object, configFixed.Limit, configFixed.Seconds);
var ruleIp = new IpBlacklistRule(configIpBlocked);
var policy = new RateLimitPolicy();
policy.AddRule(ruleFixed);
policy.AddRule(ruleIp);

var httpContext = GetHttpContext(configIpBlocked.First());

// Act
bool result = await policy.EvaluateAllAsync(httpContext);

// Assert
Assert.False(result);
}

#endregion

#region Fixed

[Fact]
public async Task EvaluateFixed_ShouldAllow_WhenUnderLimit()
{
// Arrange
FixedWindowConfig config = new(5, 10);
string current = "2";
_cacheMock.Setup(c => c.GetAsync(It.IsAny<string>(), default))
.ReturnsAsync(Encoding.UTF8.GetBytes(current));

var rule = new FixedWindowRule(_cacheMock.Object, config.Limit, config.Seconds);
var httpContext = GetHttpContext();

// Act
bool result = await rule.EvaluateAsync(httpContext);

// Assert
Assert.True(result);
}

[Fact]
public async Task EvaluateFixed_ShouldNotAllow_WhenOverLimit()
{
// Arrange
FixedWindowConfig config = new(2, 10);
string current = "2";
_cacheMock.Setup(c => c.GetAsync(It.IsAny<string>(), default))
.ReturnsAsync(Encoding.UTF8.GetBytes(current));

var rule = new FixedWindowRule(_cacheMock.Object, config.Limit, config.Seconds);
var httpContext = GetHttpContext();

// Act
bool result = await rule.EvaluateAsync(httpContext);

// Assert
Assert.False(result);
}

[Fact]
public async Task EvaluateFixed_ShouldAllow_WhenCacheEmpty()
{
// Arrange
FixedWindowConfig config = new(5, 10);
_cacheMock.Setup(c => c.GetAsync(It.IsAny<string>(), default))
.ReturnsAsync(null as byte[]);

var rule = new FixedWindowRule(_cacheMock.Object, config.Limit, config.Seconds);
var httpContext = GetHttpContext();

// Act
bool result = await rule.EvaluateAsync(httpContext);

// Assert
Assert.True(result); // First request should pass
}

[Fact]
public async Task EvaluateFixed_ShouldUpdateCache_WhenRequestMade()
{
// Arrange
FixedWindowConfig config = new(5, 10);
_cacheMock.Setup(c => c.GetAsync(It.IsAny<string>(), default))
.ReturnsAsync(Encoding.UTF8.GetBytes("2")); // Simulate 2 requests so far

var rule = new FixedWindowRule(_cacheMock.Object, config.Limit, config.Seconds);
var httpContext = GetHttpContext();

// Act
bool result = await rule.EvaluateAsync(httpContext);

// Assert
Assert.True(result); // Request should pass
_cacheMock.Verify(c => c.SetAsync(It.IsAny<string>(), It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), default), Times.Once);
}

#endregion

#region Geo

[Fact]
public async Task EvaluateGeo_ShouldAllow_WhenUnderLimit()
{
// Arrange
GeoBasedConfig config = new(Country.EU, 10);
_cacheMock.Setup(c => c.GetAsync(It.IsAny<string>(), default))
.ReturnsAsync(null as byte[]);

var rule = new GeoBasedRule(_cacheMock.Object, [config]);
var httpContext = GetHttpContext();

// Act
bool result = await rule.EvaluateAsync(httpContext);

// Assert
Assert.True(result);
}

[Fact]
public async Task EvaluateGeo_ShouldNotAllow_WhenOverLimit()
{
// Arrange
GeoBasedConfig config = new(Country.EU, 10);
string current = "0";
_cacheMock.Setup(c => c.GetAsync(It.IsAny<string>(), default))
.ReturnsAsync(Encoding.UTF8.GetBytes(current));

var rule = new GeoBasedRule(_cacheMock.Object, [config]);
var httpContext = GetHttpContext();

// Act
bool result = await rule.EvaluateAsync(httpContext);

// Assert
Assert.False(result);
}

#endregion

private static DefaultHttpContext GetHttpContext(string ip = "192.168.1.100")
{
var httpContext = new DefaultHttpContext();
httpContext.Connection.RemoteIpAddress = IPAddress.Parse(ip);
return httpContext;
}
}
}
17 changes: 17 additions & 0 deletions RateLimiter/Configuration/CooldownConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace RateLimiter.Configuration
{
public class CooldownConfig
{
public int Seconds { get; set; }

public CooldownConfig()
{

}

public CooldownConfig(int seconds)
{
Seconds = seconds;
}
}
}
10 changes: 10 additions & 0 deletions RateLimiter/Configuration/Country.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace RateLimiter.Configuration
{
// Enum not used to skip calling ".ToString()" every time
public static class Country
{
public const string Default = nameof(Default);
public const string EU = nameof(EU);
public const string US = nameof(US);
}
}
19 changes: 19 additions & 0 deletions RateLimiter/Configuration/FixedWindowConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace RateLimiter.Configuration
{
public class FixedWindowConfig
{
public int Limit { get; set; }
public int Seconds { get; set; }

public FixedWindowConfig()
{

}

public FixedWindowConfig(int limit, int seconds)
{
Limit = limit;
Seconds = seconds;
}
}
}
19 changes: 19 additions & 0 deletions RateLimiter/Configuration/GeoBasedConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace RateLimiter.Configuration
{
public class GeoBasedConfig
{
public string Country { get; set; }
public int Seconds { get; set; }

public GeoBasedConfig()
{
Country = Configuration.Country.Default;
}

public GeoBasedConfig(string country, int seconds)
{
Country = country;
Seconds = seconds;
}
}
}
19 changes: 19 additions & 0 deletions RateLimiter/Configuration/IpWhitelistConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Collections.Generic;

namespace RateLimiter.Configuration
{
public class IpWhitelistConfig
{
public IEnumerable<string> Blocked { get; set; }

public IpWhitelistConfig()
{
Blocked = [];
}

public IpWhitelistConfig(IEnumerable<string> blocked)
{
Blocked = blocked;
}
}
}
12 changes: 12 additions & 0 deletions RateLimiter/Configuration/RateLimiterConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.Collections.Generic;

namespace RateLimiter.Configuration
{
public class RateLimiterConfig
{
public FixedWindowConfig FixedWindowConfig { get; set; } = new(3, 5);
public CooldownConfig CooldownConfig { get; set; } = new(1);
public IEnumerable<GeoBasedConfig> GeoBasedConfig { get; set; } = [];
public IEnumerable<string> IpBlacklistConfig { get; set; } = [];
}
}
32 changes: 32 additions & 0 deletions RateLimiter/Policy/RateLimitPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Http;
using RateLimiter.Rules;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace RateLimiter.Policy
{
public class RateLimitPolicy()
{
private readonly List<IRateLimiterRule> _rateLimiters = [];

public void AddRule(IRateLimiterRule rule)
{
_rateLimiters.Add(rule);
}

public async Task<bool> EvaluateAllAsync(HttpContext httpContext)
{
// Use Task.WhenAny() if evaluation is long-running

foreach (var rule in _rateLimiters)
{
if (!await rule.EvaluateAsync(httpContext))
{
return false;
}
}

return true;
}
}
}
23 changes: 23 additions & 0 deletions RateLimiter/Policy/RateLimitPolicyRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;

namespace RateLimiter.Policy
{
public class RateLimitPolicyRegistry()
{
private readonly Dictionary<string, RateLimitPolicy> _policies = [];

public void AddPolicy(string name, Action<RateLimitPolicy> configure)
{
var policy = new RateLimitPolicy();
configure(policy);
// overrides if already exists
_policies[name] = policy;
}

public RateLimitPolicy? GetPolicy(string name)
{
return _policies.TryGetValue(name, out var policy) ? policy : null;
}
}
}
Loading