work work hard work

This commit is contained in:
StellaOps Bot
2025-12-18 00:47:24 +02:00
parent dee252940b
commit b4235c134c
189 changed files with 9627 additions and 3258 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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.";
}
}
}

View File

@@ -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");
}
}

View 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*");
}
}

View File

@@ -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);
}
}
}

View File

@@ -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");
}
}

View 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);
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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>
{
}