Repair router frontdoor route boundaries and service prefixes
This commit is contained in:
@@ -12,3 +12,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| RGH-01 | DONE | 2026-02-22: Added SPA fallback handling for browser deep links on microservice route matches; API prefixes remain backend-dispatched. |
|
||||
| RGH-02 | DONE | 2026-03-04: Expanded approved auth passthrough prefixes (`/authority`, `/doctor`, `/api`) to unblock authenticated gateway routes used by Audit Log UI E2E. |
|
||||
| RGH-03 | DONE | 2026-03-05: Aligned `/api/v1/search*` and `/api/v1/advisory-ai*` route translations to AdvisoryAI `/v1/*`, added compose/runtime parity safeguards, and verified setup smoke coverage. |
|
||||
| RGH-04 | DONE | 2026-03-10: Tightened frontdoor route boundaries for `/policy`, restored explicit platform ownership for `/api/v1/aoc*` and `/api/v1/administration*`, and stripped `/doctor`/`/scheduler` shell prefixes before microservice dispatch. |
|
||||
|
||||
@@ -96,6 +96,8 @@
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/determinization(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/workflows$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/aoc(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/aoc$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/administration(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/administration$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/authority/quotas(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/release-control(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/release-control$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/gateway/rate-limits(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/gateway/rate-limits$1" },
|
||||
@@ -131,12 +133,14 @@
|
||||
{ "Type": "Microservice", "Path": "^/api/admin/plans(.*)", "IsRegex": true, "TranslatesTo": "http://registry-token.stella-ops.local/api/admin/plans$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/admin(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/admin$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/analytics(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/analytics$1" },
|
||||
{ "Type": "Microservice", "Path": "^/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local$1" },
|
||||
{ "Type": "Microservice", "Path": "^/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/orchestrator$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/jobengine(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/jobengine$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local/api/scheduler$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local/api/doctor$1" },
|
||||
|
||||
{ "Type": "Microservice", "Path": "^/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy$1" },
|
||||
{ "Type": "Microservice", "Path": "^/policy(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy$1" },
|
||||
|
||||
{ "Type": "Microservice", "Path": "^/v1/evidence-packs(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs$1" },
|
||||
{ "Type": "Microservice", "Path": "^/v1/runs(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/v1/runs$1" },
|
||||
|
||||
@@ -12,11 +12,15 @@ public sealed class GatewayRouteSearchMappingsTests
|
||||
("^/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/v1/aoc(.*)", "http://platform.stella-ops.local/api/v1/aoc$1", "Microservice", true),
|
||||
("^/api/v1/administration(.*)", "http://platform.stella-ops.local/api/v1/administration$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),
|
||||
("^/scheduler(.*)", "http://scheduler.stella-ops.local$1", "Microservice", true),
|
||||
("^/doctor(.*)", "http://doctor.stella-ops.local$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)
|
||||
];
|
||||
@@ -58,7 +62,8 @@ public sealed class GatewayRouteSearchMappingsTests
|
||||
route.TryGetProperty("IsRegex", out var isRegex) && isRegex.GetBoolean()))
|
||||
.ToList();
|
||||
|
||||
var catchAllIndex = routes.FindIndex(route => string.Equals(route.Path, "/api", StringComparison.Ordinal));
|
||||
var legacyApiCatchAllIndex = routes.FindIndex(route => string.Equals(route.Path, "/api", StringComparison.Ordinal));
|
||||
var genericV1CatchAllIndex = routes.FindIndex(route => string.Equals(route.Path, "^/api/v1/([^/]+)(.*)", StringComparison.Ordinal));
|
||||
|
||||
foreach (var (requiredPath, requiredTarget, requiredType, requiredIsRegex) in RequiredMappings)
|
||||
{
|
||||
@@ -68,13 +73,51 @@ public sealed class GatewayRouteSearchMappingsTests
|
||||
Assert.Equal(requiredTarget, route!.TranslatesTo);
|
||||
Assert.Equal(requiredIsRegex, route!.IsRegex);
|
||||
|
||||
if (catchAllIndex >= 0)
|
||||
if (legacyApiCatchAllIndex >= 0)
|
||||
{
|
||||
Assert.True(route.Index < catchAllIndex, $"{requiredPath} must appear before /api catch-all in {configRelativePath}.");
|
||||
Assert.True(route.Index < legacyApiCatchAllIndex, $"{requiredPath} must appear before /api catch-all in {configRelativePath}.");
|
||||
}
|
||||
|
||||
if (genericV1CatchAllIndex >= 0 &&
|
||||
requiredPath.StartsWith("^/api/v1/", StringComparison.Ordinal))
|
||||
{
|
||||
Assert.True(
|
||||
route.Index < genericV1CatchAllIndex,
|
||||
$"{requiredPath} must appear before the generic /api/v1/{{service}} catch-all in {configRelativePath}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RouteConfigPaths))]
|
||||
public void RouteTable_UsesSegmentBoundaryForPolicyRootRoute(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 route = routes.SingleOrDefault(candidate => string.Equals(candidate.TranslatesTo, "http://policy-gateway.stella-ops.local/policy$1", StringComparison.Ordinal));
|
||||
Assert.True(route is not null, $"Missing policy root route in {configRelativePath}.");
|
||||
Assert.Equal("^/policy(?=/|$)(.*)", route!.Path);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RouteConfigPaths))]
|
||||
public void RouteTable_ContainsRequiredReverseProxyMappings(string configRelativePath)
|
||||
|
||||
@@ -390,6 +390,51 @@ public sealed class RouteDispatchMiddlewareMicroserviceTests
|
||||
context.Items[RouterHttpContextKeys.TranslatedRequestPath] as string);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/doctor/api/v1/doctor/checks", @"^/doctor(.*)", "http://doctor.stella-ops.local$1", "doctor", "/api/v1/doctor/checks")]
|
||||
[InlineData("/scheduler/api/v1/scheduler/runs", @"^/scheduler(.*)", "http://scheduler.stella-ops.local$1", "scheduler", "/api/v1/scheduler/runs")]
|
||||
public async Task InvokeAsync_ServiceRootPrefixRoute_StripsFrontdoorPrefixBeforeDispatch(
|
||||
string requestPath,
|
||||
string routePath,
|
||||
string translatesTo,
|
||||
string expectedService,
|
||||
string expectedTranslatedPath)
|
||||
{
|
||||
var resolver = new StellaOpsRouteResolver(
|
||||
[
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = routePath,
|
||||
IsRegex = true,
|
||||
TranslatesTo = translatesTo
|
||||
}
|
||||
]);
|
||||
|
||||
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 = requestPath;
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(nextCalled);
|
||||
Assert.Equal(expectedService, context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
|
||||
Assert.Equal(expectedTranslatedPath, context.Items[RouterHttpContextKeys.TranslatedRequestPath] as string);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_MicroserviceApiPath_DoesNotUseSpaFallback()
|
||||
{
|
||||
|
||||
@@ -79,6 +79,40 @@ public sealed class StellaOpsRouteResolverTests
|
||||
Assert.Equal("/health", result.RegexMatch.Groups[2].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_SegmentBoundPolicyRoot_DoesNotCaptureStaticChunkPaths()
|
||||
{
|
||||
var resolver = new StellaOpsRouteResolver(
|
||||
[
|
||||
MakeRoute(
|
||||
@"^/policy(?=/|$)(.*)",
|
||||
isRegex: true,
|
||||
translatesTo: "http://policy-gateway.stella-ops.local/policy$1"),
|
||||
MakeRoute(
|
||||
"/",
|
||||
type: StellaOpsRouteType.StaticFiles,
|
||||
translatesTo: "/app/wwwroot")
|
||||
]);
|
||||
|
||||
var chunkResult = resolver.Resolve(new PathString("/policy-decisioning.routes-ABCDE.js"));
|
||||
var policyRootResult = resolver.Resolve(new PathString("/policy"));
|
||||
var policyChildResult = resolver.Resolve(new PathString("/policy/overview"));
|
||||
|
||||
Assert.NotNull(chunkResult.Route);
|
||||
Assert.Equal(StellaOpsRouteType.StaticFiles, chunkResult.Route!.Type);
|
||||
Assert.Null(chunkResult.RegexMatch);
|
||||
|
||||
Assert.NotNull(policyRootResult.Route);
|
||||
Assert.Equal(@"^/policy(?=/|$)(.*)", policyRootResult.Route!.Path);
|
||||
Assert.NotNull(policyRootResult.RegexMatch);
|
||||
Assert.Equal(string.Empty, policyRootResult.RegexMatch!.Groups[1].Value);
|
||||
|
||||
Assert.NotNull(policyChildResult.Route);
|
||||
Assert.Equal(@"^/policy(?=/|$)(.*)", policyChildResult.Route!.Path);
|
||||
Assert.NotNull(policyChildResult.RegexMatch);
|
||||
Assert.Equal("/overview", policyChildResult.RegexMatch!.Groups[1].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NoMatch_ReturnsNull()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user