elsa-core/test/unit/Elsa.Http.UnitTests/RateLimiting/IngressRateLimitingTests.cs
Sipke Schoorstra 541218a37f
Add ingress rate limiting hooks (#7512)
* Add ingress rate limiting hooks

* Fix ingress rate limiting middleware setup

* Harden rate limiter policy validation

* Preserve routed endpoints during rate limiting

* Address rate limiting review feedback

* Address rate limiting Copilot feedback

* Register rate limiter services for external policies

* Address rate limiting review comments

* Keep rate limiter service detection best effort

* Address rate limiting review comments

* Remove brittle rate limiter validation

* Address rate limiting review feedback

* Address rate limiting nullable review

* Address rate limiting review feedback

* Assign ingress rate limit policies when enabled

* Refine ingress rate limiting middleware cleanup

* Address rate limiting review feedback

* Align rate limiting review feedback

* Clarify rate limiting policy semantics

* Stabilize rate limiting exception tests

* Fix rate limiting endpoint matching default
2026-05-22 00:13:11 +02:00

480 lines
18 KiB
C#

using System.Net;
using System.Threading.RateLimiting;
using Elsa.Extensions;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Http.UnitTests.RateLimiting;
public class IngressRateLimitingTests
{
private const string PolicyName = "test";
[Fact]
public async Task UseWorkflowsApiRateLimiting_AppliesPolicyToApiPrefix()
{
await using var app = await CreateRoutedAppAsync(app => app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/elsa/api/ping");
var secondResponse = await client.GetAsync("/elsa/api/ping");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsRateLimiting_AppliesPolicyToHttpWorkflowBasePath()
{
await using var app = await CreateAppAsync(app => app.UseWorkflowsRateLimiting("/workflows", PolicyName));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/workflows/hello-world");
var secondResponse = await client.GetAsync("/workflows/hello-world");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
[Theory]
[InlineData("/workflows/")]
[InlineData("workflows")]
public async Task UseWorkflowsRateLimiting_NormalizesHttpWorkflowBasePath(string basePath)
{
await using var app = await CreateAppAsync(app => app.UseWorkflowsRateLimiting(basePath, PolicyName));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/workflows/hello-world");
var secondResponse = await client.GetAsync("/workflows/hello-world");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_NormalizesRoutePrefixWhitespace()
{
await using var app = await CreateRoutedAppAsync(app => app.UseWorkflowsApiRateLimiting(" elsa/api ", PolicyName));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/elsa/api/ping");
var secondResponse = await client.GetAsync("/elsa/api/ping");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_DoesNotApplyWhitespaceOnlyRoutePrefixToAllPaths()
{
await using var app = await CreateAppAsync(app => app.UseWorkflowsApiRateLimiting(" ", PolicyName));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/other/path");
var secondResponse = await client.GetAsync("/other/path");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_DoesNotApplyPolicyToOtherPaths()
{
await using var app = await CreateRoutedAppAsync(app => app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName));
var client = app.GetTestClient();
await client.GetAsync("/elsa/api/ping");
await client.GetAsync("/elsa/api/ping");
var otherResponse = await client.GetAsync("/other/path");
Assert.Equal(HttpStatusCode.NotFound, otherResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsRateLimiting_DoesNotApplyPolicyToOtherPaths()
{
await using var app = await CreateAppAsync(app => app.UseWorkflowsRateLimiting("/workflows", PolicyName));
var client = app.GetTestClient();
await client.GetAsync("/workflows/hello-world");
await client.GetAsync("/workflows/hello-world");
var otherResponse = await client.GetAsync("/other/path");
Assert.Equal(HttpStatusCode.OK, otherResponse.StatusCode);
}
[Theory]
[InlineData("/")]
[InlineData(" / ")]
public async Task UseWorkflowsRateLimiting_DoesNotApplyRootBasePathToAllPaths(string basePath)
{
await using var app = await CreateAppAsync(app => app.UseWorkflowsRateLimiting(basePath, PolicyName));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/other/path");
var secondResponse = await client.GetAsync("/other/path");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsRateLimiting_AppliesPolicyToMiddlewarePathWhenEndpointRoutesExist()
{
await using var app = await CreateAppWithEndpointRouteAsync(app => app.UseWorkflowsRateLimiting("/workflows", PolicyName));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/workflows/hello-world");
var secondResponse = await client.GetAsync("/workflows/hello-world");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
[Fact]
public async Task UseRateLimitingPolicyForPath_DefaultOverloadRequiresMatchedEndpoint()
{
await using var app = await CreateAppWithEndpointRouteAsync(app => app.UseRateLimitingPolicyForPath("/proxy", PolicyName, "Proxy rate limiting endpoint"));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/proxy/downstream");
var secondResponse = await client.GetAsync("/proxy/downstream");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_UsesExistingGlobalRateLimiterMiddleware()
{
var policy = new CountingRateLimiterPolicy();
await using var app = await CreateRoutedAppAsync(
app => app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName),
options => options.AddPolicy(PolicyName, policy));
var client = app.GetTestClient();
var partitionRequestCount = policy.PartitionRequestCount;
var firstResponse = await client.GetAsync("/elsa/api/ping");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.True(policy.PartitionRequestCount > partitionRequestCount);
partitionRequestCount = policy.PartitionRequestCount;
var secondResponse = await client.GetAsync("/elsa/api/ping");
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
Assert.True(policy.PartitionRequestCount > partitionRequestCount);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_PreservesRoutedEndpointExecution()
{
await using var app = await CreateRoutedAppAsync(app => app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/elsa/api/ping");
var content = await firstResponse.Content.ReadAsStringAsync();
var secondResponse = await client.GetAsync("/elsa/api/ping");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal("pong", content);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_PreservesUnmatchedApiPrefixRouting()
{
await using var app = await CreateRoutedAppAsync(app => app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName));
var client = app.GetTestClient();
var unmatchedResponse = await client.GetAsync("/elsa/api/not-found");
var routedResponse = await client.GetAsync("/elsa/api/ping");
Assert.Equal(HttpStatusCode.NotFound, unmatchedResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, routedResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsRateLimiting_PreservesEndpointRoutingNotFoundForUnmatchedPath()
{
await using var app = await CreateEndpointRoutedAppAsync(app => app.UseWorkflowsRateLimiting("/workflows", PolicyName));
var client = app.GetTestClient();
var response = await client.GetAsync("/workflows/not-found");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_CachesAugmentedRouteEndpointAndPreservesRouteDetails()
{
var builder = CreateBuilder();
AddRateLimiterServices(builder.Services);
RouteEndpoint? originalEndpoint = null;
RouteEndpoint? firstAugmentedEndpoint = null;
RouteEndpoint? secondAugmentedEndpoint = null;
var requestCount = 0;
var routeMetadata = new TestRouteMetadata("ping");
var app = new TestApplication(builder.Build(), app =>
{
app.MapGet("/elsa/api/ping", () => "pong")
.WithDisplayName("Elsa API Ping")
.WithMetadata(routeMetadata);
app.UseRouting();
app.Use(async (context, next) =>
{
originalEndpoint ??= Assert.IsType<RouteEndpoint>(context.GetEndpoint());
await next(context);
});
app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName);
app.Use(async (context, next) =>
{
var augmentedEndpoint = Assert.IsType<RouteEndpoint>(context.GetEndpoint());
requestCount++;
if (requestCount == 1)
firstAugmentedEndpoint = augmentedEndpoint;
else
secondAugmentedEndpoint = augmentedEndpoint;
await next(context);
});
app.UseRateLimiter();
});
await using (app)
{
app.Configure();
await app.StartAsync();
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/elsa/api/ping");
var secondResponse = await client.GetAsync("/elsa/api/ping");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
Assert.NotNull(originalEndpoint);
Assert.NotNull(firstAugmentedEndpoint);
Assert.NotNull(secondAugmentedEndpoint);
Assert.NotSame(originalEndpoint, firstAugmentedEndpoint);
Assert.Same(firstAugmentedEndpoint, secondAugmentedEndpoint);
Assert.Equal(originalEndpoint.RoutePattern.RawText, firstAugmentedEndpoint.RoutePattern.RawText);
Assert.Equal(originalEndpoint.Order, firstAugmentedEndpoint.Order);
Assert.Equal(originalEndpoint.DisplayName, firstAugmentedEndpoint.DisplayName);
Assert.Same(routeMetadata, firstAugmentedEndpoint.Metadata.GetMetadata<TestRouteMetadata>());
Assert.Equal(PolicyName, firstAugmentedEndpoint.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_ReplacesExistingRateLimitingMetadata()
{
var builder = CreateBuilder();
AddRateLimiterServices(builder.Services);
RouteEndpoint? augmentedEndpoint = null;
var app = new TestApplication(builder.Build(), app =>
{
app.MapGet("/elsa/api/ping", () => "pong")
.RequireRateLimiting("other")
.DisableRateLimiting();
app.UseRouting();
app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName);
app.Use(async (context, next) =>
{
augmentedEndpoint ??= Assert.IsType<RouteEndpoint>(context.GetEndpoint());
await next(context);
});
app.UseRateLimiter();
});
await using (app)
{
app.Configure();
await app.StartAsync();
var client = app.GetTestClient();
var response = await client.GetAsync("/elsa/api/ping");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
Assert.NotNull(augmentedEndpoint);
var enableRateLimitingMetadata = augmentedEndpoint.Metadata.OfType<EnableRateLimitingAttribute>().ToList();
Assert.Single(enableRateLimitingMetadata);
Assert.Equal(PolicyName, enableRateLimitingMetadata.Single().PolicyName);
Assert.DoesNotContain(augmentedEndpoint.Metadata, x => x is DisableRateLimitingAttribute);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_FailsWhenPolicyIsNotRegistered()
{
await using var app = CreateRoutedApp(
app => app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName),
options => AddFixedWindowLimiter(options, "other"));
app.Configure();
await app.StartAsync();
var client = app.GetTestClient();
await Assert.ThrowsAsync<InvalidOperationException>(() => client.GetAsync("/elsa/api/ping"));
}
[Fact]
public void UseWorkflowsApiRateLimiting_UsesFrameworkServiceValidation()
{
using var app = CreateApp(
app => app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName),
registerRateLimiter: false);
Assert.Throws<InvalidOperationException>(() => app.Configure());
}
private static async Task<TestApplication> CreateAppAsync(Action<WebApplication> configure, Action<RateLimiterOptions>? configureRateLimiter = null)
{
var app = CreateApp(configure, configureRateLimiter);
app.Configure();
await app.StartAsync();
return app;
}
private static async Task<TestApplication> CreateRoutedAppAsync(Action<WebApplication> configure, Action<RateLimiterOptions>? configureRateLimiter = null)
{
var app = CreateRoutedApp(configure, configureRateLimiter);
app.Configure();
await app.StartAsync();
return app;
}
private static TestApplication CreateRoutedApp(Action<WebApplication> configure, Action<RateLimiterOptions>? configureRateLimiter = null)
{
var builder = CreateBuilder();
AddRateLimiterServices(builder.Services, configureRateLimiter);
var app = new TestApplication(builder.Build(), app =>
{
app.MapGet("/elsa/api/ping", () => "pong");
app.UseRouting();
configure(app);
app.UseRateLimiter();
});
return app;
}
private static async Task<TestApplication> CreateAppWithEndpointRouteAsync(Action<WebApplication> configure)
{
var builder = CreateBuilder();
AddRateLimiterServices(builder.Services);
var app = new TestApplication(builder.Build(), app =>
{
app.MapGet("/elsa/api/ping", () => "pong");
app.UseRouting();
configure(app);
app.UseRateLimiter();
app.Run(context => context.Response.WriteAsync("ok"));
});
app.Configure();
await app.StartAsync();
return app;
}
private static async Task<TestApplication> CreateEndpointRoutedAppAsync(Action<WebApplication> configure)
{
var builder = CreateBuilder();
AddRateLimiterServices(builder.Services);
var app = new TestApplication(builder.Build(), app =>
{
app.MapGet("/elsa/api/ping", () => "pong");
app.UseRouting();
configure(app);
app.UseRateLimiter();
app.UseEndpoints(_ => { });
});
app.Configure();
await app.StartAsync();
return app;
}
private static TestApplication CreateApp(Action<WebApplication> configure, Action<RateLimiterOptions>? configureRateLimiter = null, bool registerRateLimiter = true)
{
var builder = CreateBuilder();
if (registerRateLimiter)
AddRateLimiterServices(builder.Services, configureRateLimiter);
return new TestApplication(builder.Build(), app =>
{
configure(app);
app.UseRateLimiter();
app.Run(context => context.Response.WriteAsync("ok"));
});
}
private static WebApplicationBuilder CreateBuilder()
{
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.UseTestServer();
return builder;
}
private static void AddRateLimiterServices(IServiceCollection services, Action<RateLimiterOptions>? configureRateLimiter = null)
{
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
if (configureRateLimiter == null)
AddFixedWindowLimiter(options, PolicyName);
else
configureRateLimiter(options);
});
}
private static void AddFixedWindowLimiter(RateLimiterOptions options, string policyName)
{
options.AddFixedWindowLimiter(policyName, limiterOptions =>
{
limiterOptions.PermitLimit = 1;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 0;
});
}
private sealed class TestApplication(WebApplication app, Action<WebApplication> configure) : IAsyncDisposable, IDisposable
{
public void Configure() => configure(app);
public HttpClient GetTestClient() => app.GetTestClient();
public Task StartAsync() => app.StartAsync();
public void Dispose() => app.DisposeAsync().AsTask().GetAwaiter().GetResult();
public ValueTask DisposeAsync() => app.DisposeAsync();
}
private sealed class CountingRateLimiterPolicy : IRateLimiterPolicy<string>
{
public int PartitionRequestCount { get; private set; }
public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected => null;
public RateLimitPartition<string> GetPartition(HttpContext httpContext)
{
PartitionRequestCount++;
return RateLimitPartition.GetFixedWindowLimiter(PolicyName, _ => new()
{
PermitLimit = 1,
Window = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
}
}
private sealed record TestRouteMetadata(string Value);
}