consolidate the tests locations
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Services;
|
||||
using StellaOps.Router.Gateway.State;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class ConnectionManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StartAsync_WhenHelloInvalid_RejectsAndClosesChannel()
|
||||
{
|
||||
var (manager, server, registry, routingState) = Create();
|
||||
var client = CreateClient(registry, server);
|
||||
|
||||
try
|
||||
{
|
||||
await manager.StartAsync(CancellationToken.None);
|
||||
|
||||
var invalid = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "inv-1",
|
||||
ServiceName = "",
|
||||
Version = "1.0.0",
|
||||
Region = "eu1"
|
||||
};
|
||||
|
||||
await client.ConnectAsync(
|
||||
invalid,
|
||||
endpoints:
|
||||
[
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "inventory",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/items"
|
||||
}
|
||||
],
|
||||
CancellationToken.None);
|
||||
|
||||
await EventuallyAsync(
|
||||
() => registry.Count == 0,
|
||||
timeout: TimeSpan.FromSeconds(5));
|
||||
|
||||
routingState.GetAllConnections().Should().BeEmpty();
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.Dispose();
|
||||
await manager.StopAsync(CancellationToken.None);
|
||||
server.Dispose();
|
||||
registry.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WhenClientDisconnects_RemovesFromRoutingState()
|
||||
{
|
||||
var (manager, server, registry, routingState) = Create();
|
||||
var client = CreateClient(registry, server);
|
||||
|
||||
try
|
||||
{
|
||||
await manager.StartAsync(CancellationToken.None);
|
||||
|
||||
await client.ConnectAsync(
|
||||
CreateInstance("inventory", "1.0.0", "inv-1"),
|
||||
endpoints:
|
||||
[
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "inventory",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/items"
|
||||
}
|
||||
],
|
||||
CancellationToken.None);
|
||||
|
||||
await EventuallyAsync(
|
||||
() => routingState.GetAllConnections().Count == 1,
|
||||
timeout: TimeSpan.FromSeconds(5));
|
||||
|
||||
client.Dispose();
|
||||
|
||||
await EventuallyAsync(
|
||||
() => routingState.GetAllConnections().Count == 0,
|
||||
timeout: TimeSpan.FromSeconds(5));
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.Dispose();
|
||||
await manager.StopAsync(CancellationToken.None);
|
||||
server.Dispose();
|
||||
registry.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WhenMultipleClientsConnect_TracksAndCleansIndependently()
|
||||
{
|
||||
var (manager, server, registry, routingState) = Create();
|
||||
var client1 = CreateClient(registry, server);
|
||||
var client2 = CreateClient(registry, server);
|
||||
|
||||
try
|
||||
{
|
||||
await manager.StartAsync(CancellationToken.None);
|
||||
|
||||
var endpoints =
|
||||
new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "inventory",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/items"
|
||||
}
|
||||
};
|
||||
|
||||
await client1.ConnectAsync(
|
||||
CreateInstance("inventory", "1.0.0", "inv-1"),
|
||||
endpoints,
|
||||
CancellationToken.None);
|
||||
|
||||
await client2.ConnectAsync(
|
||||
CreateInstance("inventory", "1.0.0", "inv-2"),
|
||||
endpoints,
|
||||
CancellationToken.None);
|
||||
|
||||
await EventuallyAsync(
|
||||
() => routingState.GetConnectionsFor("inventory", "1.0.0", "GET", "/items").Count == 2,
|
||||
timeout: TimeSpan.FromSeconds(5));
|
||||
|
||||
var before = routingState.GetConnectionsFor("inventory", "1.0.0", "GET", "/items")
|
||||
.Select(c => c.Instance.InstanceId)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
before.Should().BeEquivalentTo(new[] { "inv-1", "inv-2" });
|
||||
|
||||
client1.Dispose();
|
||||
|
||||
await EventuallyAsync(
|
||||
() => routingState.GetConnectionsFor("inventory", "1.0.0", "GET", "/items").Count == 1,
|
||||
timeout: TimeSpan.FromSeconds(5));
|
||||
|
||||
var after = routingState.GetConnectionsFor("inventory", "1.0.0", "GET", "/items")
|
||||
.Single()
|
||||
.Instance.InstanceId;
|
||||
|
||||
after.Should().Be("inv-2");
|
||||
}
|
||||
finally
|
||||
{
|
||||
client1.Dispose();
|
||||
client2.Dispose();
|
||||
await manager.StopAsync(CancellationToken.None);
|
||||
server.Dispose();
|
||||
registry.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static (ConnectionManager Manager, InMemoryTransportServer Server, InMemoryConnectionRegistry Registry, InMemoryRoutingState RoutingState) Create()
|
||||
{
|
||||
var registry = new InMemoryConnectionRegistry();
|
||||
var server = new InMemoryTransportServer(
|
||||
registry,
|
||||
Options.Create(new InMemoryTransportOptions()),
|
||||
NullLogger<InMemoryTransportServer>.Instance);
|
||||
|
||||
var routingState = new InMemoryRoutingState();
|
||||
var manager = new ConnectionManager(
|
||||
server,
|
||||
registry,
|
||||
routingState,
|
||||
NullLogger<ConnectionManager>.Instance);
|
||||
|
||||
return (manager, server, registry, routingState);
|
||||
}
|
||||
|
||||
private static InMemoryTransportClient CreateClient(InMemoryConnectionRegistry registry, InMemoryTransportServer server)
|
||||
{
|
||||
return new InMemoryTransportClient(
|
||||
registry,
|
||||
Options.Create(new InMemoryTransportOptions()),
|
||||
NullLogger<InMemoryTransportClient>.Instance,
|
||||
server);
|
||||
}
|
||||
|
||||
private static InstanceDescriptor CreateInstance(string serviceName, string version, string instanceId)
|
||||
{
|
||||
return new InstanceDescriptor
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = "eu1"
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task EventuallyAsync(Func<bool> predicate, TimeSpan timeout, TimeSpan? pollInterval = null)
|
||||
{
|
||||
pollInterval ??= TimeSpan.FromMilliseconds(25);
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (predicate())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(pollInterval.Value);
|
||||
}
|
||||
|
||||
predicate().Should().BeTrue("condition should become true within {0}", timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
using StellaOps.Router.Gateway.Routing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class DefaultRoutingPluginTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_WhenNoCandidates_ReturnsNull()
|
||||
{
|
||||
var plugin = CreatePlugin(gatewayRegion: "eu1");
|
||||
|
||||
var decision = await plugin.ChooseInstanceAsync(
|
||||
new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/items",
|
||||
Headers = new Dictionary<string, string>(),
|
||||
Endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "inventory",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/items"
|
||||
},
|
||||
AvailableConnections = [],
|
||||
GatewayRegion = "eu1",
|
||||
RequestedVersion = null,
|
||||
CancellationToken = CancellationToken.None
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
decision.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_WhenRequestedVersionDoesNotMatch_ReturnsNull()
|
||||
{
|
||||
var plugin = CreatePlugin(
|
||||
gatewayRegion: "eu1",
|
||||
options: new RoutingOptions
|
||||
{
|
||||
StrictVersionMatching = true,
|
||||
DefaultVersion = null
|
||||
});
|
||||
|
||||
var candidates = new List<ConnectionState>
|
||||
{
|
||||
CreateConnection("inv-1", "inventory", "1.0.0", region: "eu1", status: InstanceHealthStatus.Healthy)
|
||||
};
|
||||
|
||||
var decision = await plugin.ChooseInstanceAsync(
|
||||
CreateContext(
|
||||
requestedVersion: "2.0.0",
|
||||
candidates: candidates),
|
||||
CancellationToken.None);
|
||||
|
||||
decision.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_PrefersHealthyOverDegraded()
|
||||
{
|
||||
var plugin = CreatePlugin(
|
||||
gatewayRegion: "eu1",
|
||||
options: new RoutingOptions
|
||||
{
|
||||
StrictVersionMatching = true,
|
||||
DefaultVersion = null,
|
||||
PreferLocalRegion = true,
|
||||
AllowDegradedInstances = true,
|
||||
TieBreaker = TieBreakerMode.RoundRobin
|
||||
});
|
||||
|
||||
var degraded = CreateConnection("inv-degraded", "inventory", "1.0.0", region: "eu1", status: InstanceHealthStatus.Degraded);
|
||||
degraded.AveragePingMs = 1;
|
||||
|
||||
var healthy = CreateConnection("inv-healthy", "inventory", "1.0.0", region: "eu1", status: InstanceHealthStatus.Healthy);
|
||||
healthy.AveragePingMs = 50;
|
||||
|
||||
var decision = await plugin.ChooseInstanceAsync(
|
||||
CreateContext(
|
||||
requestedVersion: "1.0.0",
|
||||
candidates: [degraded, healthy]),
|
||||
CancellationToken.None);
|
||||
|
||||
decision.Should().NotBeNull();
|
||||
decision!.Connection.ConnectionId.Should().Be("inv-healthy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_PrefersLocalRegionOverRemote()
|
||||
{
|
||||
var plugin = CreatePlugin(
|
||||
gatewayRegion: "eu1",
|
||||
options: new RoutingOptions
|
||||
{
|
||||
StrictVersionMatching = true,
|
||||
DefaultVersion = null,
|
||||
PreferLocalRegion = true,
|
||||
AllowDegradedInstances = true,
|
||||
TieBreaker = TieBreakerMode.RoundRobin
|
||||
});
|
||||
|
||||
var remote = CreateConnection("inv-us1", "inventory", "1.0.0", region: "us1", status: InstanceHealthStatus.Healthy);
|
||||
var local = CreateConnection("inv-eu1", "inventory", "1.0.0", region: "eu1", status: InstanceHealthStatus.Healthy);
|
||||
|
||||
var decision = await plugin.ChooseInstanceAsync(
|
||||
CreateContext(
|
||||
requestedVersion: "1.0.0",
|
||||
candidates: [remote, local]),
|
||||
CancellationToken.None);
|
||||
|
||||
decision.Should().NotBeNull();
|
||||
decision!.Connection.ConnectionId.Should().Be("inv-eu1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_WhenNoLocal_UsesNeighborRegion()
|
||||
{
|
||||
var plugin = CreatePlugin(
|
||||
gatewayRegion: "eu1",
|
||||
gatewayNeighbors: ["eu2"],
|
||||
options: new RoutingOptions
|
||||
{
|
||||
StrictVersionMatching = true,
|
||||
DefaultVersion = null,
|
||||
PreferLocalRegion = true,
|
||||
AllowDegradedInstances = true,
|
||||
TieBreaker = TieBreakerMode.RoundRobin
|
||||
});
|
||||
|
||||
var neighbor = CreateConnection("inv-eu2", "inventory", "1.0.0", region: "eu2", status: InstanceHealthStatus.Healthy);
|
||||
var remote = CreateConnection("inv-us1", "inventory", "1.0.0", region: "us1", status: InstanceHealthStatus.Healthy);
|
||||
|
||||
var decision = await plugin.ChooseInstanceAsync(
|
||||
CreateContext(
|
||||
requestedVersion: "1.0.0",
|
||||
candidates: [remote, neighbor]),
|
||||
CancellationToken.None);
|
||||
|
||||
decision.Should().NotBeNull();
|
||||
decision!.Connection.ConnectionId.Should().Be("inv-eu2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_WhenTied_UsesRoundRobin()
|
||||
{
|
||||
var plugin = CreatePlugin(
|
||||
gatewayRegion: "eu1",
|
||||
options: new RoutingOptions
|
||||
{
|
||||
StrictVersionMatching = true,
|
||||
DefaultVersion = null,
|
||||
PreferLocalRegion = false,
|
||||
AllowDegradedInstances = true,
|
||||
TieBreaker = TieBreakerMode.RoundRobin,
|
||||
PingToleranceMs = 1_000
|
||||
});
|
||||
|
||||
var heartbeat = DateTime.UtcNow;
|
||||
|
||||
var a = CreateConnection("inv-a", "inventory", "1.0.0", region: "us1", status: InstanceHealthStatus.Healthy);
|
||||
a.AveragePingMs = 10;
|
||||
a.LastHeartbeatUtc = heartbeat;
|
||||
|
||||
var b = CreateConnection("inv-b", "inventory", "1.0.0", region: "us1", status: InstanceHealthStatus.Healthy);
|
||||
b.AveragePingMs = 10;
|
||||
b.LastHeartbeatUtc = heartbeat;
|
||||
|
||||
var ctx = CreateContext(requestedVersion: "1.0.0", candidates: [a, b]);
|
||||
|
||||
var decision1 = await plugin.ChooseInstanceAsync(ctx, CancellationToken.None);
|
||||
var decision2 = await plugin.ChooseInstanceAsync(ctx, CancellationToken.None);
|
||||
|
||||
decision1.Should().NotBeNull();
|
||||
decision2.Should().NotBeNull();
|
||||
|
||||
decision1!.Connection.ConnectionId.Should().Be("inv-a");
|
||||
decision2!.Connection.ConnectionId.Should().Be("inv-b");
|
||||
}
|
||||
|
||||
private static DefaultRoutingPlugin CreatePlugin(
|
||||
string gatewayRegion,
|
||||
RoutingOptions? options = null,
|
||||
IReadOnlyList<string>? gatewayNeighbors = null)
|
||||
{
|
||||
options ??= new RoutingOptions
|
||||
{
|
||||
StrictVersionMatching = true,
|
||||
DefaultVersion = null,
|
||||
PreferLocalRegion = true,
|
||||
AllowDegradedInstances = true,
|
||||
TieBreaker = TieBreakerMode.RoundRobin
|
||||
};
|
||||
|
||||
var node = new RouterNodeConfig
|
||||
{
|
||||
Region = gatewayRegion,
|
||||
NeighborRegions = gatewayNeighbors?.ToList() ?? []
|
||||
};
|
||||
|
||||
return new DefaultRoutingPlugin(
|
||||
Options.Create(options),
|
||||
Options.Create(node));
|
||||
}
|
||||
|
||||
private static RoutingContext CreateContext(
|
||||
string? requestedVersion,
|
||||
IReadOnlyList<ConnectionState> candidates)
|
||||
{
|
||||
return new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/items",
|
||||
Headers = new Dictionary<string, string>(),
|
||||
Endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "inventory",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/items"
|
||||
},
|
||||
AvailableConnections = candidates,
|
||||
GatewayRegion = "eu1",
|
||||
RequestedVersion = requestedVersion,
|
||||
CancellationToken = CancellationToken.None
|
||||
};
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId,
|
||||
string serviceName,
|
||||
string version,
|
||||
string region,
|
||||
InstanceHealthStatus status)
|
||||
{
|
||||
return new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = connectionId,
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = region
|
||||
},
|
||||
Status = status,
|
||||
LastHeartbeatUtc = DateTime.UtcNow,
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.State;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class InMemoryRoutingStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveEndpoint_WhenExactMatch_ReturnsEndpointDescriptor()
|
||||
{
|
||||
var routingState = new InMemoryRoutingState();
|
||||
|
||||
var connection = CreateConnection("conn-1", "inventory", "1.0.0", region: "test");
|
||||
connection.Endpoints[("GET", "/items")] = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "inventory",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/items"
|
||||
};
|
||||
|
||||
routingState.AddConnection(connection);
|
||||
|
||||
var resolved = routingState.ResolveEndpoint("GET", "/items");
|
||||
|
||||
resolved.Should().NotBeNull();
|
||||
resolved!.ServiceName.Should().Be("inventory");
|
||||
resolved.Version.Should().Be("1.0.0");
|
||||
resolved.Method.Should().Be("GET");
|
||||
resolved.Path.Should().Be("/items");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpoint_WhenTemplateMatch_ReturnsEndpointDescriptor()
|
||||
{
|
||||
var routingState = new InMemoryRoutingState();
|
||||
|
||||
var connection = CreateConnection("conn-1", "inventory", "1.0.0", region: "test");
|
||||
connection.Endpoints[("GET", "/items/{sku}")] = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "inventory",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/items/{sku}"
|
||||
};
|
||||
|
||||
routingState.AddConnection(connection);
|
||||
|
||||
var resolved = routingState.ResolveEndpoint("GET", "/items/SKU-001");
|
||||
|
||||
resolved.Should().NotBeNull();
|
||||
resolved!.Path.Should().Be("/items/{sku}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveConnection_RemovesEndpointsFromIndex()
|
||||
{
|
||||
var routingState = new InMemoryRoutingState();
|
||||
|
||||
var connection = CreateConnection("conn-1", "inventory", "1.0.0", region: "test");
|
||||
connection.Endpoints[("GET", "/items")] = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "inventory",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/items"
|
||||
};
|
||||
|
||||
routingState.AddConnection(connection);
|
||||
routingState.ResolveEndpoint("GET", "/items").Should().NotBeNull();
|
||||
|
||||
routingState.RemoveConnection("conn-1");
|
||||
|
||||
routingState.ResolveEndpoint("GET", "/items").Should().BeNull();
|
||||
routingState.GetAllConnections().Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnectionsFor_FiltersByServiceAndVersion()
|
||||
{
|
||||
var routingState = new InMemoryRoutingState();
|
||||
|
||||
var inventoryV1 = CreateConnection("inv-v1", "inventory", "1.0.0", region: "test");
|
||||
inventoryV1.Endpoints[("GET", "/items")] = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "inventory",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/items"
|
||||
};
|
||||
|
||||
var inventoryV2 = CreateConnection("inv-v2", "inventory", "2.0.0", region: "test");
|
||||
inventoryV2.Endpoints[("GET", "/items")] = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "inventory",
|
||||
Version = "2.0.0",
|
||||
Method = "GET",
|
||||
Path = "/items"
|
||||
};
|
||||
|
||||
routingState.AddConnection(inventoryV1);
|
||||
routingState.AddConnection(inventoryV2);
|
||||
|
||||
var connections = routingState.GetConnectionsFor(
|
||||
serviceName: "inventory",
|
||||
version: "1.0.0",
|
||||
method: "GET",
|
||||
path: "/items");
|
||||
|
||||
connections.Should().HaveCount(1);
|
||||
connections[0].ConnectionId.Should().Be("inv-v1");
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId,
|
||||
string serviceName,
|
||||
string version,
|
||||
string region)
|
||||
{
|
||||
return new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = connectionId,
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = region
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
LastHeartbeatUtc = DateTime.UtcNow,
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Authorization;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
using StellaOps.Router.Gateway.Middleware;
|
||||
using StellaOps.Router.Gateway.State;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class MiddlewareErrorScenarioTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EndpointResolutionMiddleware_WhenNoEndpoint_Returns404StructuredError()
|
||||
{
|
||||
var context = CreateContext(method: "GET", path: "/missing");
|
||||
var routingState = new InMemoryRoutingState();
|
||||
|
||||
var nextCalled = false;
|
||||
var middleware = new EndpointResolutionMiddleware(_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
await middleware.Invoke(context, routingState);
|
||||
|
||||
nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
|
||||
|
||||
var body = ReadJson(context);
|
||||
body.GetProperty("error").GetString().Should().Be("Endpoint not found");
|
||||
body.GetProperty("status").GetInt32().Should().Be(404);
|
||||
body.GetProperty("method").GetString().Should().Be("GET");
|
||||
body.GetProperty("path").GetString().Should().Be("/missing");
|
||||
body.GetProperty("traceId").GetString().Should().Be("trace-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoutingDecisionMiddleware_WhenNoInstances_Returns503StructuredError()
|
||||
{
|
||||
var context = CreateContext(method: "GET", path: "/items");
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor] = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "inventory",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/items"
|
||||
};
|
||||
|
||||
var routingState = new InMemoryRoutingState();
|
||||
var plugin = new NullRoutingPlugin();
|
||||
|
||||
var nextCalled = false;
|
||||
var middleware = new RoutingDecisionMiddleware(_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
plugin,
|
||||
routingState,
|
||||
Options.Create(new RouterNodeConfig { Region = "eu1", NodeId = "gw-eu1-01" }),
|
||||
Options.Create(new RoutingOptions { DefaultVersion = null }));
|
||||
|
||||
nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
var body = ReadJson(context);
|
||||
body.GetProperty("error").GetString().Should().Be("No instances available");
|
||||
body.GetProperty("status").GetInt32().Should().Be(503);
|
||||
body.GetProperty("service").GetString().Should().Be("inventory");
|
||||
body.GetProperty("version").GetString().Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthorizationMiddleware_WhenMissingClaim_Returns403StructuredError()
|
||||
{
|
||||
var context = CreateContext(method: "GET", path: "/items");
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[new Claim("scope", "user")],
|
||||
authenticationType: "test"));
|
||||
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor] = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "inventory",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/items",
|
||||
RequiringClaims = []
|
||||
};
|
||||
|
||||
var claimsStore = new StaticClaimsStore(
|
||||
[new ClaimRequirement { Type = "scope", Value = "admin" }]);
|
||||
|
||||
var nextCalled = false;
|
||||
var middleware = new AuthorizationMiddleware(
|
||||
_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
claimsStore,
|
||||
NullLogger<AuthorizationMiddleware>.Instance);
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
|
||||
var body = ReadJson(context);
|
||||
body.GetProperty("error").GetString().Should().Be("Forbidden");
|
||||
body.GetProperty("status").GetInt32().Should().Be(403);
|
||||
body.GetProperty("service").GetString().Should().Be("inventory");
|
||||
body.GetProperty("version").GetString().Should().Be("1.0.0");
|
||||
body.GetProperty("details").GetProperty("requiredClaimType").GetString().Should().Be("scope");
|
||||
body.GetProperty("details").GetProperty("requiredClaimValue").GetString().Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GlobalErrorHandlerMiddleware_WhenUnhandledException_Returns500StructuredError()
|
||||
{
|
||||
var context = CreateContext(method: "GET", path: "/boom");
|
||||
var environment = new TestHostEnvironment { EnvironmentName = Environments.Development };
|
||||
|
||||
var middleware = new GlobalErrorHandlerMiddleware(
|
||||
_ => throw new InvalidOperationException("boom"),
|
||||
NullLogger<GlobalErrorHandlerMiddleware>.Instance,
|
||||
environment);
|
||||
|
||||
await middleware.Invoke(context);
|
||||
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
|
||||
|
||||
var body = ReadJson(context);
|
||||
body.GetProperty("error").GetString().Should().Be("Internal Server Error");
|
||||
body.GetProperty("status").GetInt32().Should().Be(500);
|
||||
body.GetProperty("message").GetString().Should().Be("boom");
|
||||
}
|
||||
|
||||
private static DefaultHttpContext CreateContext(string method, string path, string? queryString = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddOptions();
|
||||
|
||||
var context = new DefaultHttpContext
|
||||
{
|
||||
RequestServices = services.BuildServiceProvider()
|
||||
};
|
||||
|
||||
context.TraceIdentifier = "trace-1";
|
||||
context.Request.Method = method;
|
||||
context.Request.Path = path;
|
||||
context.Request.QueryString = string.IsNullOrWhiteSpace(queryString) ? QueryString.Empty : new QueryString(queryString);
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static JsonElement ReadJson(DefaultHttpContext context)
|
||||
{
|
||||
context.Response.Body.Position = 0;
|
||||
using var doc = JsonDocument.Parse(context.Response.Body);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
|
||||
private sealed class NullRoutingPlugin : IRoutingPlugin
|
||||
{
|
||||
public Task<RoutingDecision?> ChooseInstanceAsync(RoutingContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<RoutingDecision?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StaticClaimsStore : IEffectiveClaimsStore
|
||||
{
|
||||
private readonly IReadOnlyList<ClaimRequirement> _claims;
|
||||
|
||||
public StaticClaimsStore(IReadOnlyList<ClaimRequirement> claims)
|
||||
{
|
||||
_claims = claims;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ClaimRequirement> GetEffectiveClaims(string serviceName, string method, string path) => _claims;
|
||||
|
||||
public void UpdateFromMicroservice(string serviceName, IReadOnlyList<EndpointDescriptor> endpoints)
|
||||
{
|
||||
}
|
||||
|
||||
public void UpdateFromAuthority(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides)
|
||||
{
|
||||
}
|
||||
|
||||
public void RemoveService(string serviceName)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestHostEnvironment : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = Environments.Production;
|
||||
public string ApplicationName { get; set; } = "StellaOps.Router.Gateway.Tests";
|
||||
public string ContentRootPath { get; set; } = Environment.CurrentDirectory;
|
||||
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RoutingDecisionPropertyTests.cs
|
||||
// Sprint: SPRINT_5100_0007_0001_testing_strategy_2026
|
||||
// Task: TEST-STRAT-5100-004 - Property-based tests for routing/decision logic
|
||||
// Description: FsCheck property tests for DefaultRoutingPlugin routing invariants
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using FsCheck;
|
||||
using FsCheck.Xunit;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
using StellaOps.Router.Gateway.Routing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests.Properties;
|
||||
|
||||
/// <summary>
|
||||
/// Property-based tests for routing decision logic using FsCheck.
|
||||
/// Tests verify invariants of the DefaultRoutingPlugin routing algorithm.
|
||||
/// </summary>
|
||||
public sealed class RoutingDecisionPropertyTests
|
||||
{
|
||||
#region Generators
|
||||
|
||||
/// <summary>
|
||||
/// Generates a random ConnectionState with valid values.
|
||||
/// </summary>
|
||||
private static Gen<ConnectionState> GenerateConnection(
|
||||
string? forcedRegion = null,
|
||||
InstanceHealthStatus? forcedStatus = null,
|
||||
string? forcedVersion = null)
|
||||
{
|
||||
return from connectionId in Gen.Elements("conn-1", "conn-2", "conn-3", "conn-4", "conn-5")
|
||||
from serviceName in Gen.Constant("test-service")
|
||||
from version in forcedVersion != null
|
||||
? Gen.Constant(forcedVersion)
|
||||
: Gen.Elements("1.0.0", "1.1.0", "2.0.0")
|
||||
from region in forcedRegion != null
|
||||
? Gen.Constant(forcedRegion)
|
||||
: Gen.Elements("eu1", "eu2", "us1", "us2", "ap1")
|
||||
from status in forcedStatus.HasValue
|
||||
? Gen.Constant(forcedStatus.Value)
|
||||
: Gen.Elements(InstanceHealthStatus.Healthy, InstanceHealthStatus.Degraded, InstanceHealthStatus.Unhealthy)
|
||||
from pingMs in Gen.Choose(1, 500)
|
||||
select new ConnectionState
|
||||
{
|
||||
ConnectionId = $"{connectionId}-{region}",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = $"{connectionId}-{region}",
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = region
|
||||
},
|
||||
Status = status,
|
||||
AveragePingMs = pingMs,
|
||||
LastHeartbeatUtc = DateTimeOffset.UtcNow.AddSeconds(-pingMs % 60)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a list of connection candidates.
|
||||
/// </summary>
|
||||
private static Gen<List<ConnectionState>> GenerateCandidates(
|
||||
int minCount = 1,
|
||||
int maxCount = 10,
|
||||
string? forcedRegion = null,
|
||||
InstanceHealthStatus? forcedStatus = null)
|
||||
{
|
||||
return from count in Gen.Choose(minCount, maxCount)
|
||||
from connections in Gen.ListOf(count, GenerateConnection(forcedRegion, forcedStatus))
|
||||
select connections.DistinctBy(c => c.ConnectionId).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates RoutingOptions with valid combinations.
|
||||
/// </summary>
|
||||
private static Gen<RoutingOptions> GenerateRoutingOptions()
|
||||
{
|
||||
return from preferLocal in Arb.Generate<bool>()
|
||||
from allowDegraded in Arb.Generate<bool>()
|
||||
from strictVersion in Arb.Generate<bool>()
|
||||
from tieBreaker in Gen.Elements(TieBreakerMode.Random, TieBreakerMode.RoundRobin, TieBreakerMode.LowestLatency)
|
||||
select new RoutingOptions
|
||||
{
|
||||
PreferLocalRegion = preferLocal,
|
||||
AllowDegradedInstances = allowDegraded,
|
||||
StrictVersionMatching = strictVersion,
|
||||
TieBreaker = tieBreaker,
|
||||
RoutingTimeoutMs = 5000,
|
||||
DefaultVersion = null
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Tests - Determinism
|
||||
|
||||
[Property(MaxTest = 100, Arbitrary = new[] { typeof(ConnectionArbitrary) })]
|
||||
public void SameInputs_ProduceDeterministicDecisions()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions
|
||||
{
|
||||
PreferLocalRegion = true,
|
||||
AllowDegradedInstances = true,
|
||||
StrictVersionMatching = true,
|
||||
TieBreaker = TieBreakerMode.LowestLatency,
|
||||
RoutingTimeoutMs = 5000
|
||||
};
|
||||
|
||||
var plugin = CreatePlugin("eu1", options);
|
||||
var candidates = CreateFixedCandidates();
|
||||
|
||||
// Act - Run routing multiple times
|
||||
var decisions = new List<string?>();
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var decision = plugin.ChooseInstanceAsync(
|
||||
CreateContext("1.0.0", candidates),
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
decisions.Add(decision?.Connection?.ConnectionId);
|
||||
}
|
||||
|
||||
// Assert - All decisions should be identical
|
||||
decisions.All(d => d == decisions[0]).Should().BeTrue(
|
||||
"same inputs with deterministic tie-breaker should produce same routing decision");
|
||||
}
|
||||
|
||||
[Property(MaxTest = 100)]
|
||||
public void EmptyCandidates_AlwaysReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var optionsGen = GenerateRoutingOptions();
|
||||
var options = optionsGen.Sample(1, 1).First();
|
||||
var plugin = CreatePlugin("eu1", options);
|
||||
|
||||
// Act
|
||||
var decision = plugin.ChooseInstanceAsync(
|
||||
CreateContext("1.0.0", []),
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
// Assert
|
||||
decision.Should().BeNull("empty candidates should always return null");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Tests - Health Preference
|
||||
|
||||
[Property(MaxTest = 100)]
|
||||
public void HealthyPreferred_WhenHealthyExists_NeverChoosesDegraded()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions
|
||||
{
|
||||
PreferLocalRegion = false,
|
||||
AllowDegradedInstances = true,
|
||||
StrictVersionMatching = false,
|
||||
TieBreaker = TieBreakerMode.LowestLatency,
|
||||
RoutingTimeoutMs = 5000
|
||||
};
|
||||
|
||||
var plugin = CreatePlugin("eu1", options);
|
||||
|
||||
// Create mixed candidates with both healthy and degraded
|
||||
var healthy = new ConnectionState
|
||||
{
|
||||
ConnectionId = "healthy-1",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = "healthy-1",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "eu1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
AveragePingMs = 100 // Higher latency but healthy
|
||||
};
|
||||
|
||||
var degraded = new ConnectionState
|
||||
{
|
||||
ConnectionId = "degraded-1",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = "degraded-1",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "eu1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Degraded,
|
||||
AveragePingMs = 1 // Lower latency but degraded
|
||||
};
|
||||
|
||||
var candidates = new List<ConnectionState> { degraded, healthy };
|
||||
|
||||
// Act
|
||||
var decision = plugin.ChooseInstanceAsync(
|
||||
CreateContext(null, candidates),
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
// Assert
|
||||
decision.Should().NotBeNull();
|
||||
decision!.Connection.Status.Should().Be(InstanceHealthStatus.Healthy,
|
||||
"healthy instances should always be preferred over degraded");
|
||||
}
|
||||
|
||||
[Property(MaxTest = 100)]
|
||||
public void WhenOnlyDegraded_AndAllowDegradedTrue_SelectsDegraded()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions
|
||||
{
|
||||
PreferLocalRegion = false,
|
||||
AllowDegradedInstances = true,
|
||||
StrictVersionMatching = false,
|
||||
TieBreaker = TieBreakerMode.LowestLatency,
|
||||
RoutingTimeoutMs = 5000
|
||||
};
|
||||
|
||||
var plugin = CreatePlugin("eu1", options);
|
||||
|
||||
var degraded1 = new ConnectionState
|
||||
{
|
||||
ConnectionId = "degraded-1",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = "degraded-1",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "eu1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Degraded,
|
||||
AveragePingMs = 10
|
||||
};
|
||||
|
||||
var degraded2 = new ConnectionState
|
||||
{
|
||||
ConnectionId = "degraded-2",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = "degraded-2",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "eu1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Degraded,
|
||||
AveragePingMs = 20
|
||||
};
|
||||
|
||||
var candidates = new List<ConnectionState> { degraded1, degraded2 };
|
||||
|
||||
// Act
|
||||
var decision = plugin.ChooseInstanceAsync(
|
||||
CreateContext(null, candidates),
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
// Assert
|
||||
decision.Should().NotBeNull("degraded instances should be selected when no healthy available and AllowDegradedInstances=true");
|
||||
decision!.Connection.Status.Should().Be(InstanceHealthStatus.Degraded);
|
||||
}
|
||||
|
||||
[Property(MaxTest = 100)]
|
||||
public void WhenOnlyDegraded_AndAllowDegradedFalse_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions
|
||||
{
|
||||
PreferLocalRegion = false,
|
||||
AllowDegradedInstances = false,
|
||||
StrictVersionMatching = false,
|
||||
TieBreaker = TieBreakerMode.LowestLatency,
|
||||
RoutingTimeoutMs = 5000
|
||||
};
|
||||
|
||||
var plugin = CreatePlugin("eu1", options);
|
||||
|
||||
var degraded = new ConnectionState
|
||||
{
|
||||
ConnectionId = "degraded-1",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = "degraded-1",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "eu1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Degraded,
|
||||
AveragePingMs = 10
|
||||
};
|
||||
|
||||
var candidates = new List<ConnectionState> { degraded };
|
||||
|
||||
// Act
|
||||
var decision = plugin.ChooseInstanceAsync(
|
||||
CreateContext(null, candidates),
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
// Assert
|
||||
decision.Should().BeNull("degraded instances should not be selected when AllowDegradedInstances=false");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Tests - Region Tier Preference
|
||||
|
||||
[Property(MaxTest = 100)]
|
||||
public void LocalRegion_AlwaysPreferred_WhenAvailable()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions
|
||||
{
|
||||
PreferLocalRegion = true,
|
||||
AllowDegradedInstances = false,
|
||||
StrictVersionMatching = false,
|
||||
TieBreaker = TieBreakerMode.LowestLatency,
|
||||
RoutingTimeoutMs = 5000
|
||||
};
|
||||
|
||||
var gatewayRegion = "eu1";
|
||||
var plugin = CreatePlugin(gatewayRegion, options);
|
||||
|
||||
var localInstance = new ConnectionState
|
||||
{
|
||||
ConnectionId = "local-1",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = "local-1",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "eu1" // Same as gateway
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
AveragePingMs = 100 // Higher latency
|
||||
};
|
||||
|
||||
var remoteInstance = new ConnectionState
|
||||
{
|
||||
ConnectionId = "remote-1",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = "remote-1",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "us1" // Different region
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
AveragePingMs = 1 // Lower latency
|
||||
};
|
||||
|
||||
var candidates = new List<ConnectionState> { remoteInstance, localInstance };
|
||||
|
||||
// Act
|
||||
var decision = plugin.ChooseInstanceAsync(
|
||||
CreateContext(null, candidates, gatewayRegion),
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
// Assert
|
||||
decision.Should().NotBeNull();
|
||||
decision!.Connection.Instance.Region.Should().Be(gatewayRegion,
|
||||
"local region should always be preferred when PreferLocalRegion=true");
|
||||
}
|
||||
|
||||
[Property(MaxTest = 100)]
|
||||
public void WhenNoLocalRegion_FallsBackToRemote()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions
|
||||
{
|
||||
PreferLocalRegion = true,
|
||||
AllowDegradedInstances = false,
|
||||
StrictVersionMatching = false,
|
||||
TieBreaker = TieBreakerMode.LowestLatency,
|
||||
RoutingTimeoutMs = 5000
|
||||
};
|
||||
|
||||
var gatewayRegion = "eu1";
|
||||
var plugin = CreatePlugin(gatewayRegion, options);
|
||||
|
||||
var remoteInstance = new ConnectionState
|
||||
{
|
||||
ConnectionId = "remote-1",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = "remote-1",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "us1" // Different region
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
AveragePingMs = 10
|
||||
};
|
||||
|
||||
var candidates = new List<ConnectionState> { remoteInstance };
|
||||
|
||||
// Act
|
||||
var decision = plugin.ChooseInstanceAsync(
|
||||
CreateContext(null, candidates, gatewayRegion),
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
// Assert
|
||||
decision.Should().NotBeNull("should fallback to remote region when no local available");
|
||||
decision!.Connection.Instance.Region.Should().Be("us1");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Tests - Version Matching
|
||||
|
||||
[Property(MaxTest = 100)]
|
||||
public void StrictVersionMatching_RejectsNonMatchingVersions()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions
|
||||
{
|
||||
PreferLocalRegion = false,
|
||||
AllowDegradedInstances = true,
|
||||
StrictVersionMatching = true,
|
||||
TieBreaker = TieBreakerMode.LowestLatency,
|
||||
RoutingTimeoutMs = 5000
|
||||
};
|
||||
|
||||
var plugin = CreatePlugin("eu1", options);
|
||||
|
||||
var v1Instance = new ConnectionState
|
||||
{
|
||||
ConnectionId = "v1-1",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = "v1-1",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "eu1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
AveragePingMs = 10
|
||||
};
|
||||
|
||||
var v2Instance = new ConnectionState
|
||||
{
|
||||
ConnectionId = "v2-1",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = "v2-1",
|
||||
ServiceName = "test-service",
|
||||
Version = "2.0.0",
|
||||
Region = "eu1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
AveragePingMs = 10
|
||||
};
|
||||
|
||||
var candidates = new List<ConnectionState> { v1Instance, v2Instance };
|
||||
|
||||
// Act
|
||||
var decision = plugin.ChooseInstanceAsync(
|
||||
CreateContext("2.0.0", candidates),
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
// Assert
|
||||
decision.Should().NotBeNull();
|
||||
decision!.Connection.Instance.Version.Should().Be("2.0.0",
|
||||
"strict version matching should only select matching version");
|
||||
}
|
||||
|
||||
[Property(MaxTest = 100)]
|
||||
public void RequestedVersion_NotAvailable_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions
|
||||
{
|
||||
PreferLocalRegion = false,
|
||||
AllowDegradedInstances = true,
|
||||
StrictVersionMatching = true,
|
||||
TieBreaker = TieBreakerMode.LowestLatency,
|
||||
RoutingTimeoutMs = 5000
|
||||
};
|
||||
|
||||
var plugin = CreatePlugin("eu1", options);
|
||||
|
||||
var v1Instance = new ConnectionState
|
||||
{
|
||||
ConnectionId = "v1-1",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = "v1-1",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "eu1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
AveragePingMs = 10
|
||||
};
|
||||
|
||||
var candidates = new List<ConnectionState> { v1Instance };
|
||||
|
||||
// Act
|
||||
var decision = plugin.ChooseInstanceAsync(
|
||||
CreateContext("3.0.0", candidates),
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
// Assert
|
||||
decision.Should().BeNull("requested version not available should return null");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Tests - Tie-Breaker Behavior
|
||||
|
||||
[Property(MaxTest = 100)]
|
||||
public void LowestLatency_TieBreaker_SelectsLowestPing()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions
|
||||
{
|
||||
PreferLocalRegion = false,
|
||||
AllowDegradedInstances = false,
|
||||
StrictVersionMatching = false,
|
||||
TieBreaker = TieBreakerMode.LowestLatency,
|
||||
RoutingTimeoutMs = 5000
|
||||
};
|
||||
|
||||
var plugin = CreatePlugin("eu1", options);
|
||||
|
||||
var highLatency = new ConnectionState
|
||||
{
|
||||
ConnectionId = "high-1",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = "high-1",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "eu1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
AveragePingMs = 100
|
||||
};
|
||||
|
||||
var lowLatency = new ConnectionState
|
||||
{
|
||||
ConnectionId = "low-1",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = "low-1",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "eu1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
AveragePingMs = 10
|
||||
};
|
||||
|
||||
var candidates = new List<ConnectionState> { highLatency, lowLatency };
|
||||
|
||||
// Act
|
||||
var decision = plugin.ChooseInstanceAsync(
|
||||
CreateContext(null, candidates),
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
// Assert
|
||||
decision.Should().NotBeNull();
|
||||
decision!.Connection.ConnectionId.Should().Be("low-1",
|
||||
"lowest latency tie-breaker should select instance with lowest ping");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Tests - Invariants
|
||||
|
||||
[Property(MaxTest = 100)]
|
||||
public void DecisionAlwaysIncludesEndpoint()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions
|
||||
{
|
||||
PreferLocalRegion = false,
|
||||
AllowDegradedInstances = true,
|
||||
StrictVersionMatching = false,
|
||||
TieBreaker = TieBreakerMode.LowestLatency,
|
||||
RoutingTimeoutMs = 5000
|
||||
};
|
||||
|
||||
var plugin = CreatePlugin("eu1", options);
|
||||
var candidates = CreateFixedCandidates();
|
||||
|
||||
// Act
|
||||
var decision = plugin.ChooseInstanceAsync(
|
||||
CreateContext(null, candidates),
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
// Assert
|
||||
decision.Should().NotBeNull();
|
||||
decision!.Endpoint.Should().NotBeNull("decision should always include endpoint");
|
||||
decision.Connection.Should().NotBeNull("decision should always include connection");
|
||||
}
|
||||
|
||||
[Property(MaxTest = 100)]
|
||||
public void UnhealthyInstances_NeverSelected()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions
|
||||
{
|
||||
PreferLocalRegion = false,
|
||||
AllowDegradedInstances = true,
|
||||
StrictVersionMatching = false,
|
||||
TieBreaker = TieBreakerMode.LowestLatency,
|
||||
RoutingTimeoutMs = 5000
|
||||
};
|
||||
|
||||
var plugin = CreatePlugin("eu1", options);
|
||||
|
||||
var unhealthy = new ConnectionState
|
||||
{
|
||||
ConnectionId = "unhealthy-1",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = "unhealthy-1",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "eu1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Unhealthy,
|
||||
AveragePingMs = 1 // Even with lowest latency
|
||||
};
|
||||
|
||||
var candidates = new List<ConnectionState> { unhealthy };
|
||||
|
||||
// Act
|
||||
var decision = plugin.ChooseInstanceAsync(
|
||||
CreateContext(null, candidates),
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
// Assert
|
||||
decision.Should().BeNull("unhealthy instances should never be selected");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static DefaultRoutingPlugin CreatePlugin(string gatewayRegion, RoutingOptions? options = null)
|
||||
{
|
||||
options ??= new RoutingOptions
|
||||
{
|
||||
PreferLocalRegion = true,
|
||||
AllowDegradedInstances = true,
|
||||
StrictVersionMatching = false,
|
||||
TieBreaker = TieBreakerMode.LowestLatency,
|
||||
RoutingTimeoutMs = 5000
|
||||
};
|
||||
|
||||
var gatewayConfig = new RouterNodeConfig
|
||||
{
|
||||
Region = gatewayRegion,
|
||||
NeighborRegions = ["eu2", "eu3"]
|
||||
};
|
||||
|
||||
return new DefaultRoutingPlugin(
|
||||
Options.Create(options),
|
||||
Options.Create(gatewayConfig));
|
||||
}
|
||||
|
||||
private static RoutingContext CreateContext(
|
||||
string? requestedVersion,
|
||||
List<ConnectionState> candidates,
|
||||
string gatewayRegion = "eu1")
|
||||
{
|
||||
return new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/test",
|
||||
Headers = new Dictionary<string, string>(),
|
||||
Endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/test"
|
||||
},
|
||||
AvailableConnections = candidates,
|
||||
GatewayRegion = gatewayRegion,
|
||||
RequestedVersion = requestedVersion,
|
||||
CancellationToken = CancellationToken.None
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ConnectionState> CreateFixedCandidates()
|
||||
{
|
||||
return
|
||||
[
|
||||
new ConnectionState
|
||||
{
|
||||
ConnectionId = "conn-1",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = "conn-1",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "eu1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
AveragePingMs = 10
|
||||
},
|
||||
new ConnectionState
|
||||
{
|
||||
ConnectionId = "conn-2",
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = "conn-2",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "eu1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
AveragePingMs = 20
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom Arbitrary for generating ConnectionState instances.
|
||||
/// </summary>
|
||||
public class ConnectionArbitrary
|
||||
{
|
||||
public static Arbitrary<ConnectionState> ConnectionState()
|
||||
{
|
||||
return Arb.From(Gen.Elements(
|
||||
CreateConn("c1", "eu1", InstanceHealthStatus.Healthy, 10),
|
||||
CreateConn("c2", "eu1", InstanceHealthStatus.Healthy, 20),
|
||||
CreateConn("c3", "eu2", InstanceHealthStatus.Healthy, 30),
|
||||
CreateConn("c4", "us1", InstanceHealthStatus.Degraded, 5)));
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConn(string id, string region, InstanceHealthStatus status, int pingMs)
|
||||
{
|
||||
return new ConnectionState
|
||||
{
|
||||
ConnectionId = id,
|
||||
Instance = new ServiceInstance
|
||||
{
|
||||
InstanceId = id,
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = region
|
||||
},
|
||||
Status = status,
|
||||
AveragePingMs = pingMs
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,40 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
using StellaOps.Router.Gateway.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class RouterNodeConfigValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void RouterNodeConfig_WhenRegionMissing_ThrowsOptionsValidationException()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddRouterGatewayCore();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var act = () => provider.GetRequiredService<IOptions<RouterNodeConfig>>().Value;
|
||||
|
||||
act.Should().Throw<OptionsValidationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouterNodeConfig_WhenRegionProvided_GeneratesNodeIdIfMissing()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddRouterGatewayCore();
|
||||
services.Configure<RouterNodeConfig>(c => c.Region = "test");
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var config = provider.GetRequiredService<IOptions<RouterNodeConfig>>().Value;
|
||||
|
||||
config.Region.Should().Be("test");
|
||||
config.NodeId.Should().StartWith("gw-test-");
|
||||
config.NodeId.Should().HaveLength("gw-test-".Length + 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<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="..\__Libraries\StellaOps.Router.Gateway\StellaOps.Router.Gateway.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="FsCheck" Version="2.16.6" />
|
||||
<PackageReference Include="FsCheck.Xunit" Version="2.16.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="Testcontainers" Version="4.4.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,81 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
[Collection(nameof(ValkeyTestcontainerCollection))]
|
||||
public sealed class ValkeyRateLimitStoreIntegrationTests
|
||||
{
|
||||
private readonly ValkeyTestcontainerFixture _valkey;
|
||||
|
||||
public ValkeyRateLimitStoreIntegrationTests(ValkeyTestcontainerFixture valkey)
|
||||
{
|
||||
_valkey = valkey;
|
||||
}
|
||||
|
||||
[IntegrationFact]
|
||||
public async Task IncrementAndCheckAsync_UsesSmallestWindowAsRepresentativeWhenAllowed()
|
||||
{
|
||||
var bucket = $"stella-router-rate-limit-it-{Guid.NewGuid():N}";
|
||||
using var store = new ValkeyRateLimitStore(_valkey.ConnectionString, bucket);
|
||||
|
||||
var rules = new[]
|
||||
{
|
||||
new RateLimitRule { PerSeconds = 3600, MaxRequests = 1000 },
|
||||
new RateLimitRule { PerSeconds = 60, MaxRequests = 10 },
|
||||
};
|
||||
|
||||
var result = await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None);
|
||||
|
||||
result.Allowed.Should().BeTrue();
|
||||
result.WindowSeconds.Should().Be(60);
|
||||
result.Limit.Should().Be(10);
|
||||
result.CurrentCount.Should().Be(1);
|
||||
result.RetryAfterSeconds.Should().Be(0);
|
||||
}
|
||||
|
||||
[IntegrationFact]
|
||||
public async Task IncrementAndCheckAsync_DeniesWhenLimitExceeded()
|
||||
{
|
||||
var bucket = $"stella-router-rate-limit-it-{Guid.NewGuid():N}";
|
||||
using var store = new ValkeyRateLimitStore(_valkey.ConnectionString, bucket);
|
||||
|
||||
var rules = new[]
|
||||
{
|
||||
new RateLimitRule { PerSeconds = 2, MaxRequests = 1 },
|
||||
};
|
||||
|
||||
(await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None)).Allowed.Should().BeTrue();
|
||||
var denied = await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None);
|
||||
|
||||
denied.Allowed.Should().BeFalse();
|
||||
denied.WindowSeconds.Should().Be(2);
|
||||
denied.Limit.Should().Be(1);
|
||||
denied.CurrentCount.Should().Be(2);
|
||||
denied.RetryAfterSeconds.Should().BeInRange(1, 2);
|
||||
}
|
||||
|
||||
[IntegrationFact]
|
||||
public async Task IncrementAndCheckAsync_ReturnsMostRestrictiveRetryAfterAcrossRules()
|
||||
{
|
||||
var bucket = $"stella-router-rate-limit-it-{Guid.NewGuid():N}";
|
||||
using var store = new ValkeyRateLimitStore(_valkey.ConnectionString, bucket);
|
||||
|
||||
var rules = new[]
|
||||
{
|
||||
new RateLimitRule { PerSeconds = 60, MaxRequests = 1 },
|
||||
new RateLimitRule { PerSeconds = 2, MaxRequests = 100 },
|
||||
};
|
||||
|
||||
(await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None)).Allowed.Should().BeTrue();
|
||||
var denied = await store.IncrementAndCheckAsync("svc", rules, CancellationToken.None);
|
||||
|
||||
denied.Allowed.Should().BeFalse();
|
||||
denied.WindowSeconds.Should().Be(60);
|
||||
denied.Limit.Should().Be(1);
|
||||
denied.CurrentCount.Should().Be(2);
|
||||
denied.RetryAfterSeconds.Should().BeInRange(1, 60);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class ValkeyTestcontainerFixture : IAsyncLifetime
|
||||
{
|
||||
private IContainer? _container;
|
||||
|
||||
public string ConnectionString { get; private set; } = "";
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (!IntegrationTestSettings.IsEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_container = new ContainerBuilder()
|
||||
.WithImage("valkey/valkey:8-alpine")
|
||||
.WithPortBinding(6379, true)
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(6379))
|
||||
.Build();
|
||||
|
||||
await _container.StartAsync();
|
||||
|
||||
var port = _container.GetMappedPublicPort(6379);
|
||||
ConnectionString = $"{_container.Hostname}:{port}";
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_container is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _container.StopAsync();
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(nameof(ValkeyTestcontainerCollection))]
|
||||
public sealed class ValkeyTestcontainerCollection : ICollectionFixture<ValkeyTestcontainerFixture>
|
||||
{
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user