work work hard work
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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` |
|
||||
|
||||
201
tests/load/router-rate-limiting-load-test.js
Normal file
201
tests/load/router-rate-limiting-load-test.js
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user