Fix router frontdoor readiness and route contracts
This commit is contained in:
@@ -353,6 +353,56 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RegexWithValidCaptureGroupRefs_DoesNotThrow()
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = @"^/api/v1/([^/]+)(.*)",
|
||||
IsRegex = true,
|
||||
TranslatesTo = "http://$1.stella-ops.local/api/v1/$1$2"
|
||||
});
|
||||
|
||||
var exception = Record.Exception(() => GatewayOptionsValidator.Validate(options));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RegexWithInvalidCaptureGroupRef_Throws()
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = @"^/api/v1/([^/]+)",
|
||||
IsRegex = true,
|
||||
TranslatesTo = "http://$1.stella-ops.local/$2"
|
||||
});
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayOptionsValidator.Validate(options));
|
||||
|
||||
Assert.Contains("$2", exception.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("capture group", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RegexWithNoTranslatesTo_DoesNotThrow()
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = @"^/api/v1/([^/]+)(.*)",
|
||||
IsRegex = true
|
||||
});
|
||||
|
||||
var exception = Record.Exception(() => GatewayOptionsValidator.Validate(options));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
|
||||
@@ -4,17 +4,33 @@ namespace StellaOps.Gateway.WebService.Tests.Configuration;
|
||||
|
||||
public sealed class GatewayRouteSearchMappingsTests
|
||||
{
|
||||
private static readonly (string Path, string Target, string RouteType)[] RequiredMappings =
|
||||
private static readonly (string Path, string Target, string RouteType, bool IsRegex)[] RequiredMappings =
|
||||
[
|
||||
("/api/v1/search", "http://advisoryai.stella-ops.local/v1/search", "ReverseProxy"),
|
||||
("/api/v1/advisory-ai", "http://advisoryai.stella-ops.local/v1/advisory-ai", "ReverseProxy")
|
||||
("^/api/v1/search(.*)", "http://advisoryai.stella-ops.local/v1/search$1", "Microservice", true),
|
||||
("^/api/v1/advisory-ai(.*)", "http://advisoryai.stella-ops.local/v1/advisory-ai$1", "Microservice", true),
|
||||
("^/api/v1/watchlist(.*)", "http://attestor.stella-ops.local/api/v1/watchlist$1", "Microservice", true),
|
||||
("^/api/v1/audit(.*)", "http://timeline.stella-ops.local/api/v1/audit$1", "Microservice", true),
|
||||
("^/api/v1/advisory-sources(.*)", "http://concelier.stella-ops.local/api/v1/advisory-sources$1", "Microservice", true),
|
||||
("^/api/v1/notifier/delivery(.*)", "http://notifier.stella-ops.local/api/v2/notify/deliveries$1", "Microservice", true),
|
||||
("^/api/v2/context(.*)", "http://platform.stella-ops.local/api/v2/context$1", "Microservice", true),
|
||||
("^/api/v2/releases(.*)", "http://platform.stella-ops.local/api/v2/releases$1", "Microservice", true),
|
||||
("^/api/v2/security(.*)", "http://platform.stella-ops.local/api/v2/security$1", "Microservice", true),
|
||||
("^/api/v2/topology(.*)", "http://platform.stella-ops.local/api/v2/topology$1", "Microservice", true),
|
||||
("^/api/v2/integrations(.*)", "http://platform.stella-ops.local/api/v2/integrations$1", "Microservice", true),
|
||||
("^/api/jobengine(.*)", "http://orchestrator.stella-ops.local/api/jobengine$1", "Microservice", true),
|
||||
("^/api/scheduler(.*)", "http://scheduler.stella-ops.local/api/scheduler$1", "Microservice", true)
|
||||
];
|
||||
|
||||
private static readonly (string Path, string AppSettingsTarget, string LocalTarget)[] RequiredReverseProxyMappings =
|
||||
[
|
||||
("/connect", "http://authority.stella-ops.local/connect", "http://authority.stella-ops.local/connect"),
|
||||
("/authority/console", "http://authority.stella-ops.local/console", "https://authority.stella-ops.local/console")
|
||||
];
|
||||
|
||||
public static TheoryData<string> RouteConfigPaths => new()
|
||||
{
|
||||
"src/Router/StellaOps.Gateway.WebService/appsettings.json",
|
||||
"devops/compose/router-gateway-local.json",
|
||||
"devops/compose/router-gateway-local.reverseproxy.json"
|
||||
"devops/compose/router-gateway-local.json"
|
||||
};
|
||||
|
||||
[Theory]
|
||||
@@ -38,17 +54,19 @@ public sealed class GatewayRouteSearchMappingsTests
|
||||
route.GetProperty("Path").GetString() ?? string.Empty,
|
||||
route.TryGetProperty("TranslatesTo", out var translatesTo)
|
||||
? translatesTo.GetString() ?? string.Empty
|
||||
: string.Empty))
|
||||
: string.Empty,
|
||||
route.TryGetProperty("IsRegex", out var isRegex) && isRegex.GetBoolean()))
|
||||
.ToList();
|
||||
|
||||
var catchAllIndex = routes.FindIndex(route => string.Equals(route.Path, "/api", StringComparison.Ordinal));
|
||||
|
||||
foreach (var (requiredPath, requiredTarget, requiredType) in RequiredMappings)
|
||||
foreach (var (requiredPath, requiredTarget, requiredType, requiredIsRegex) in RequiredMappings)
|
||||
{
|
||||
var route = routes.FirstOrDefault(candidate => string.Equals(candidate.Path, requiredPath, StringComparison.Ordinal));
|
||||
Assert.True(route is not null, $"Missing route '{requiredPath}' in {configRelativePath}.");
|
||||
Assert.Equal(requiredType, route!.Type);
|
||||
Assert.Equal(requiredTarget, route!.TranslatesTo);
|
||||
Assert.Equal(requiredIsRegex, route!.IsRegex);
|
||||
|
||||
if (catchAllIndex >= 0)
|
||||
{
|
||||
@@ -57,6 +75,46 @@ public sealed class GatewayRouteSearchMappingsTests
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RouteConfigPaths))]
|
||||
public void RouteTable_ContainsRequiredReverseProxyMappings(string configRelativePath)
|
||||
{
|
||||
var repoRoot = FindRepositoryRoot();
|
||||
var configPath = Path.Combine(repoRoot, configRelativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
Assert.True(File.Exists(configPath), $"Config file not found: {configPath}");
|
||||
|
||||
using var stream = File.OpenRead(configPath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
|
||||
var routes = document.RootElement
|
||||
.GetProperty("Gateway")
|
||||
.GetProperty("Routes")
|
||||
.EnumerateArray()
|
||||
.Select(route => new RouteEntry(
|
||||
Index: -1,
|
||||
route.GetProperty("Type").GetString() ?? string.Empty,
|
||||
route.GetProperty("Path").GetString() ?? string.Empty,
|
||||
route.TryGetProperty("TranslatesTo", out var translatesTo)
|
||||
? translatesTo.GetString() ?? string.Empty
|
||||
: string.Empty,
|
||||
route.TryGetProperty("IsRegex", out var isRegex) && isRegex.GetBoolean()))
|
||||
.ToList();
|
||||
|
||||
var isLocalComposeConfig = string.Equals(
|
||||
configRelativePath,
|
||||
"devops/compose/router-gateway-local.json",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
foreach (var (requiredPath, appSettingsTarget, localTarget) in RequiredReverseProxyMappings)
|
||||
{
|
||||
var route = routes.FirstOrDefault(candidate => string.Equals(candidate.Path, requiredPath, StringComparison.Ordinal));
|
||||
Assert.True(route is not null, $"Missing route '{requiredPath}' in {configRelativePath}.");
|
||||
Assert.Equal("ReverseProxy", route!.Type);
|
||||
Assert.Equal(isLocalComposeConfig ? localTarget : appSettingsTarget, route.TranslatesTo);
|
||||
Assert.False(route.IsRegex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent)
|
||||
@@ -70,5 +128,5 @@ public sealed class GatewayRouteSearchMappingsTests
|
||||
throw new InvalidOperationException($"Unable to locate repository root from {AppContext.BaseDirectory}.");
|
||||
}
|
||||
|
||||
private sealed record RouteEntry(int Index, string Type, string Path, string TranslatesTo);
|
||||
private sealed record RouteEntry(int Index, string Type, string Path, string TranslatesTo, bool IsRegex);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Gateway.WebService.Configuration;
|
||||
using StellaOps.Gateway.WebService.Routing;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
@@ -51,6 +52,93 @@ public sealed class GatewayIntegrationTests : IClassFixture<GatewayWebApplicatio
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthReady_ReturnsServiceUnavailable_WhenRequiredMicroserviceIsMissing()
|
||||
{
|
||||
using var factory = CreateFactoryWithRequiredServices("policy");
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/health/ready");
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
|
||||
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
Assert.Equal("policy", payload.RootElement.GetProperty("missingMicroservices")[0].GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthReady_ReturnsOk_WhenRequiredMicroserviceIsRegistered()
|
||||
{
|
||||
using var factory = CreateFactoryWithRequiredServices("policy");
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var routingState = scope.ServiceProvider.GetRequiredService<IGlobalRoutingState>();
|
||||
routingState.AddConnection(new ConnectionState
|
||||
{
|
||||
ConnectionId = "conn-policy",
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "policy-01",
|
||||
ServiceName = "policy-gateway",
|
||||
Version = "1.0.0",
|
||||
Region = "test"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
TransportType = TransportType.Messaging
|
||||
});
|
||||
}
|
||||
|
||||
var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/health/ready");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthReady_ReturnsServiceUnavailable_WhenOnlySiblingServiceIsRegistered()
|
||||
{
|
||||
using var factory = CreateFactoryWithRequiredServices("policy");
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var routingState = scope.ServiceProvider.GetRequiredService<IGlobalRoutingState>();
|
||||
routingState.AddConnection(new ConnectionState
|
||||
{
|
||||
ConnectionId = "conn-policy-engine",
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "policy-engine-01",
|
||||
ServiceName = "policy-engine",
|
||||
Version = "1.0.0",
|
||||
Region = "test"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
TransportType = TransportType.Messaging
|
||||
});
|
||||
}
|
||||
|
||||
var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/health/ready");
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
|
||||
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
Assert.Equal("policy", payload.RootElement.GetProperty("missingMicroservices")[0].GetString());
|
||||
}
|
||||
|
||||
private WebApplicationFactory<Program> CreateFactoryWithRequiredServices(params string[] requiredServices)
|
||||
{
|
||||
return new GatewayWebApplicationFactory().WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.PostConfigure<GatewayOptions>(options =>
|
||||
{
|
||||
options.Health.RequiredMicroservices = requiredServices.ToList();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenApiJson_ReturnsValidOpenApiDocument()
|
||||
{
|
||||
|
||||
@@ -259,6 +259,137 @@ public sealed class RouteDispatchMiddlewareMicroserviceTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_RegexCatchAll_CaptureGroupSubstitution_ResolvesServiceAndPath()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = new StellaOpsRouteResolver(
|
||||
[
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = @"^/api/v1/([^/]+)(.*)",
|
||||
IsRegex = true,
|
||||
TranslatesTo = "http://$1.stella-ops.local/api/v1/$1$2"
|
||||
}
|
||||
]);
|
||||
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
|
||||
|
||||
var nextCalled = false;
|
||||
var middleware = new RouteDispatchMiddleware(
|
||||
_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
resolver,
|
||||
httpClientFactory.Object,
|
||||
NullLogger<RouteDispatchMiddleware>.Instance);
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = "/api/v1/scanner/health";
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(nextCalled);
|
||||
Assert.Equal(
|
||||
"scanner",
|
||||
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
|
||||
// Path matches request: no translation needed
|
||||
Assert.False(context.Items.ContainsKey(RouterHttpContextKeys.TranslatedRequestPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_RegexAlias_CaptureGroupSubstitution_ResolvesCorrectService()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = new StellaOpsRouteResolver(
|
||||
[
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = @"^/api/v1/vulnerabilities(.*)",
|
||||
IsRegex = true,
|
||||
TranslatesTo = "http://scanner.stella-ops.local/api/v1/vulnerabilities$1"
|
||||
}
|
||||
]);
|
||||
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
|
||||
|
||||
var nextCalled = false;
|
||||
var middleware = new RouteDispatchMiddleware(
|
||||
_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
resolver,
|
||||
httpClientFactory.Object,
|
||||
NullLogger<RouteDispatchMiddleware>.Instance);
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = "/api/v1/vulnerabilities/cve-2024-1234";
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(nextCalled);
|
||||
Assert.Equal(
|
||||
"scanner",
|
||||
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_RegexCatchAll_WithPathRewrite_SetsTranslatedPath()
|
||||
{
|
||||
// Arrange: advisoryai /api/v1/search -> /v1/search (path rewrite via capture group)
|
||||
var resolver = new StellaOpsRouteResolver(
|
||||
[
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = @"^/api/v1/search(.*)",
|
||||
IsRegex = true,
|
||||
TranslatesTo = "http://advisoryai.stella-ops.local/v1/search$1"
|
||||
}
|
||||
]);
|
||||
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
|
||||
|
||||
var nextCalled = false;
|
||||
var middleware = new RouteDispatchMiddleware(
|
||||
_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
resolver,
|
||||
httpClientFactory.Object,
|
||||
NullLogger<RouteDispatchMiddleware>.Instance);
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = "/api/v1/search/entities";
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(nextCalled);
|
||||
Assert.Equal(
|
||||
"advisoryai",
|
||||
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
|
||||
Assert.Equal(
|
||||
"/v1/search/entities",
|
||||
context.Items[RouterHttpContextKeys.TranslatedRequestPath] as string);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_MicroserviceApiPath_DoesNotUseSpaFallback()
|
||||
{
|
||||
|
||||
@@ -29,8 +29,9 @@ public sealed class StellaOpsRouteResolverTests
|
||||
|
||||
var result = resolver.Resolve(new PathString("/dashboard"));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("/dashboard", result.Path);
|
||||
Assert.NotNull(result.Route);
|
||||
Assert.Equal("/dashboard", result.Route.Path);
|
||||
Assert.Null(result.RegexMatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -41,8 +42,9 @@ public sealed class StellaOpsRouteResolverTests
|
||||
|
||||
var result = resolver.Resolve(new PathString("/app/index.html"));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("/app", result.Path);
|
||||
Assert.NotNull(result.Route);
|
||||
Assert.Equal("/app", result.Route.Path);
|
||||
Assert.Null(result.RegexMatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -53,9 +55,28 @@ public sealed class StellaOpsRouteResolverTests
|
||||
|
||||
var result = resolver.Resolve(new PathString("/api/v2/data"));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsRegex);
|
||||
Assert.Equal(@"^/api/v[0-9]+/.*", result.Path);
|
||||
Assert.NotNull(result.Route);
|
||||
Assert.True(result.Route.IsRegex);
|
||||
Assert.Equal(@"^/api/v[0-9]+/.*", result.Route.Path);
|
||||
Assert.NotNull(result.RegexMatch);
|
||||
Assert.True(result.RegexMatch.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_RegexRoute_ReturnsCaptureGroups()
|
||||
{
|
||||
var route = MakeRoute(
|
||||
@"^/api/v1/([^/]+)(.*)",
|
||||
isRegex: true,
|
||||
translatesTo: "http://$1.stella-ops.local/api/v1/$1$2");
|
||||
var resolver = new StellaOpsRouteResolver(new[] { route });
|
||||
|
||||
var result = resolver.Resolve(new PathString("/api/v1/scanner/health"));
|
||||
|
||||
Assert.NotNull(result.Route);
|
||||
Assert.NotNull(result.RegexMatch);
|
||||
Assert.Equal("scanner", result.RegexMatch.Groups[1].Value);
|
||||
Assert.Equal("/health", result.RegexMatch.Groups[2].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -66,7 +87,8 @@ public sealed class StellaOpsRouteResolverTests
|
||||
|
||||
var result = resolver.Resolve(new PathString("/unknown"));
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.Null(result.Route);
|
||||
Assert.Null(result.RegexMatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -78,8 +100,8 @@ public sealed class StellaOpsRouteResolverTests
|
||||
|
||||
var result = resolver.Resolve(new PathString("/api/resource"));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("http://first:5000", result.TranslatesTo);
|
||||
Assert.NotNull(result.Route);
|
||||
Assert.Equal("http://first:5000", result.Route.TranslatesTo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -90,7 +112,7 @@ public sealed class StellaOpsRouteResolverTests
|
||||
|
||||
var result = resolver.Resolve(new PathString("/not-found"));
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.Null(result.Route);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -101,7 +123,7 @@ public sealed class StellaOpsRouteResolverTests
|
||||
|
||||
var result = resolver.Resolve(new PathString("/error"));
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.Null(result.Route);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -112,8 +134,8 @@ public sealed class StellaOpsRouteResolverTests
|
||||
|
||||
var result = resolver.Resolve(new PathString("/APP"));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("/app", result.Path);
|
||||
Assert.NotNull(result.Route);
|
||||
Assert.Equal("/app", result.Route.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -123,6 +145,6 @@ public sealed class StellaOpsRouteResolverTests
|
||||
|
||||
var result = resolver.Resolve(new PathString("/anything"));
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.Null(result.Route);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,3 +12,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| RGH-01-T | DONE | 2026-02-22: Added route-dispatch unit tests for microservice SPA fallback and API-prefix bypass behavior. |
|
||||
| RGH-03-T | DONE | 2026-03-05: Added deterministic route-table parity tests for unified search mappings across gateway runtime and compose configs; verified in gateway test run. |
|
||||
| LIVE-ROUTER-012-T1 | DONE | 2026-03-09: Added quota compatibility regressions for coarse-scope expansion and authorization against resolved gateway scopes. |
|
||||
| ROUTER-READY-001-T | DONE | 2026-03-10: Added required-service readiness coverage and truthful warm-up status contracts for router frontdoor convergence. |
|
||||
|
||||
@@ -31,6 +31,7 @@ public sealed class RouterConnectionManagerTests : IDisposable
|
||||
Region = "test",
|
||||
InstanceId = "test-instance-1",
|
||||
HeartbeatInterval = TimeSpan.FromMilliseconds(50),
|
||||
RegistrationRefreshInterval = TimeSpan.FromMilliseconds(20),
|
||||
ReconnectBackoffInitial = TimeSpan.FromMilliseconds(10),
|
||||
ReconnectBackoffMax = TimeSpan.FromMilliseconds(100)
|
||||
};
|
||||
@@ -385,6 +386,47 @@ public sealed class RouterConnectionManagerTests : IDisposable
|
||||
capturedHeartbeat.ErrorRate.Should().Be(0.05);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StartAsync_ReplaysHelloWithinRegistrationRefreshInterval()
|
||||
{
|
||||
// Arrange
|
||||
_options.Routers.Add(new RouterEndpointConfig
|
||||
{
|
||||
Host = "localhost",
|
||||
Port = 5000,
|
||||
TransportType = TransportType.InMemory
|
||||
});
|
||||
|
||||
var registrationReplayObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var connectCount = 0;
|
||||
_transportMock
|
||||
.Setup(t => t.ConnectAsync(
|
||||
It.IsAny<InstanceDescriptor>(),
|
||||
It.IsAny<IReadOnlyList<EndpointDescriptor>>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, SchemaDefinition>?>(),
|
||||
It.IsAny<ServiceOpenApiInfo?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback(() =>
|
||||
{
|
||||
if (Interlocked.Increment(ref connectCount) >= 2)
|
||||
{
|
||||
registrationReplayObserved.TrySetResult();
|
||||
}
|
||||
})
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
using var manager = CreateManager();
|
||||
|
||||
// Act
|
||||
await manager.StartAsync(CancellationToken.None);
|
||||
await registrationReplayObserved.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
await manager.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
connectCount.Should().BeGreaterThanOrEqualTo(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dispose Tests
|
||||
|
||||
@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0393-T | DONE | Revalidated 2026-01-07; test coverage audit for Router StellaOps.Microservice.Tests. |
|
||||
| AUDIT-0393-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| ROUTER-READY-004-T | DONE | 2026-03-10: Added deterministic coverage for bounded HELLO replay cadence after gateway restarts. |
|
||||
|
||||
@@ -205,6 +205,7 @@ public sealed class StellaRouterIntegrationHelperTests
|
||||
["TimelineIndexer:Router:Messaging:RequestTimeout"] = "45s",
|
||||
["TimelineIndexer:Router:Messaging:LeaseDuration"] = "4m",
|
||||
["TimelineIndexer:Router:Messaging:HeartbeatInterval"] = "12s",
|
||||
["TimelineIndexer:Router:RegistrationRefreshIntervalSeconds"] = "7",
|
||||
["TimelineIndexer:Router:Messaging:valkey:ConnectionString"] = "cache.stella-ops.local:6379",
|
||||
["TimelineIndexer:Router:Messaging:valkey:Database"] = "2"
|
||||
});
|
||||
@@ -223,6 +224,7 @@ public sealed class StellaRouterIntegrationHelperTests
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(TimeSpan.FromSeconds(12), options.HeartbeatInterval);
|
||||
Assert.Equal(TimeSpan.FromSeconds(7), options.RegistrationRefreshInterval);
|
||||
Assert.Equal("router:requests:{service}", messaging.RequestQueueTemplate);
|
||||
Assert.Equal("router:responses", messaging.ResponseQueueName);
|
||||
Assert.Equal("timelineindexer", messaging.ConsumerGroup);
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Middleware;
|
||||
|
||||
@@ -103,7 +104,7 @@ public sealed class EndpointResolutionMiddlewareTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_DoesNotFallbackToDifferentService_WhenTargetHintHasNoMatch()
|
||||
public async Task Invoke_Returns503_WhenTargetServiceIsNotRegistered()
|
||||
{
|
||||
// Arrange
|
||||
var context = new DefaultHttpContext();
|
||||
@@ -144,12 +145,91 @@ public sealed class EndpointResolutionMiddlewareTests
|
||||
|
||||
// Assert
|
||||
nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
|
||||
routingState.Verify(
|
||||
state => state.ResolveEndpoint(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_Returns503_WhenSpecificGatewayHintWouldOtherwiseMatchSiblingService()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = HttpMethods.Get;
|
||||
context.Request.Path = "/api/v1/policy/__router_smoke__";
|
||||
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/api/v1/policy/__router_smoke__";
|
||||
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "policy-gateway";
|
||||
|
||||
var siblingEndpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "policy-engine",
|
||||
Version = "1.0.0",
|
||||
Method = HttpMethods.Get,
|
||||
Path = "/api/v1/policy/__router_smoke__"
|
||||
};
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState
|
||||
.Setup(state => state.GetAllConnections())
|
||||
.Returns(
|
||||
[
|
||||
CreateConnection("conn-policy-engine", "policy-engine", siblingEndpoint)
|
||||
]);
|
||||
|
||||
var nextCalled = false;
|
||||
var middleware = new EndpointResolutionMiddleware(_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
await middleware.Invoke(context, routingState.Object);
|
||||
|
||||
nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
|
||||
routingState.Verify(
|
||||
state => state.ResolveEndpoint(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_Returns404_WhenTargetServiceIsRegisteredButEndpointDoesNotExist()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = HttpMethods.Get;
|
||||
context.Request.Path = "/api/v1/governance/not-real";
|
||||
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/api/v1/governance/not-real";
|
||||
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "policy";
|
||||
|
||||
var otherEndpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "policy-gateway",
|
||||
Version = "1.0.0",
|
||||
Method = HttpMethods.Get,
|
||||
Path = "/api/v1/governance/staleness/config"
|
||||
};
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState
|
||||
.Setup(state => state.GetAllConnections())
|
||||
.Returns(
|
||||
[
|
||||
CreateConnection("conn-policy", "policy-gateway", otherEndpoint)
|
||||
]);
|
||||
|
||||
var nextCalled = false;
|
||||
var middleware = new EndpointResolutionMiddleware(_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
await middleware.Invoke(context, routingState.Object);
|
||||
|
||||
nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_MatchesRouteHintWithServicePrefixAlias()
|
||||
{
|
||||
@@ -186,10 +266,55 @@ public sealed class EndpointResolutionMiddlewareTests
|
||||
context.Items[RouterHttpContextKeys.TargetMicroservice].Should().Be("findings-ledger");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WhenMultipleMatchingConnectionsExist_PrefersHealthyMostRecentEndpoint()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = HttpMethods.Get;
|
||||
context.Request.Path = "/api/v1/governance/staleness/config";
|
||||
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/api/v1/governance/staleness/config";
|
||||
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "policy";
|
||||
|
||||
var staleEndpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "policy-gateway",
|
||||
Version = "1.0.0",
|
||||
Method = HttpMethods.Get,
|
||||
Path = "/api/v1/governance/staleness/config"
|
||||
};
|
||||
|
||||
var healthyEndpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "policy-gateway",
|
||||
Version = "1.0.1",
|
||||
Method = HttpMethods.Get,
|
||||
Path = "/api/v1/governance/staleness/config"
|
||||
};
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState
|
||||
.Setup(state => state.GetAllConnections())
|
||||
.Returns(
|
||||
[
|
||||
CreateConnection("conn-stale", "policy-gateway", staleEndpoint, InstanceHealthStatus.Unhealthy, new DateTime(2026, 3, 10, 1, 10, 0, DateTimeKind.Utc)),
|
||||
CreateConnection("conn-healthy", "policy-gateway", healthyEndpoint, InstanceHealthStatus.Healthy, new DateTime(2026, 3, 10, 1, 11, 0, DateTimeKind.Utc))
|
||||
]);
|
||||
|
||||
var middleware = new EndpointResolutionMiddleware(_ => Task.CompletedTask);
|
||||
|
||||
await middleware.Invoke(context, routingState.Object);
|
||||
|
||||
context.Response.StatusCode.Should().NotBe(StatusCodes.Status404NotFound);
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().BeSameAs(healthyEndpoint);
|
||||
context.Items[RouterHttpContextKeys.TargetMicroservice].Should().Be("policy-gateway");
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId,
|
||||
string serviceName,
|
||||
EndpointDescriptor endpoint)
|
||||
EndpointDescriptor endpoint,
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
|
||||
DateTime? lastHeartbeatUtc = null)
|
||||
{
|
||||
var instance = new InstanceDescriptor
|
||||
{
|
||||
@@ -203,6 +328,8 @@ public sealed class EndpointResolutionMiddlewareTests
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = instance,
|
||||
Status = status,
|
||||
LastHeartbeatUtc = lastHeartbeatUtc ?? new DateTime(2026, 3, 10, 1, 0, 0, DateTimeKind.Utc),
|
||||
TransportType = StellaOps.Router.Common.Enums.TransportType.Messaging
|
||||
};
|
||||
|
||||
|
||||
@@ -54,15 +54,46 @@ public sealed class InMemoryRoutingStateTests
|
||||
.Which.ConnectionId.Should().Be("conn-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpoint_WhenSamePathExistsAcrossVersions_PrefersHealthyMostRecentRegistration()
|
||||
{
|
||||
var state = new InMemoryRoutingState();
|
||||
var staleConnection = CreateConnection(
|
||||
connectionId: "conn-stale",
|
||||
instanceId: "policy-gateway-old",
|
||||
endpointPath: "/api/v1/governance/staleness/config",
|
||||
version: "1.0.0",
|
||||
status: InstanceHealthStatus.Unhealthy,
|
||||
lastHeartbeatUtc: new DateTime(2026, 3, 10, 1, 10, 0, DateTimeKind.Utc));
|
||||
var healthyConnection = CreateConnection(
|
||||
connectionId: "conn-healthy",
|
||||
instanceId: "policy-gateway-new",
|
||||
endpointPath: "/api/v1/governance/staleness/config",
|
||||
version: "1.0.1",
|
||||
status: InstanceHealthStatus.Healthy,
|
||||
lastHeartbeatUtc: new DateTime(2026, 3, 10, 1, 11, 0, DateTimeKind.Utc));
|
||||
|
||||
state.AddConnection(staleConnection);
|
||||
state.AddConnection(healthyConnection);
|
||||
|
||||
var resolved = state.ResolveEndpoint("GET", "/api/v1/governance/staleness/config");
|
||||
|
||||
resolved.Should().NotBeNull();
|
||||
resolved!.Version.Should().Be("1.0.1");
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId,
|
||||
string instanceId,
|
||||
string endpointPath)
|
||||
string endpointPath,
|
||||
string version = "1.0.0",
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
|
||||
DateTime? lastHeartbeatUtc = null)
|
||||
{
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "integrations",
|
||||
Version = "1.0.0",
|
||||
Version = version,
|
||||
Method = "GET",
|
||||
Path = endpointPath
|
||||
};
|
||||
@@ -73,10 +104,12 @@ public sealed class InMemoryRoutingStateTests
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
ServiceName = "integrations",
|
||||
Version = "1.0.0",
|
||||
ServiceName = endpointPath.Contains("/governance/", StringComparison.Ordinal) ? "policy-gateway" : "integrations",
|
||||
Version = version,
|
||||
Region = "local"
|
||||
},
|
||||
Status = status,
|
||||
LastHeartbeatUtc = lastHeartbeatUtc ?? new DateTime(2026, 3, 10, 1, 0, 0, DateTimeKind.Utc),
|
||||
TransportType = TransportType.Messaging
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user