Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/nimanikoo/Dotnet-RateLimiter/llms.txt

Use this file to discover all available pages before exploring further.

Dotnet-RateLimiter ships with a focused xUnit test suite that exercises RedisRateLimitingMiddleware in complete isolation from Redis. By mocking RedisRateLimiter with Moq, each test controls exactly what IsAllowedAsync returns, letting you verify the middleware’s key-building logic, identity resolution, and HTTP response codes without a live Redis instance.

Test Setup

Framework: xUnit 2.9.3
Mocking: Moq 4.20.72
Target framework: net10.0
The test class constructor builds a Mock<RedisRateLimiter> by first creating a Mock<IConnectionMultiplexer> and passing it to the RedisRateLimiter constructor. This is required because RedisRateLimiter takes an IConnectionMultiplexer as its sole constructor argument and uses it to obtain an IDatabase reference internally.
public RateLimitingMiddlewareTests()
{
    var mockMultiplexer = new Mock<IConnectionMultiplexer>();
    _mockLimiter = new Mock<RedisRateLimiter>(mockMultiplexer.Object);
}
The IsAllowedAsync method is declared virtual in RedisRateLimiter, which is a deliberate design choice — it is the prerequisite for Moq to intercept and override calls to that method in tests.

Test Cases

Full test class

public class RateLimitingMiddlewareTests
{
    private readonly Mock<RedisRateLimiter> _mockLimiter;
    private readonly RequestDelegate _next = (innerHttpContext) => Task.CompletedTask;

    public RateLimitingMiddlewareTests()
    {
        var mockMultiplexer = new Mock<IConnectionMultiplexer>();
        _mockLimiter = new Mock<RedisRateLimiter>(mockMultiplexer.Object);
    }

    [Fact]
    public async Task InvokeAsync_AuthenticatedUser_UsesUserIdInKey()
    {
        // Arrange
        var context = new DefaultHttpContext();

        var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "user-123") };
        var identity = new ClaimsIdentity(claims, "TestAuth");
        context.User = new ClaimsPrincipal(identity);

        var endpoint = new Endpoint(null, new EndpointMetadataCollection(new RedisRateLimitAttribute(10, 60)), "Test");
        context.SetEndpoint(endpoint);

        var middleware = new RedisRateLimitingMiddleware(_next, _mockLimiter.Object);

        _mockLimiter.Setup(l => l.IsAllowedAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<TimeSpan>()))
            .ReturnsAsync(true);

        // Act
        await middleware.InvokeAsync(context);

        // Assert
        _mockLimiter.Verify(l => l.IsAllowedAsync(
                It.Is<string>(s => s.Contains("user:user-123")),
                It.IsAny<int>(),
                It.IsAny<TimeSpan>()),
            Times.Once);
    }

    [Fact]
    public async Task InvokeAsync_WhenRateLimitExceeded_Returns429()
    {
        // Arrange
        var context = new DefaultHttpContext();
        context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("127.0.0.1");

        var endpoint = new Endpoint(null, new EndpointMetadataCollection(new RedisRateLimitAttribute(5, 60)), "Test");
        context.SetEndpoint(endpoint);

        var middleware = new RedisRateLimitingMiddleware(_next, _mockLimiter.Object);

        _mockLimiter.Setup(l => l.IsAllowedAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<TimeSpan>()))
            .ReturnsAsync(false);

        // Act
        await middleware.InvokeAsync(context);

        // Assert
        Assert.Equal(StatusCodes.Status429TooManyRequests, context.Response.StatusCode);
    }
}

InvokeAsync_AuthenticatedUser_UsesUserIdInKey

Verifies that when a request carries a ClaimTypes.NameIdentifier claim of "user-123", the middleware calls IsAllowedAsync with a key that contains the substring "user:user-123". This confirms that the userType prefix and the claim-derived identity value are concatenated correctly before being forwarded to the Lua rate limiter.

InvokeAsync_WhenRateLimitExceeded_Returns429

Verifies the HTTP response code path. IsAllowedAsync is mocked to return false (rate limit exceeded), and the test asserts that the response status code is 429 Too Many Requests. The request is sent as an anonymous (unauthenticated) request with RemoteIpAddress set to 127.0.0.1.

Running the Tests

Run the full suite locally with:
dotnet test
Or with verbose output to see each test name:
dotnet test --verbosity normal

CI/CD pipeline

The .github/workflows/dotnet.yml workflow runs the test suite automatically on every push and pull request targeting the master branch:
name: .NET

on:
  push:
    branches: [ "master" ]
  pull_request:
    branches: [ "master" ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - name: Setup .NET
      uses: actions/setup-dotnet@v4
      with:
        dotnet-version: 8.0.x
    - name: Restore dependencies
      run: dotnet restore
    - name: Build
      run: dotnet build --no-restore
    - name: Test
      run: dotnet test --no-build --verbosity normal

Key Testing Patterns

Mocking IsAllowedAsync — allow or block

Return true to simulate a request within the rate limit:
_mockLimiter.Setup(l => l.IsAllowedAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<TimeSpan>()))
    .ReturnsAsync(true);
Return false to simulate an exceeded rate limit (triggers 429):
_mockLimiter.Setup(l => l.IsAllowedAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<TimeSpan>()))
    .ReturnsAsync(false);

Attaching RedisRateLimitAttribute to a fake endpoint

The middleware reads RedisRateLimitAttribute from endpoint.Metadata. Use EndpointMetadataCollection to attach it to a synthetic Endpoint object:
var endpoint = new Endpoint(
    null,
    new EndpointMetadataCollection(new RedisRateLimitAttribute(10, 60)),
    "Test"
);
context.SetEndpoint(endpoint);
The first argument to RedisRateLimitAttribute is MaxRequests and the second is WindowSeconds.

Simulating an authenticated user

Build a ClaimsIdentity with an authentication type string (any non-null value marks it as authenticated) and a NameIdentifier claim, then assign it to context.User:
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "user-123") };
var identity = new ClaimsIdentity(claims, "TestAuth");
context.User = new ClaimsPrincipal(identity);

Simulating an anonymous user

Leave context.User at its default (unauthenticated) state and set the remote IP address instead:
context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("127.0.0.1");
The middleware will fall into the guest branch and use the IP address as identityKey.
The virtual keyword on IsAllowedAsync in RedisRateLimiter is intentional — it is required for Moq to generate a proxy class that overrides the method. Without virtual, calling .Setup() on the mock would have no effect and the real Redis-backed implementation would be invoked instead, causing the test to fail at runtime.

Build docs developers (and LLMs) love