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,165 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using StellaOps.AirGap.Importer.Validation;
namespace StellaOps.AirGap.Importer.Tests.Validation;
public sealed class RekorOfflineReceiptVerifierTests
{
[Fact]
public async Task VerifyAsync_ValidReceiptAndCheckpoint_Succeeds()
{
var temp = Path.Combine(Path.GetTempPath(), "stellaops-rekor-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(temp);
try
{
// Leaf 0 is the DSSE digest we verify for inclusion.
var dsseSha256 = SHA256.HashData(Encoding.UTF8.GetBytes("dsse-envelope"));
var otherDsseSha256 = SHA256.HashData(Encoding.UTF8.GetBytes("other-envelope"));
var leaf0 = HashLeaf(dsseSha256);
var leaf1 = HashLeaf(otherDsseSha256);
var root = HashInterior(leaf0, leaf1);
var rootBase64 = Convert.ToBase64String(root);
var treeSize = 2L;
var origin = "rekor.sigstore.dev - 2605736670972794746";
var timestamp = "1700000000";
var canonicalBody = $"{origin}\n{treeSize}\n{rootBase64}\n{timestamp}\n";
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var signature = ecdsa.SignData(Encoding.UTF8.GetBytes(canonicalBody), HashAlgorithmName.SHA256);
var signatureBase64 = Convert.ToBase64String(signature);
var checkpointPath = Path.Combine(temp, "checkpoint.sig");
await File.WriteAllTextAsync(
checkpointPath,
canonicalBody + $"sig {signatureBase64}\n",
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
var publicKeyPath = Path.Combine(temp, "rekor-pub.pem");
await File.WriteAllTextAsync(
publicKeyPath,
WrapPem("PUBLIC KEY", ecdsa.ExportSubjectPublicKeyInfo()),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
var receiptPath = Path.Combine(temp, "rekor-receipt.json");
var receiptJson = JsonSerializer.Serialize(new
{
uuid = "uuid-1",
logIndex = 0,
rootHash = Convert.ToHexString(root).ToLowerInvariant(),
hashes = new[] { Convert.ToHexString(leaf1).ToLowerInvariant() },
checkpoint = "checkpoint.sig"
}, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
await File.WriteAllTextAsync(receiptPath, receiptJson, new UTF8Encoding(false));
var result = await RekorOfflineReceiptVerifier.VerifyAsync(receiptPath, dsseSha256, publicKeyPath, CancellationToken.None);
result.Verified.Should().BeTrue();
result.CheckpointSignatureVerified.Should().BeTrue();
result.RekorUuid.Should().Be("uuid-1");
result.LogIndex.Should().Be(0);
result.TreeSize.Should().Be(2);
result.ExpectedRootHash.Should().Be(Convert.ToHexString(root).ToLowerInvariant());
result.ComputedRootHash.Should().Be(Convert.ToHexString(root).ToLowerInvariant());
}
finally
{
Directory.Delete(temp, recursive: true);
}
}
[Fact]
public async Task VerifyAsync_TamperedCheckpointSignature_Fails()
{
var temp = Path.Combine(Path.GetTempPath(), "stellaops-rekor-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(temp);
try
{
var dsseSha256 = SHA256.HashData(Encoding.UTF8.GetBytes("dsse-envelope"));
var otherDsseSha256 = SHA256.HashData(Encoding.UTF8.GetBytes("other-envelope"));
var leaf0 = HashLeaf(dsseSha256);
var leaf1 = HashLeaf(otherDsseSha256);
var root = HashInterior(leaf0, leaf1);
var rootBase64 = Convert.ToBase64String(root);
var treeSize = 2L;
var origin = "rekor.sigstore.dev - 2605736670972794746";
var timestamp = "1700000000";
var canonicalBody = $"{origin}\n{treeSize}\n{rootBase64}\n{timestamp}\n";
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var signature = ecdsa.SignData(Encoding.UTF8.GetBytes(canonicalBody), HashAlgorithmName.SHA256);
signature[0] ^= 0xFF; // tamper
var checkpointPath = Path.Combine(temp, "checkpoint.sig");
await File.WriteAllTextAsync(
checkpointPath,
canonicalBody + $"sig {Convert.ToBase64String(signature)}\n",
new UTF8Encoding(false));
var publicKeyPath = Path.Combine(temp, "rekor-pub.pem");
await File.WriteAllTextAsync(
publicKeyPath,
WrapPem("PUBLIC KEY", ecdsa.ExportSubjectPublicKeyInfo()),
new UTF8Encoding(false));
var receiptPath = Path.Combine(temp, "rekor-receipt.json");
var receiptJson = JsonSerializer.Serialize(new
{
uuid = "uuid-1",
logIndex = 0,
rootHash = Convert.ToHexString(root).ToLowerInvariant(),
hashes = new[] { Convert.ToHexString(leaf1).ToLowerInvariant() },
checkpoint = "checkpoint.sig"
}, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
await File.WriteAllTextAsync(receiptPath, receiptJson, new UTF8Encoding(false));
var result = await RekorOfflineReceiptVerifier.VerifyAsync(receiptPath, dsseSha256, publicKeyPath, CancellationToken.None);
result.Verified.Should().BeFalse();
result.FailureReason.Should().Contain("checkpoint signature", because: result.FailureReason);
}
finally
{
Directory.Delete(temp, recursive: true);
}
}
private static byte[] HashLeaf(byte[] leafData)
{
var buffer = new byte[1 + leafData.Length];
buffer[0] = 0x00;
leafData.CopyTo(buffer, 1);
return SHA256.HashData(buffer);
}
private static byte[] HashInterior(byte[] left, byte[] right)
{
var buffer = new byte[1 + left.Length + right.Length];
buffer[0] = 0x01;
left.CopyTo(buffer, 1);
right.CopyTo(buffer, 1 + left.Length);
return SHA256.HashData(buffer);
}
private static string WrapPem(string label, byte[] derBytes)
{
var base64 = Convert.ToBase64String(derBytes);
var sb = new StringBuilder();
sb.AppendLine($"-----BEGIN {label}-----");
for (var i = 0; i < base64.Length; i += 64)
{
sb.AppendLine(base64.Substring(i, Math.Min(64, base64.Length - i)));
}
sb.AppendLine($"-----END {label}-----");
return sb.ToString();
}
}

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

View File

@@ -43,6 +43,32 @@ k6 run --env BASE_URL=http://localhost:5000 \
tests/load/ttfs-load-test.js
```
### Router Rate Limiting Load Test (`router-rate-limiting-load-test.js`)
Exercises Router rate limiting behavior under load (instance/environment limits, mixed routes) and validates `429` + `Retry-After`.
**Scenarios:**
- **below_limit (A)**: sustained load below expected limits
- **above_limit (B)**: ramp above expected limits (expect some `429`)
- **route_mix (C)**: mixed-path traffic to exercise route matching/overrides
- **activation_gate (F)**: low traffic then spike (activation gate exercise)
**Run locally:**
```bash
mkdir results
k6 run --env BASE_URL=http://localhost:5000 \
--env PATH=/api/test \
tests/load/router-rate-limiting-load-test.js
```
**Run with multiple paths (route mix):**
```bash
mkdir results
k6 run --env BASE_URL=http://localhost:5000 \
--env PATHS_JSON='[\"/api/a\",\"/api/b\",\"/api/c\"]' \
tests/load/router-rate-limiting-load-test.js
```
## CI Integration
Load tests can be integrated into CI pipelines. See `.gitea/workflows/load-test.yml` for an example.
@@ -86,3 +112,7 @@ k6 run --out json=results/metrics.json tests/load/ttfs-load-test.js
| `RUN_IDS` | JSON array of run IDs to test | `["run-load-1",...,"run-load-5"]` |
| `TENANT_ID` | Tenant ID header value | `load-test-tenant` |
| `AUTH_TOKEN` | Bearer token for authentication | (none) |
| `METHOD` | HTTP method for router rate limiting test | `GET` |
| `PATH` | Single path for router rate limiting test | `/api/test` |
| `PATHS_JSON` | JSON array of paths for route mix | (none) |
| `RESULTS_DIR` | Output directory for JSON artifacts | `results` |

View File

@@ -0,0 +1,201 @@
/**
* Router Rate Limiting Load Test Suite (k6)
* Reference: SPRINT_1200_001_005 (RRL-05-003)
*
* Goals:
* - Validate 429 + Retry-After behavior under load (instance and/or environment limits).
* - Measure overhead (latency) while rate limiting is enabled.
* - Exercise route-level matching via mixed-path traffic.
*
* Notes:
* - This test suite is environment-config driven. Ensure Router rate limiting is configured
* for the targeted route(s) in the environment under test.
* - "Scenario B" (environment multi-instance) is achieved by running the same test
* concurrently from multiple machines/agents.
*/
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
const BASE_URL = (__ENV.BASE_URL || 'http://localhost:5000').replace(/\/+$/, '');
const METHOD = (__ENV.METHOD || 'GET').toUpperCase();
const PATH = __ENV.PATH || '/api/test';
const PATHS_JSON = __ENV.PATHS_JSON || '';
const TENANT_ID = __ENV.TENANT_ID || 'load-test-tenant';
const AUTH_TOKEN = __ENV.AUTH_TOKEN || '';
const RESULTS_DIR = __ENV.RESULTS_DIR || 'results';
function parsePaths() {
if (!PATHS_JSON) {
return [PATH];
}
try {
const parsed = JSON.parse(PATHS_JSON);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed.map((p) => (typeof p === 'string' ? p : PATH)).filter((p) => !!p);
}
} catch {
// Ignore parse errors; fall back to single PATH.
}
return [PATH];
}
const PATHS = parsePaths();
// Custom metrics
const rateLimitDenied = new Rate('router_rate_limit_denied');
const retryAfterSeconds = new Trend('router_rate_limit_retry_after_seconds');
const status429MissingRetryAfter = new Rate('router_rate_limit_429_missing_retry_after');
// Scenario configuration (defaults can be overridden via env vars)
const BELOW_RPS = parseInt(__ENV.BELOW_RPS || '50', 10);
const ABOVE_RPS = parseInt(__ENV.ABOVE_RPS || '500', 10);
export const options = {
scenarios: {
// Scenario A: baseline below configured limits
below_limit: {
executor: 'constant-arrival-rate',
rate: BELOW_RPS,
timeUnit: '1s',
duration: __ENV.BELOW_DURATION || '2m',
preAllocatedVUs: parseInt(__ENV.BELOW_VUS || '50', 10),
maxVUs: parseInt(__ENV.BELOW_MAX_VUS || '200', 10),
tags: { scenario: 'below_limit' },
},
// Scenario B: above configured limits (expect some 429s)
above_limit: {
executor: 'ramping-arrival-rate',
startRate: BELOW_RPS,
timeUnit: '1s',
stages: [
{ duration: __ENV.ABOVE_RAMP_UP || '20s', target: ABOVE_RPS },
{ duration: __ENV.ABOVE_HOLD || '40s', target: ABOVE_RPS },
{ duration: __ENV.ABOVE_RAMP_DOWN || '20s', target: BELOW_RPS },
],
preAllocatedVUs: parseInt(__ENV.ABOVE_VUS || '100', 10),
maxVUs: parseInt(__ENV.ABOVE_MAX_VUS || '500', 10),
startTime: __ENV.ABOVE_START || '2m10s',
tags: { scenario: 'above_limit' },
},
// Scenario C: route mix (exercise route-specific limits/matching)
route_mix: {
executor: 'constant-arrival-rate',
rate: parseInt(__ENV.MIX_RPS || '100', 10),
timeUnit: '1s',
duration: __ENV.MIX_DURATION || '2m',
preAllocatedVUs: parseInt(__ENV.MIX_VUS || '75', 10),
maxVUs: parseInt(__ENV.MIX_MAX_VUS || '300', 10),
startTime: __ENV.MIX_START || '3m30s',
tags: { scenario: 'route_mix' },
},
// Scenario F: activation gate (low traffic then spike)
activation_gate: {
executor: 'ramping-arrival-rate',
startRate: 1,
timeUnit: '1s',
stages: [
{ duration: __ENV.GATE_LOW_DURATION || '2m', target: parseInt(__ENV.GATE_LOW_RPS || '5', 10) },
{ duration: __ENV.GATE_SPIKE_DURATION || '30s', target: parseInt(__ENV.GATE_SPIKE_RPS || '200', 10) },
{ duration: __ENV.GATE_RECOVERY_DURATION || '30s', target: parseInt(__ENV.GATE_LOW_RPS || '5', 10) },
],
preAllocatedVUs: parseInt(__ENV.GATE_VUS || '50', 10),
maxVUs: parseInt(__ENV.GATE_MAX_VUS || '300', 10),
startTime: __ENV.GATE_START || '5m40s',
tags: { scenario: 'activation_gate' },
},
},
thresholds: {
'http_req_failed': ['rate<0.01'],
'router_rate_limit_429_missing_retry_after': ['rate<0.001'],
},
};
export default function () {
const path = PATHS[Math.floor(Math.random() * PATHS.length)];
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const url = `${BASE_URL}${normalizedPath}`;
const headers = {
'Accept': 'application/json',
'X-Tenant-Id': TENANT_ID,
'X-Correlation-Id': `rl-load-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
};
if (AUTH_TOKEN) {
headers['Authorization'] = `Bearer ${AUTH_TOKEN}`;
}
const res = http.request(METHOD, url, null, {
headers,
tags: { endpoint: normalizedPath },
});
const is429 = res.status === 429;
rateLimitDenied.add(is429);
if (is429) {
const retryAfter = res.headers['Retry-After'];
status429MissingRetryAfter.add(!retryAfter);
if (retryAfter) {
const parsed = parseInt(retryAfter, 10);
if (!Number.isNaN(parsed)) {
retryAfterSeconds.add(parsed);
}
}
}
check(res, {
'status is 2xx or 429': (r) => (r.status >= 200 && r.status < 300) || r.status === 429,
'Retry-After present on 429': (r) => r.status !== 429 || r.headers['Retry-After'] !== undefined,
});
sleep(0.05 + Math.random() * 0.1);
}
export function setup() {
console.log(`Starting Router rate limiting load test against ${BASE_URL}`);
console.log(`Method=${METHOD}, paths=${JSON.stringify(PATHS)}`);
}
export function handleSummary(data) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
function metricValue(metricName, valueName) {
const metric = data.metrics && data.metrics[metricName];
const values = metric && metric.values;
return values ? values[valueName] : undefined;
}
const summary = {
timestampUtc: new Date().toISOString(),
baseUrl: BASE_URL,
method: METHOD,
paths: PATHS,
metrics: {
httpReqFailedRate: metricValue('http_req_failed', 'rate'),
httpReqDurationP95Ms: metricValue('http_req_duration', 'p(95)'),
rateLimitDeniedRate: metricValue('router_rate_limit_denied', 'rate'),
retryAfterP95Seconds: metricValue('router_rate_limit_retry_after_seconds', 'p(95)'),
missingRetryAfterRate: metricValue('router_rate_limit_429_missing_retry_after', 'rate'),
},
notes: [
`Set RESULTS_DIR to control file output directory (default: ${RESULTS_DIR}).`,
'Ensure the results directory exists before running if you want JSON artifacts written.',
],
};
const json = JSON.stringify(data, null, 2);
const summaryJson = JSON.stringify(summary, null, 2);
return {
stdout: `${summaryJson}\n`,
[`${RESULTS_DIR}/router-rate-limiting-load-test-${timestamp}.json`]: json,
[`${RESULTS_DIR}/router-rate-limiting-load-test-latest.json`]: json,
};
}