diff --git a/devops/compose/README.md b/devops/compose/README.md index 188a62619..e6718553b 100644 --- a/devops/compose/README.md +++ b/devops/compose/README.md @@ -133,6 +133,12 @@ First-party Stella APIs are expected to flow through router transport; reverse p external/bootstrap surfaces that cannot participate in router registration yet (for example OIDC browser flows, Rekor, and static/platform bootstrap assets). +The local route table also carries a small set of explicit precedence rules that must stay ahead of the +generic `^/api/v1/{service}` matcher. Platform-owned surfaces such as `/api/v1/aoc/*` and +`/api/v1/administration/*` resolve directly to `platform`, and browser compatibility prefixes such as +`/doctor/*` and `/scheduler/*` strip the frontdoor prefix before dispatch so the target services still +receive their canonical `/api/v1/doctor/*` and `/api/v1/scheduler/*` paths. + ```bash # Default frontdoor route table ROUTER_GATEWAY_CONFIG=./router-gateway-local.json \ diff --git a/devops/compose/router-gateway-local.json b/devops/compose/router-gateway-local.json index 8b5234c41..773443a0e 100644 --- a/devops/compose/router-gateway-local.json +++ b/devops/compose/router-gateway-local.json @@ -68,6 +68,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" }, @@ -103,12 +105,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" }, diff --git a/docs/implplan/SPRINT_20260310_020_Router_frontdoor_route_boundary_and_service_prefix_repair.md b/docs/implplan/SPRINT_20260310_020_Router_frontdoor_route_boundary_and_service_prefix_repair.md new file mode 100644 index 000000000..c93b26f5d --- /dev/null +++ b/docs/implplan/SPRINT_20260310_020_Router_frontdoor_route_boundary_and_service_prefix_repair.md @@ -0,0 +1,77 @@ +# Sprint 20260310-020 - Router Frontdoor Route Boundary And Service Prefix Repair + +## Topic & Scope +- Repair frontdoor route precedence defects that only appear on the real scratch stack after router-gateway reloads and rebuilt web assets. +- Keep first-party Stella APIs on router transport while correcting the route table so static chunks, platform-owned APIs, and shell compatibility prefixes dispatch to the intended target. +- Working directory: `src/Router`. +- Allowed coordination edits: `devops/compose/router-gateway-local.json`, `docs/modules/router/architecture.md`, `devops/compose/README.md`, `src/Router/StellaOps.Gateway.WebService/TASKS.md`, `docs/implplan/SPRINT_20260310_020_Router_frontdoor_route_boundary_and_service_prefix_repair.md`. +- Expected evidence: focused router tests, live gateway probes on `https://stella-ops.local`, and a Playwright canonical route sweep after gateway restart. + +## Dependencies & Concurrency +- Depends on `SPRINT_20260310_001_Router_frontdoor_required_service_readiness.md` for truthful frontdoor readiness and restart convergence. +- Safe parallelism: stay in `src/Router` plus the listed compose/docs files; leave frontend route repairs for separate FE iterations once the frontdoor contract is verified. + +## Documentation Prerequisites +- `AGENTS.md` +- `src/Router/AGENTS.md` +- `src/Router/StellaOps.Gateway.WebService/AGENTS.md` +- `src/Router/__Tests/StellaOps.Gateway.WebService.Tests/AGENTS.md` +- `docs/modules/router/architecture.md` + +## Delivery Tracker + +### ROUTER-BOUNDARY-001 - Restore route precedence for platform-owned and static-boundary paths +Status: DONE +Dependency: none +Owners: Developer, QA +Task description: +- Tighten the root `/policy` regex so it only owns the actual route segment and no longer steals Angular static chunks. +- Restore explicit platform ownership for `/api/v1/aoc/*` and `/api/v1/administration/*` ahead of the generic `/api/v1/{service}` matcher so those requests do not dispatch to synthetic microservice names. + +Completion criteria: +- [x] `/policy-decisioning.routes-*.js` stays on the static route. +- [x] `/api/v1/aoc/*` and `/api/v1/administration/*` resolve to `platform`. +- [x] Focused route-table tests prove precedence against generic catch-alls. + +### ROUTER-BOUNDARY-002 - Strip browser compatibility prefixes before microservice dispatch +Status: DONE +Dependency: ROUTER-BOUNDARY-001 +Owners: Developer, QA +Task description: +- Preserve the shell-facing `/doctor/*` and `/scheduler/*` entrypoints while translating them to the canonical backend paths those services actually expose. +- Verify the dispatch middleware forwards `/doctor/api/v1/doctor/*` and `/scheduler/api/v1/scheduler/*` as `/api/v1/doctor/*` and `/api/v1/scheduler/*` to the correct microservice. + +Completion criteria: +- [x] Route config translates `/doctor/*` and `/scheduler/*` without duplicating the service-root prefix. +- [x] Middleware tests assert the translated request path seen by the microservice pipeline. +- [x] Live frontdoor probes return authenticated backend responses instead of SPA fallback or `404`. + +### ROUTER-BOUNDARY-003 - Reverify the live scratch frontdoor with Playwright +Status: DONE +Dependency: ROUTER-BOUNDARY-002 +Owners: QA +Task description: +- Restart the live `router-gateway`, probe the corrected routes directly, and rerun the Playwright canonical sweep against the local scratch stack. +- Capture the remaining failures explicitly so the next iteration starts from UI defects only, not route ownership ambiguity. + +Completion criteria: +- [x] `router-gateway` restarted on the local compose stack after config changes. +- [x] Live probes for `/doctor`, `/scheduler`, `/api/v1/aoc`, and `/api/v1/administration` return backend-authenticated responses. +- [x] Playwright canonical sweep records the post-fix result set and isolates any remaining failures. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-10 | Sprint created after live scratch verification showed three route-boundary defects: the `/policy` root regex captured Angular chunks, the generic `/api/v1/{service}` matcher stole platform-owned AOC and administration APIs, and shell compatibility prefixes for doctor/scheduler forwarded the wrong backend path. | Developer | +| 2026-03-10 | Added explicit platform mappings, segment-bound `/policy` matching, and prefix-stripping `/doctor`/`/scheduler` routes in both gateway configs. Focused router tests passed `269/269`. | Developer | +| 2026-03-10 | Restarted `router-gateway`, re-probed the repaired live frontdoor, and reran the Playwright canonical route sweep. Result: `109/111` passed, with only `/ops/operations/scheduler` and `/ops/operations/doctor` still failing due to frontend routing fallback rather than router dispatch. | QA | + +## Decisions & Risks +- Decision: reverse proxy remains reserved for external/bootstrap surfaces only; first-party Stella API defects must be solved by correcting router route ownership, not by bypassing router transport. +- Decision: root-prefix regex routes must use segment boundaries whenever SPA/static assets can share the same textual prefix. +- Decision: browser-facing compatibility prefixes are acceptable when the route table strips them before dispatch so backend services keep their canonical API roots. +- Risk: the remaining `109/111` Playwright result still includes two frontend route failures under `/ops/operations/*`. Mitigation: treat those as a separate FE iteration and keep this router commit scoped to dispatch correctness. + +## Next Checkpoints +- 2026-03-10: Commit the router/frontdoor boundary repair as a standalone iteration. +- 2026-03-10: Triage `/ops/operations/scheduler` and `/ops/operations/doctor` in the web route layer using the clean router baseline. diff --git a/docs/modules/router/architecture.md b/docs/modules/router/architecture.md index 7bff6cafc..ad434de05 100644 --- a/docs/modules/router/architecture.md +++ b/docs/modules/router/architecture.md @@ -87,6 +87,10 @@ Route types: Reverse proxy is reserved for external/bootstrap surfaces such as OIDC browser flows, Rekor, and frontdoor static assets. First-party Stella API surfaces are expected to use `Microservice` routing so the gateway remains the single routing authority instead of silently bypassing router registration state. +Regex microservice routes that own a root prefix must use a segment boundary when the same prefix can appear in static asset filenames. The local frontdoor uses `^/policy(?=/|$)(.*)` rather than `^/policy(.*)` so Angular chunks such as `/policy-decisioning.routes-*.js` stay on the SPA/static path instead of being misrouted to the Policy service. + +Browser-facing compatibility prefixes that exist only at the frontdoor must strip that prefix before dispatching to the target microservice. Local compose keeps `/doctor/api/v1/doctor/*` and `/scheduler/api/v1/scheduler/*` for the shell, but the route table translates them to `http://doctor.stella-ops.local$1` and `http://scheduler.stella-ops.local$1` so the backend still receives its canonical `/api/v1//*` path. + ### Pipeline Order System paths (`/health`, `/metrics`, `/openapi.*`) bypass the route table entirely. The dispatch middleware runs before the microservice pipeline: diff --git a/src/Router/StellaOps.Gateway.WebService/TASKS.md b/src/Router/StellaOps.Gateway.WebService/TASKS.md index 7aa72e965..27a977481 100644 --- a/src/Router/StellaOps.Gateway.WebService/TASKS.md +++ b/src/Router/StellaOps.Gateway.WebService/TASKS.md @@ -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. | diff --git a/src/Router/StellaOps.Gateway.WebService/appsettings.json b/src/Router/StellaOps.Gateway.WebService/appsettings.json index a5f9b7ade..3630dbe56 100644 --- a/src/Router/StellaOps.Gateway.WebService/appsettings.json +++ b/src/Router/StellaOps.Gateway.WebService/appsettings.json @@ -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" }, diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs index 845badaeb..dda955de4 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs @@ -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) diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareMicroserviceTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareMicroserviceTests.cs index 7af9d834e..be7749672 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareMicroserviceTests.cs +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareMicroserviceTests.cs @@ -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(); + httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny())).Returns(new HttpClient()); + + var nextCalled = false; + var middleware = new RouteDispatchMiddleware( + _ => + { + nextCalled = true; + return Task.CompletedTask; + }, + resolver, + httpClientFactory.Object, + NullLogger.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() { diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Routing/StellaOpsRouteResolverTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Routing/StellaOpsRouteResolverTests.cs index db8d76a9c..a309e358f 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Routing/StellaOpsRouteResolverTests.cs +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Routing/StellaOpsRouteResolverTests.cs @@ -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() {