Fix router frontdoor readiness and route contracts

This commit is contained in:
master
2026-03-10 10:19:49 +02:00
parent eae2dfc9d4
commit 7acf0ae8f2
37 changed files with 1408 additions and 1914 deletions

View File

@@ -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("")]

View File

@@ -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);
}

View File

@@ -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()
{

View File

@@ -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()
{

View File

@@ -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);
}
}

View File

@@ -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. |