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 IncrementAndCheckAsync( string key, IReadOnlyList 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.Instance); var service = new RateLimitService( config, instanceLimiter: null, environmentLimiter, NullLogger.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(StringComparer.OrdinalIgnoreCase) { ["scanner"] = new MicroserviceLimitsConfig { Routes = new Dictionary(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.Instance); var service = new RateLimitService( config, instanceLimiter: null, environmentLimiter, NullLogger.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); } }