work work hard work
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class InMemoryValkeyRateLimitStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task IncrementAndCheckAsync_UsesSmallestWindowAsRepresentativeWhenAllowed()
|
||||
{
|
||||
var store = new InMemoryValkeyRateLimitStore();
|
||||
var rules = new[]
|
||||
{
|
||||
new RateLimitRule { PerSeconds = 3600, MaxRequests = 1000 },
|
||||
new RateLimitRule { PerSeconds = 60, MaxRequests = 10 },
|
||||
};
|
||||
|
||||
var result = await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None);
|
||||
|
||||
result.Allowed.Should().BeTrue();
|
||||
result.WindowSeconds.Should().Be(60);
|
||||
result.Limit.Should().Be(10);
|
||||
result.CurrentCount.Should().Be(1);
|
||||
result.RetryAfterSeconds.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncrementAndCheckAsync_DeniesWhenLimitExceeded()
|
||||
{
|
||||
var store = new InMemoryValkeyRateLimitStore();
|
||||
var rules = new[]
|
||||
{
|
||||
new RateLimitRule { PerSeconds = 300, MaxRequests = 1 },
|
||||
new RateLimitRule { PerSeconds = 3600, MaxRequests = 1000 },
|
||||
};
|
||||
|
||||
(await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None)).Allowed.Should().BeTrue();
|
||||
var denied = await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None);
|
||||
|
||||
denied.Allowed.Should().BeFalse();
|
||||
denied.WindowSeconds.Should().Be(300);
|
||||
denied.Limit.Should().Be(1);
|
||||
denied.CurrentCount.Should().Be(2);
|
||||
denied.RetryAfterSeconds.Should().BeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class InstanceRateLimiterTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryAcquire_ReportsMostConstrainedRuleWhenAllowed()
|
||||
{
|
||||
var limiter = new InstanceRateLimiter(
|
||||
[
|
||||
new RateLimitRule { PerSeconds = 300, MaxRequests = 2 },
|
||||
new RateLimitRule { PerSeconds = 30, MaxRequests = 1 },
|
||||
]);
|
||||
|
||||
var decision = limiter.TryAcquire("svc");
|
||||
|
||||
decision.Allowed.Should().BeTrue();
|
||||
decision.Scope.Should().Be(RateLimitScope.Instance);
|
||||
decision.WindowSeconds.Should().Be(30);
|
||||
decision.Limit.Should().Be(1);
|
||||
decision.CurrentCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAcquire_DeniesWhenAnyRuleIsExceeded()
|
||||
{
|
||||
var limiter = new InstanceRateLimiter(
|
||||
[
|
||||
new RateLimitRule { PerSeconds = 300, MaxRequests = 2 },
|
||||
new RateLimitRule { PerSeconds = 30, MaxRequests = 1 },
|
||||
]);
|
||||
|
||||
limiter.TryAcquire("svc").Allowed.Should().BeTrue();
|
||||
|
||||
var decision = limiter.TryAcquire("svc");
|
||||
|
||||
decision.Allowed.Should().BeFalse();
|
||||
decision.Scope.Should().Be(RateLimitScope.Instance);
|
||||
decision.WindowSeconds.Should().Be(30);
|
||||
decision.Limit.Should().Be(1);
|
||||
decision.RetryAfterSeconds.Should().BeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
internal static class IntegrationTestSettings
|
||||
{
|
||||
public static bool IsEnabled
|
||||
{
|
||||
get
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable("STELLAOPS_INTEGRATION_TESTS");
|
||||
return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class IntegrationFactAttribute : FactAttribute
|
||||
{
|
||||
public IntegrationFactAttribute()
|
||||
{
|
||||
if (!IntegrationTestSettings.IsEnabled)
|
||||
{
|
||||
Skip = "Integration tests disabled. Set STELLAOPS_INTEGRATION_TESTS=true to enable.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class IntegrationTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public IntegrationTheoryAttribute()
|
||||
{
|
||||
if (!IntegrationTestSettings.IsEnabled)
|
||||
{
|
||||
Skip = "Integration tests disabled. Set STELLAOPS_INTEGRATION_TESTS=true to enable.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class LimitInheritanceResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveEnvironmentTarget_UsesEnvironmentDefaultsWhenNoMicroserviceOverride()
|
||||
{
|
||||
var config = new RateLimitConfig
|
||||
{
|
||||
ActivationThresholdPer5Min = 0,
|
||||
ForEnvironment = new EnvironmentLimitsConfig
|
||||
{
|
||||
ValkeyConnection = "localhost:6379",
|
||||
ValkeyBucket = "bucket",
|
||||
Rules = [new RateLimitRule { PerSeconds = 60, MaxRequests = 600 }],
|
||||
}
|
||||
}.Validate();
|
||||
|
||||
var resolver = new LimitInheritanceResolver(config);
|
||||
var target = resolver.ResolveEnvironmentTarget("scanner", "/api/scans");
|
||||
|
||||
target.Enabled.Should().BeTrue();
|
||||
target.Kind.Should().Be(RateLimitTargetKind.EnvironmentDefault);
|
||||
target.RouteName.Should().BeNull();
|
||||
target.TargetKey.Should().Be("scanner");
|
||||
target.Rules.Should().ContainSingle();
|
||||
target.Rules[0].PerSeconds.Should().Be(60);
|
||||
target.Rules[0].MaxRequests.Should().Be(600);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEnvironmentTarget_UsesMicroserviceOverrideWhenPresent()
|
||||
{
|
||||
var config = new RateLimitConfig
|
||||
{
|
||||
ActivationThresholdPer5Min = 0,
|
||||
ForEnvironment = new EnvironmentLimitsConfig
|
||||
{
|
||||
ValkeyConnection = "localhost:6379",
|
||||
ValkeyBucket = "bucket",
|
||||
Rules = [new RateLimitRule { PerSeconds = 60, MaxRequests = 600 }],
|
||||
Microservices = new Dictionary<string, MicroserviceLimitsConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["scanner"] = new MicroserviceLimitsConfig
|
||||
{
|
||||
Rules = [new RateLimitRule { PerSeconds = 10, MaxRequests = 1 }],
|
||||
}
|
||||
}
|
||||
}
|
||||
}.Validate();
|
||||
|
||||
var resolver = new LimitInheritanceResolver(config);
|
||||
var target = resolver.ResolveEnvironmentTarget("scanner", "/api/scans");
|
||||
|
||||
target.Enabled.Should().BeTrue();
|
||||
target.Kind.Should().Be(RateLimitTargetKind.Microservice);
|
||||
target.RouteName.Should().BeNull();
|
||||
target.TargetKey.Should().Be("scanner");
|
||||
target.Rules.Should().ContainSingle();
|
||||
target.Rules[0].PerSeconds.Should().Be(10);
|
||||
target.Rules[0].MaxRequests.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEnvironmentTarget_DisablesWhenNoRulesAtAnyLevel()
|
||||
{
|
||||
var config = new RateLimitConfig
|
||||
{
|
||||
ActivationThresholdPer5Min = 0,
|
||||
ForEnvironment = new EnvironmentLimitsConfig
|
||||
{
|
||||
ValkeyConnection = "localhost:6379",
|
||||
ValkeyBucket = "bucket",
|
||||
}
|
||||
}.Validate();
|
||||
|
||||
var resolver = new LimitInheritanceResolver(config);
|
||||
var target = resolver.ResolveEnvironmentTarget("scanner", "/api/scans");
|
||||
|
||||
target.Enabled.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEnvironmentTarget_UsesRouteOverrideWhenPresent()
|
||||
{
|
||||
var config = new RateLimitConfig
|
||||
{
|
||||
ActivationThresholdPer5Min = 0,
|
||||
ForEnvironment = new EnvironmentLimitsConfig
|
||||
{
|
||||
ValkeyConnection = "localhost:6379",
|
||||
ValkeyBucket = "bucket",
|
||||
Rules = [new RateLimitRule { PerSeconds = 60, MaxRequests = 600 }],
|
||||
Microservices = new Dictionary<string, MicroserviceLimitsConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["scanner"] = new MicroserviceLimitsConfig
|
||||
{
|
||||
Rules = [new RateLimitRule { PerSeconds = 60, MaxRequests = 600 }],
|
||||
Routes = new Dictionary<string, RouteLimitsConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["scan_submit"] = new RouteLimitsConfig
|
||||
{
|
||||
Pattern = "/api/scans",
|
||||
MatchType = RouteMatchType.Exact,
|
||||
Rules = [new RateLimitRule { PerSeconds = 300, MaxRequests = 1 }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.Validate();
|
||||
|
||||
var resolver = new LimitInheritanceResolver(config);
|
||||
var target = resolver.ResolveEnvironmentTarget("scanner", "/api/scans");
|
||||
|
||||
target.Enabled.Should().BeTrue();
|
||||
target.Kind.Should().Be(RateLimitTargetKind.Route);
|
||||
target.RouteName.Should().Be("scan_submit");
|
||||
target.TargetKey.Should().Be("scanner:scan_submit");
|
||||
target.Rules.Should().ContainSingle();
|
||||
target.Rules[0].PerSeconds.Should().Be(300);
|
||||
target.Rules[0].MaxRequests.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEnvironmentTarget_DoesNotTreatRouteAsOverrideWhenItHasNoRules()
|
||||
{
|
||||
var config = new RateLimitConfig
|
||||
{
|
||||
ActivationThresholdPer5Min = 0,
|
||||
ForEnvironment = new EnvironmentLimitsConfig
|
||||
{
|
||||
ValkeyConnection = "localhost:6379",
|
||||
ValkeyBucket = "bucket",
|
||||
Rules = [new RateLimitRule { PerSeconds = 60, MaxRequests = 600 }],
|
||||
Microservices = new Dictionary<string, MicroserviceLimitsConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["scanner"] = new MicroserviceLimitsConfig
|
||||
{
|
||||
Rules = [new RateLimitRule { PerSeconds = 60, MaxRequests = 600 }],
|
||||
Routes = new Dictionary<string, RouteLimitsConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["named_only"] = new RouteLimitsConfig
|
||||
{
|
||||
Pattern = "/api/scans",
|
||||
MatchType = RouteMatchType.Exact
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.Validate();
|
||||
|
||||
var resolver = new LimitInheritanceResolver(config);
|
||||
var target = resolver.ResolveEnvironmentTarget("scanner", "/api/scans");
|
||||
|
||||
target.Enabled.Should().BeTrue();
|
||||
target.Kind.Should().Be(RateLimitTargetKind.Microservice);
|
||||
target.RouteName.Should().BeNull();
|
||||
target.TargetKey.Should().Be("scanner");
|
||||
}
|
||||
}
|
||||
67
tests/StellaOps.Router.Gateway.Tests/RateLimitConfigTests.cs
Normal file
67
tests/StellaOps.Router.Gateway.Tests/RateLimitConfigTests.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class RateLimitConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void Load_BindsRoutesAndRules()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["rate_limiting:process_back_pressure_when_more_than_per_5min"] = "0",
|
||||
["rate_limiting:for_environment:valkey_connection"] = "localhost:6379",
|
||||
["rate_limiting:for_environment:valkey_bucket"] = "stella-router-rate-limit",
|
||||
|
||||
["rate_limiting:for_environment:microservices:scanner:routes:scan_submit:pattern"] = "/api/scans",
|
||||
["rate_limiting:for_environment:microservices:scanner:routes:scan_submit:match_type"] = "Exact",
|
||||
["rate_limiting:for_environment:microservices:scanner:routes:scan_submit:rules:0:per_seconds"] = "10",
|
||||
["rate_limiting:for_environment:microservices:scanner:routes:scan_submit:rules:0:max_requests"] = "50",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var config = RateLimitConfig.Load(configuration);
|
||||
|
||||
config.ActivationThresholdPer5Min.Should().Be(0);
|
||||
config.ForEnvironment.Should().NotBeNull();
|
||||
config.ForEnvironment!.Microservices.Should().NotBeNull();
|
||||
|
||||
var scanner = config.ForEnvironment.Microservices!["scanner"];
|
||||
scanner.Routes.Should().ContainKey("scan_submit");
|
||||
|
||||
var route = scanner.Routes["scan_submit"];
|
||||
route.MatchType.Should().Be(RouteMatchType.Exact);
|
||||
route.Pattern.Should().Be("/api/scans");
|
||||
route.Rules.Should().HaveCount(1);
|
||||
route.Rules[0].PerSeconds.Should().Be(10);
|
||||
route.Rules[0].MaxRequests.Should().Be(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ThrowsForInvalidRegexRoute()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["rate_limiting:process_back_pressure_when_more_than_per_5min"] = "0",
|
||||
["rate_limiting:for_environment:valkey_connection"] = "localhost:6379",
|
||||
["rate_limiting:for_environment:valkey_bucket"] = "stella-router-rate-limit",
|
||||
|
||||
["rate_limiting:for_environment:microservices:scanner:routes:bad:pattern"] = "[",
|
||||
["rate_limiting:for_environment:microservices:scanner:routes:bad:match_type"] = "Regex",
|
||||
["rate_limiting:for_environment:microservices:scanner:routes:bad:rules:0:per_seconds"] = "10",
|
||||
["rate_limiting:for_environment:microservices:scanner:routes:bad:rules:0:max_requests"] = "1",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var act = () => RateLimitConfig.Load(configuration);
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*Invalid regex pattern*");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Router.Gateway;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class RateLimitMiddlewareTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InvokeAsync_EnforcesEnvironmentLimit_WithRetryAfterAndJsonBody()
|
||||
{
|
||||
var config = new RateLimitConfig
|
||||
{
|
||||
ActivationThresholdPer5Min = 0,
|
||||
ForEnvironment = new EnvironmentLimitsConfig
|
||||
{
|
||||
ValkeyConnection = "localhost:6379",
|
||||
ValkeyBucket = "bucket",
|
||||
Microservices = new Dictionary<string, MicroserviceLimitsConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["scanner"] = new MicroserviceLimitsConfig
|
||||
{
|
||||
Routes = new Dictionary<string, RouteLimitsConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["scan_submit"] = new RouteLimitsConfig
|
||||
{
|
||||
Pattern = "/api/scans",
|
||||
MatchType = RouteMatchType.Exact,
|
||||
Rules = [new RateLimitRule { PerSeconds = 300, MaxRequests = 1 }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.Validate();
|
||||
|
||||
var store = new InMemoryValkeyRateLimitStore();
|
||||
var circuitBreaker = new CircuitBreaker(failureThreshold: 5, timeoutSeconds: 30, halfOpenTimeout: 10);
|
||||
var environmentLimiter = new EnvironmentRateLimiter(store, circuitBreaker, NullLogger<EnvironmentRateLimiter>.Instance);
|
||||
var service = new RateLimitService(config, instanceLimiter: null, environmentLimiter, NullLogger<RateLimitService>.Instance);
|
||||
|
||||
var nextCalled = 0;
|
||||
var middleware = new RateLimitMiddleware(
|
||||
next: async ctx =>
|
||||
{
|
||||
nextCalled++;
|
||||
ctx.Response.StatusCode = StatusCodes.Status200OK;
|
||||
await ctx.Response.WriteAsync("ok");
|
||||
},
|
||||
rateLimitService: service,
|
||||
logger: NullLogger<RateLimitMiddleware>.Instance);
|
||||
|
||||
// First request allowed
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = "/api/scans";
|
||||
context.Response.Body = new MemoryStream();
|
||||
context.Items[RouterHttpContextKeys.TargetMicroservice] = "scanner";
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status200OK);
|
||||
context.Response.Headers.ContainsKey("Retry-After").Should().BeFalse();
|
||||
context.Response.Headers["X-RateLimit-Limit"].ToString().Should().Be("1");
|
||||
nextCalled.Should().Be(1);
|
||||
}
|
||||
|
||||
// Second request denied
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = "/api/scans";
|
||||
context.Response.Body = new MemoryStream();
|
||||
context.Items[RouterHttpContextKeys.TargetMicroservice] = "scanner";
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status429TooManyRequests);
|
||||
context.Response.Headers.ContainsKey("Retry-After").Should().BeTrue();
|
||||
|
||||
context.Response.Body.Position = 0;
|
||||
var body = await new StreamReader(context.Response.Body, Encoding.UTF8).ReadToEndAsync();
|
||||
using var json = JsonDocument.Parse(body);
|
||||
|
||||
json.RootElement.GetProperty("error").GetString().Should().Be("rate_limit_exceeded");
|
||||
json.RootElement.GetProperty("scope").GetString().Should().Be("environment");
|
||||
json.RootElement.GetProperty("limit").GetInt64().Should().Be(1);
|
||||
|
||||
nextCalled.Should().Be(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class RateLimitRouteMatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryMatch_ExactBeatsPrefixAndRegex()
|
||||
{
|
||||
var microservice = new MicroserviceLimitsConfig
|
||||
{
|
||||
Routes = new Dictionary<string, RouteLimitsConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["exact"] = new RouteLimitsConfig
|
||||
{
|
||||
Pattern = "/api/scans",
|
||||
MatchType = RouteMatchType.Exact,
|
||||
Rules = [new RateLimitRule { PerSeconds = 10, MaxRequests = 1 }]
|
||||
},
|
||||
["prefix"] = new RouteLimitsConfig
|
||||
{
|
||||
Pattern = "/api/scans/*",
|
||||
MatchType = RouteMatchType.Prefix,
|
||||
Rules = [new RateLimitRule { PerSeconds = 10, MaxRequests = 1 }]
|
||||
},
|
||||
["regex"] = new RouteLimitsConfig
|
||||
{
|
||||
Pattern = "^/api/scans/[a-f0-9-]+$",
|
||||
MatchType = RouteMatchType.Regex,
|
||||
Rules = [new RateLimitRule { PerSeconds = 10, MaxRequests = 1 }]
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
microservice.Validate("microservice");
|
||||
|
||||
var matcher = new RateLimitRouteMatcher();
|
||||
var match = matcher.TryMatch(microservice, "/api/scans");
|
||||
|
||||
match.Should().NotBeNull();
|
||||
match!.Value.Name.Should().Be("exact");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_LongestPrefixWins()
|
||||
{
|
||||
var microservice = new MicroserviceLimitsConfig
|
||||
{
|
||||
Routes = new Dictionary<string, RouteLimitsConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["short"] = new RouteLimitsConfig
|
||||
{
|
||||
Pattern = "/api/*",
|
||||
MatchType = RouteMatchType.Prefix,
|
||||
Rules = [new RateLimitRule { PerSeconds = 10, MaxRequests = 1 }]
|
||||
},
|
||||
["long"] = new RouteLimitsConfig
|
||||
{
|
||||
Pattern = "/api/scans/*",
|
||||
MatchType = RouteMatchType.Prefix,
|
||||
Rules = [new RateLimitRule { PerSeconds = 10, MaxRequests = 1 }]
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
microservice.Validate("microservice");
|
||||
|
||||
var matcher = new RateLimitRouteMatcher();
|
||||
var match = matcher.TryMatch(microservice, "/api/scans/123");
|
||||
|
||||
match.Should().NotBeNull();
|
||||
match!.Value.Name.Should().Be("long");
|
||||
}
|
||||
}
|
||||
|
||||
107
tests/StellaOps.Router.Gateway.Tests/RateLimitServiceTests.cs
Normal file
107
tests/StellaOps.Router.Gateway.Tests/RateLimitServiceTests.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class RateLimitServiceTests
|
||||
{
|
||||
private sealed class CountingStore : IValkeyRateLimitStore
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public Task<RateLimitStoreResult> IncrementAndCheckAsync(
|
||||
string key,
|
||||
IReadOnlyList<RateLimitRule> rules,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult(new RateLimitStoreResult(
|
||||
Allowed: false,
|
||||
CurrentCount: 2,
|
||||
Limit: 1,
|
||||
WindowSeconds: 300,
|
||||
RetryAfterSeconds: 10));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckLimitAsync_DoesNotInvokeEnvironmentLimiterUntilActivationGateTriggers()
|
||||
{
|
||||
var config = new RateLimitConfig
|
||||
{
|
||||
ActivationThresholdPer5Min = 2,
|
||||
ForEnvironment = new EnvironmentLimitsConfig
|
||||
{
|
||||
ValkeyConnection = "localhost:6379",
|
||||
ValkeyBucket = "bucket",
|
||||
Rules = [new RateLimitRule { PerSeconds = 300, MaxRequests = 1 }]
|
||||
}
|
||||
}.Validate();
|
||||
|
||||
var store = new CountingStore();
|
||||
var circuitBreaker = new CircuitBreaker(failureThreshold: 5, timeoutSeconds: 30, halfOpenTimeout: 10);
|
||||
var environmentLimiter = new EnvironmentRateLimiter(store, circuitBreaker, NullLogger<EnvironmentRateLimiter>.Instance);
|
||||
var service = new RateLimitService(
|
||||
config,
|
||||
instanceLimiter: null,
|
||||
environmentLimiter,
|
||||
NullLogger<RateLimitService>.Instance);
|
||||
|
||||
var first = await service.CheckLimitAsync("scanner", "/api/scans", CancellationToken.None);
|
||||
first.Allowed.Should().BeTrue();
|
||||
store.Calls.Should().Be(0);
|
||||
|
||||
var second = await service.CheckLimitAsync("scanner", "/api/scans", CancellationToken.None);
|
||||
second.Allowed.Should().BeFalse();
|
||||
store.Calls.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckLimitAsync_EnforcesPerRouteEnvironmentRules()
|
||||
{
|
||||
var config = new RateLimitConfig
|
||||
{
|
||||
ActivationThresholdPer5Min = 0,
|
||||
ForEnvironment = new EnvironmentLimitsConfig
|
||||
{
|
||||
ValkeyConnection = "localhost:6379",
|
||||
ValkeyBucket = "bucket",
|
||||
Microservices = new Dictionary<string, MicroserviceLimitsConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["scanner"] = new MicroserviceLimitsConfig
|
||||
{
|
||||
Routes = new Dictionary<string, RouteLimitsConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["scan_submit"] = new RouteLimitsConfig
|
||||
{
|
||||
Pattern = "/api/scans",
|
||||
MatchType = RouteMatchType.Exact,
|
||||
Rules = [new RateLimitRule { PerSeconds = 300, MaxRequests = 1 }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.Validate();
|
||||
|
||||
var store = new InMemoryValkeyRateLimitStore();
|
||||
var circuitBreaker = new CircuitBreaker(failureThreshold: 5, timeoutSeconds: 30, halfOpenTimeout: 10);
|
||||
var environmentLimiter = new EnvironmentRateLimiter(store, circuitBreaker, NullLogger<EnvironmentRateLimiter>.Instance);
|
||||
var service = new RateLimitService(
|
||||
config,
|
||||
instanceLimiter: null,
|
||||
environmentLimiter,
|
||||
NullLogger<RateLimitService>.Instance);
|
||||
|
||||
(await service.CheckLimitAsync("scanner", "/api/scans", CancellationToken.None)).Allowed.Should().BeTrue();
|
||||
|
||||
var denied = await service.CheckLimitAsync("scanner", "/api/scans", CancellationToken.None);
|
||||
|
||||
denied.Allowed.Should().BeFalse();
|
||||
denied.Scope.Should().Be(RateLimitScope.Environment);
|
||||
denied.WindowSeconds.Should().Be(300);
|
||||
denied.Limit.Should().Be(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\__Libraries\StellaOps.Router.Gateway\StellaOps.Router.Gateway.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="Testcontainers" Version="4.4.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,81 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
[Collection(nameof(ValkeyTestcontainerCollection))]
|
||||
public sealed class ValkeyRateLimitStoreIntegrationTests
|
||||
{
|
||||
private readonly ValkeyTestcontainerFixture _valkey;
|
||||
|
||||
public ValkeyRateLimitStoreIntegrationTests(ValkeyTestcontainerFixture valkey)
|
||||
{
|
||||
_valkey = valkey;
|
||||
}
|
||||
|
||||
[IntegrationFact]
|
||||
public async Task IncrementAndCheckAsync_UsesSmallestWindowAsRepresentativeWhenAllowed()
|
||||
{
|
||||
var bucket = $"stella-router-rate-limit-it-{Guid.NewGuid():N}";
|
||||
using var store = new ValkeyRateLimitStore(_valkey.ConnectionString, bucket);
|
||||
|
||||
var rules = new[]
|
||||
{
|
||||
new RateLimitRule { PerSeconds = 3600, MaxRequests = 1000 },
|
||||
new RateLimitRule { PerSeconds = 60, MaxRequests = 10 },
|
||||
};
|
||||
|
||||
var result = await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None);
|
||||
|
||||
result.Allowed.Should().BeTrue();
|
||||
result.WindowSeconds.Should().Be(60);
|
||||
result.Limit.Should().Be(10);
|
||||
result.CurrentCount.Should().Be(1);
|
||||
result.RetryAfterSeconds.Should().Be(0);
|
||||
}
|
||||
|
||||
[IntegrationFact]
|
||||
public async Task IncrementAndCheckAsync_DeniesWhenLimitExceeded()
|
||||
{
|
||||
var bucket = $"stella-router-rate-limit-it-{Guid.NewGuid():N}";
|
||||
using var store = new ValkeyRateLimitStore(_valkey.ConnectionString, bucket);
|
||||
|
||||
var rules = new[]
|
||||
{
|
||||
new RateLimitRule { PerSeconds = 2, MaxRequests = 1 },
|
||||
};
|
||||
|
||||
(await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None)).Allowed.Should().BeTrue();
|
||||
var denied = await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None);
|
||||
|
||||
denied.Allowed.Should().BeFalse();
|
||||
denied.WindowSeconds.Should().Be(2);
|
||||
denied.Limit.Should().Be(1);
|
||||
denied.CurrentCount.Should().Be(2);
|
||||
denied.RetryAfterSeconds.Should().BeInRange(1, 2);
|
||||
}
|
||||
|
||||
[IntegrationFact]
|
||||
public async Task IncrementAndCheckAsync_ReturnsMostRestrictiveRetryAfterAcrossRules()
|
||||
{
|
||||
var bucket = $"stella-router-rate-limit-it-{Guid.NewGuid():N}";
|
||||
using var store = new ValkeyRateLimitStore(_valkey.ConnectionString, bucket);
|
||||
|
||||
var rules = new[]
|
||||
{
|
||||
new RateLimitRule { PerSeconds = 60, MaxRequests = 1 },
|
||||
new RateLimitRule { PerSeconds = 2, MaxRequests = 100 },
|
||||
};
|
||||
|
||||
(await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None)).Allowed.Should().BeTrue();
|
||||
var denied = await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None);
|
||||
|
||||
denied.Allowed.Should().BeFalse();
|
||||
denied.WindowSeconds.Should().Be(60);
|
||||
denied.Limit.Should().Be(1);
|
||||
denied.CurrentCount.Should().Be(2);
|
||||
denied.RetryAfterSeconds.Should().BeInRange(1, 60);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class ValkeyTestcontainerFixture : IAsyncLifetime
|
||||
{
|
||||
private IContainer? _container;
|
||||
|
||||
public string ConnectionString { get; private set; } = "";
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (!IntegrationTestSettings.IsEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_container = new ContainerBuilder()
|
||||
.WithImage("valkey/valkey:8-alpine")
|
||||
.WithPortBinding(6379, true)
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(6379))
|
||||
.Build();
|
||||
|
||||
await _container.StartAsync();
|
||||
|
||||
var port = _container.GetMappedPublicPort(6379);
|
||||
ConnectionString = $"{_container.Hostname}:{port}";
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_container is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _container.StopAsync();
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(nameof(ValkeyTestcontainerCollection))]
|
||||
public sealed class ValkeyTestcontainerCollection : ICollectionFixture<ValkeyTestcontainerFixture>
|
||||
{
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user