diff --git a/docs/implplan/SPRINT_20260309_008_Router_live_messaging_heartbeat_contract_repair.md b/docs/implplan/SPRINT_20260309_008_Router_live_messaging_heartbeat_contract_repair.md new file mode 100644 index 000000000..37d173956 --- /dev/null +++ b/docs/implplan/SPRINT_20260309_008_Router_live_messaging_heartbeat_contract_repair.md @@ -0,0 +1,78 @@ +# Sprint 20260309-008 - Router Live Messaging Heartbeat Contract Repair + +## Topic & Scope +- Repair the live frontdoor `503` cluster triggered after the full scratch rebuild, where healthy services are marked degraded or unhealthy because gateway heartbeat thresholds undercut the messaging transport's missed-notification fallback. +- Preserve the Valkey push-first CPU fix while ensuring a missed wake-up cannot stall queue consumption long enough to trip false gateway health failures. +- Rebuild and redeploy the affected router slice, then rerun the authenticated live Playwright sweep to confirm the shared `503` backlog collapses before moving on to page-specific defects. +- Working directory: `src/Router`. +- Allowed coordination edits: `docs/modules/router/architecture.md`, `docs/implplan/SPRINT_20260309_008_Router_live_messaging_heartbeat_contract_repair.md`. +- Expected evidence: focused router unit tests, rebuilt router image, redeployed gateway, refreshed live Playwright sweep artifact. + +## Dependencies & Concurrency +- Depends on `SPRINT_20260309_001_Platform_scratch_setup_bootstrap_restore.md` for the rebuilt baseline and `SPRINT_20260309_003_Router_live_frontdoor_contract_repair.md` for the already-restored frontdoor bindings. +- Safe parallelism: avoid the unrelated search and component-revival slices already landed by other agents; this sprint is limited to router messaging wake-up behavior, gateway health threshold policy, and live verification artifacts. + +## Documentation Prerequisites +- `AGENTS.md` +- `src/Router/AGENTS.md` +- `src/Router/StellaOps.Gateway.WebService/AGENTS.md` +- `docs/code-of-conduct/CODE_OF_CONDUCT.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/modules/router/architecture.md` +- `docs/modules/platform/architecture-overview.md` + +## Delivery Tracker + +### ROUTER-LIVE-008-001 - Bound the messaging wake-up fallback to heartbeat cadence +Status: DONE +Dependency: none +Owners: Developer, QA +Task description: +- Replace the fixed 30-second notifiable-queue fallback with a heartbeat-aware safety-net timeout so a missed Valkey pub/sub wake-up does not leave the gateway or microservices asleep long enough to look dead. +- Keep the transport push-first and low-CPU: the fallback exists only for missed notifications, not as a return to aggressive polling. + +Completion criteria: +- [x] Messaging queue waits derive their safety-net timeout from the configured heartbeat interval instead of a fixed 30-second constant. +- [x] Focused router tests cover the timeout calculation contract. +- [x] The transport remains push-first for notifiable queues. + +### ROUTER-LIVE-008-002 - Harden gateway health thresholds against heartbeat jitter +Status: DONE +Dependency: ROUTER-LIVE-008-001 +Owners: Developer, QA +Task description: +- Normalize gateway degraded/stale thresholds against the configured messaging heartbeat interval so the live gateway cannot mark healthy instances degraded or unhealthy earlier than the transport contract allows. +- Prefer a durable source-level policy over a compose-only tweak so the next scratch rebuild preserves the fix. + +Completion criteria: +- [x] Gateway health options are normalized to a minimum of 2x/3x the configured messaging heartbeat interval for degraded/stale transitions. +- [x] Focused router tests lock the health-threshold normalization behavior. +- [x] The router architecture dossier documents the heartbeat-to-health contract. + +### ROUTER-LIVE-008-003 - Rebuild, redeploy, and verify the live frontdoor +Status: DONE +Dependency: ROUTER-LIVE-008-002 +Owners: QA +Task description: +- Rebuild and redeploy the router slice, rerun the authenticated live sweep, and record whether the shared `503` cluster is removed or narrowed for the next iteration. + +Completion criteria: +- [x] Router artifacts are rebuilt and redeployed on the live compose stack. +- [x] The authenticated live Playwright sweep is rerun from the rebuilt stack. +- [x] Remaining failures are recorded with current evidence if any survive. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-09 | Sprint created after the rebuilt live stack showed shared gateway `503` failures caused by heartbeat health flapping rather than page-local defects. | Developer | +| 2026-03-09 | Updated messaging wait fallback to use heartbeat-derived safety-net timeouts, normalized gateway degraded/stale thresholds against messaging heartbeat cadence, and added focused router tests for both contracts. | Developer | +| 2026-03-09 | Rebuilt the full image set, redeployed the live compose stack, then reran authenticated Playwright sweeps. The first post-redeploy sweep showed transient cross-service `404` convergence misses; the second consecutive sweep completed `111/111` against `src/Web/StellaOps.Web/output/playwright/live-frontdoor-canonical-route-sweep.json`. | QA | + +## Decisions & Risks +- Decision: fix the router transport/gateway heartbeat contract in source instead of only loosening compose thresholds, because scratch rebuilds must preserve the runtime behavior. +- Decision: treat the transient post-redeploy `404` cluster as the same convergence class as earlier health flapping until proven otherwise; verify with consecutive authenticated Playwright sweeps before opening page-local code work. +- Risk: route convergence is improved but still needs continued scratch-rebuild observation in later iterations; if repeated `404` windows persist after the heartbeat contract change, the next fix belongs in startup/readiness gating rather than page clients. + +## Next Checkpoints +- 2026-03-09: completed messaging wait fallback repair, gateway threshold normalization, and live rebuild verification. +- Next iteration: expand from route availability into deeper Playwright action sweeps on the rebuilt stack. diff --git a/docs/implplan/SPRINT_20260309_012_Router_live_quota_scope_and_notify_dispatch_repairs.md b/docs/implplan/SPRINT_20260309_012_Router_live_quota_scope_and_notify_dispatch_repairs.md new file mode 100644 index 000000000..fdae5dd37 --- /dev/null +++ b/docs/implplan/SPRINT_20260309_012_Router_live_quota_scope_and_notify_dispatch_repairs.md @@ -0,0 +1,75 @@ +# Sprint 20260309-012 - Router Live Quota Scope And Notify Dispatch Repairs + +## Topic & Scope +- Repair the two remaining authenticated frontdoor regressions left after the full rebuild and redeploy: quota violations authorization and notify channel health dispatch. +- Keep the fixes in the Router layer because both failures occur before or inside Router-mediated delivery, not in the Platform or Notify business logic itself. +- Preserve existing live contracts while removing the actual transport/auth defects instead of adding route-local UI fallbacks. +- Working directory: `src/Router/`. +- Allowed coordination edits: `docs/modules/router/architecture.md`, `docs/modules/notify/architecture.md`, `docs/implplan/SPRINT_20260309_012_Router_live_quota_scope_and_notify_dispatch_repairs.md`, `src/Web/StellaOps.Web/output/playwright/**`. +- Expected evidence: targeted router test runs against individual `.csproj` files, rebuilt `router-gateway` image, redeployed compose stack, refreshed authenticated Playwright artifacts. + +## Dependencies & Concurrency +- Depends on `SPRINT_20260309_001_Platform_scratch_setup_bootstrap_restore.md` for the scratch rebuild baseline and `SPRINT_20260309_011_Platform_live_remaining_route_contract_repair.md` for the narrowed live failure inventory. +- Safe parallelism: do not touch unrelated search or component-revival work outside `src/Router/**`; leave unrelated dirty files untouched. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/code-of-conduct/CODE_OF_CONDUCT.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/modules/router/architecture.md` +- `docs/modules/notify/architecture.md` + +## Delivery Tracker + +### LIVE-ROUTER-012-001 - Restore gateway scope compatibility for quota reads +Status: DONE +Dependency: none +Owners: Developer, Test Automation +Task description: +- Fix the gateway authorization path so live quota endpoints can honor the resolved scope set produced by identity scope expansion. The frontdoor currently rejects quota reads even though the authenticated session carries `orch:quota` and the gateway already computes expanded scopes in request context. + +Completion criteria: +- [x] `/api/v1/gateway/rate-limits/violations` succeeds through the live frontdoor for the authenticated operator session. +- [x] Router gateway unit tests cover coarse-scope expansion and authorization checks against the resolved scope set. +- [x] Router docs describe that scope-based authorization uses the resolved scope context, not only raw claim payloads. + +### LIVE-ROUTER-012-002 - Fix ASP.NET bridge route matching for notify health paths +Status: DONE +Dependency: none +Owners: Developer, Test Automation +Task description: +- Fix the messaging-transport ASP.NET bridge so terminal route parameters are not treated as implicit catch-alls. The notify channel-health route currently dispatches through messaging, and the bridge incorrectly matches the shorter channel-detail route when extra segments are present. + +Completion criteria: +- [x] `/api/v1/notify/channels/{channelId}/health` resolves to the correct endpoint over Router messaging transport. +- [x] Router ASP.NET bridge tests reproduce the old terminal-parameter bug and prove explicit catch-all routes still work. +- [x] The fix is implemented in Router transport/bridge code, not in Notify page-local workarounds. + +### LIVE-ROUTER-012-003 - Rebuild, redeploy, and reverify the live frontdoor +Status: DONE +Dependency: LIVE-ROUTER-012-001, LIVE-ROUTER-012-002 +Owners: Developer, QA +Task description: +- Rebuild the touched router image, redeploy the live stack, and rerun authenticated Playwright verification for the two repaired pages before committing. + +Completion criteria: +- [x] The changed Router image is rebuilt from current source and redeployed. +- [x] Authenticated Playwright rechecks pass for `/ops/operations/quotas` and `/ops/operations/notifications`. +- [x] The canonical route sweep artifact reflects the updated live failure inventory. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-09 | Sprint created after the full rebuild/redeploy cleared scanner-backed route failures and left only two live Router-layer defects: quota scope enforcement and notify channel-health dispatch over messaging transport. | Developer | +| 2026-03-09 | Added coarse-to-fine quota scope compatibility in gateway authorization, fixed ASP.NET bridge terminal-parameter matching, rebuilt `router-gateway` and `notify-web`, and verified live `/ops/operations/quotas` plus `/ops/operations/notifications` behavior with authenticated Playwright. | Developer | +| 2026-03-09 | Re-ran the authenticated canonical live sweep after the rebuild cycle; the latest artifact reached `111/111` at `src/Web/StellaOps.Web/output/playwright/live-frontdoor-canonical-route-sweep.json`. | QA | + +## Decisions & Risks +- Decision: keep quota compatibility in Router by authorizing against the resolved scope context already produced by gateway identity expansion; do not broaden Platform policies or change token issuance. +- Decision: fix notify health in the ASP.NET bridge matcher so only explicit catch-all parameters consume extra path segments; this preserves direct HTTP and messaging parity. +- Risk: Router is a shared ingress surface. All changes must be covered by deterministic tests before redeploy to avoid collateral regressions in other routed pages. +- Decision: keep the live verification artifact in the sprint because the repaired quota and notify defects were validated in the same rebuilt stack that now serves the full canonical route set cleanly. + +## Next Checkpoints +- 2026-03-09: completed router gateway and ASP.NET bridge repairs with focused tests plus live rebuild verification. +- Next iteration: continue beyond route presence into deeper per-page action sweeps on the rebuilt stack. diff --git a/docs/modules/router/architecture.md b/docs/modules/router/architecture.md index fab0a1a03..f973c5813 100644 --- a/docs/modules/router/architecture.md +++ b/docs/modules/router/architecture.md @@ -18,6 +18,7 @@ Rollout policy: `docs/operations/multi-tenant-rollout-and-compatibility.md` - HTTP is not used for internal microservice-to-gateway traffic - Request/response bodies are opaque to the router (raw bytes/streams) - Forwarded HTTP headers remain case-insensitive across Router frame transport and ASP.NET bridge dispatch; lowercase HTTP/2 names such as `content-type` must be preserved for JSON-bound endpoints, and the ASP.NET bridge must mark POST/PUT/PATCH requests as body-capable so minimal-API JSON binding survives frame dispatch +- Gateway scope authorization evaluates against the resolved per-request scope set from identity expansion (`GatewayContextKeys.Scopes`), so coarse compatibility scopes such as `orch:quota` can satisfy their fine-grained frontdoor equivalents without changing downstream policy names ### Transport Architecture @@ -106,6 +107,8 @@ Browser → Router Gateway (port 80) → [microservices via binary transport] The Angular SPA dist is provided by a `console-builder` init container that copies the built files to a shared `console-dist` volume mounted at `/app/wwwroot`. +When the gateway runs in-container, listener binding must honor explicit `ASPNETCORE_URLS` / `ASPNETCORE_HTTP_PORTS` / `ASPNETCORE_HTTPS_PORTS` values from compose. Wildcard hosts (`+`, `*`) are normalized to `0.0.0.0` before Kestrel listeners are created so the declared HTTP frontdoor contract actually comes up. + --- ## Service Identity @@ -177,6 +180,7 @@ public sealed class EndpointDescriptor - ASP.NET-style route templates - Parameter segments: `{id}`, `{userId}` +- Extra path segments are consumed only by explicit catch-all parameters (`{**path}`); ordinary terminal parameters must not behave like implicit catch-alls during messaging transport dispatch - Case sensitivity and trailing slash handling follow ASP.NET conventions --- @@ -533,6 +537,8 @@ Gateway tracks: - Derives status from heartbeat recency - Marks stale instances as Unhealthy - Uses health in routing decisions +- Messaging transports stay push-first even when backed by notifiable queues; the missed-notification safety-net timeout is derived from the configured heartbeat interval and clamped to a short bounded window instead of falling back to a fixed long poll. +- Gateway degraded and stale transitions are normalized against the messaging heartbeat contract. A gateway may not mark an instance `Degraded` earlier than `2x` the heartbeat interval or `Unhealthy` earlier than `3x` the heartbeat interval, even when looser defaults were configured. Periodic HELLO re-registration is valid so a microservice can repopulate gateway state after a gateway restart, but it must refresh the existing logical transport connection instead of minting a second one. Gateway routing state also deduplicates by service instance identity (`ServiceName`, `Version`, `InstanceId`, transport) before re-indexing endpoints so repeated HELLO frames cannot accumulate stale route candidates. diff --git a/src/Router/StellaOps.Gateway.WebService/Authorization/AuthorizationMiddleware.cs b/src/Router/StellaOps.Gateway.WebService/Authorization/AuthorizationMiddleware.cs index 3e1793da3..3f3d3d304 100644 --- a/src/Router/StellaOps.Gateway.WebService/Authorization/AuthorizationMiddleware.cs +++ b/src/Router/StellaOps.Gateway.WebService/Authorization/AuthorizationMiddleware.cs @@ -1,7 +1,8 @@ - +using System.Security.Claims; +using System.Text.Json; +using StellaOps.Gateway.WebService.Middleware; using StellaOps.Router.Common.Models; using StellaOps.Router.Gateway; -using System.Text.Json; namespace StellaOps.Gateway.WebService.Authorization; @@ -66,6 +67,8 @@ public sealed class AuthorizationMiddleware return; } + var resolvedScopes = ResolveGatewayScopes(context); + foreach (var required in effectiveClaims) { var userClaims = context.User.Claims; @@ -78,13 +81,7 @@ public sealed class AuthorizationMiddleware else if (string.Equals(required.Type, "scope", StringComparison.OrdinalIgnoreCase) || string.Equals(required.Type, "scp", StringComparison.OrdinalIgnoreCase)) { - // Scope claims may be space-separated (RFC 6749 §3.3) or individual claims. - // Check both: exact match on individual claims, and contains-within-space-separated. - hasClaim = userClaims.Any(c => - c.Type == required.Type && - (c.Value == required.Value || - c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Any(s => string.Equals(s, required.Value, StringComparison.Ordinal)))); + hasClaim = HasRequiredScope(userClaims, resolvedScopes, required.Value); } else { @@ -108,6 +105,34 @@ public sealed class AuthorizationMiddleware await _next(context); } + private static ISet? ResolveGatewayScopes(HttpContext context) + { + return context.Items.TryGetValue(GatewayContextKeys.Scopes, out var scopesObj) && + scopesObj is ISet scopes && + scopes.Count > 0 + ? scopes + : null; + } + + private static bool HasRequiredScope( + IEnumerable userClaims, + ISet? resolvedScopes, + string requiredScope) + { + if (resolvedScopes is not null && resolvedScopes.Contains(requiredScope)) + { + return true; + } + + // Scope claims may be space-separated (RFC 6749 section 3.3) or individual claims. + return userClaims.Any(c => + (string.Equals(c.Type, "scope", StringComparison.OrdinalIgnoreCase) || + string.Equals(c.Type, "scp", StringComparison.OrdinalIgnoreCase)) && + (c.Value == requiredScope || + c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Any(s => string.Equals(s, requiredScope, StringComparison.Ordinal)))); + } + private static Task WriteUnauthorizedAsync(HttpContext context, EndpointDescriptor endpoint) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; diff --git a/src/Router/StellaOps.Gateway.WebService/Configuration/ContainerFrontdoorBindingResolver.cs b/src/Router/StellaOps.Gateway.WebService/Configuration/ContainerFrontdoorBindingResolver.cs new file mode 100644 index 000000000..5ae75ca8d --- /dev/null +++ b/src/Router/StellaOps.Gateway.WebService/Configuration/ContainerFrontdoorBindingResolver.cs @@ -0,0 +1,73 @@ +using System.Net; + +namespace StellaOps.Gateway.WebService.Configuration; + +/// +/// Resolves container listener URLs from ASP.NET Core's explicit URL and port environment variables. +/// +public static class ContainerFrontdoorBindingResolver +{ + public static IReadOnlyList ResolveConfiguredUrls( + string? serverUrls, + string? explicitUrls, + string? explicitHttpPorts, + string? explicitHttpsPorts) + { + var rawUrls = !string.IsNullOrWhiteSpace(serverUrls) + ? SplitEntries(serverUrls) + : !string.IsNullOrWhiteSpace(explicitUrls) + ? SplitEntries(explicitUrls) + : BuildUrlsFromPorts(explicitHttpPorts, explicitHttpsPorts); + + return rawUrls + .Select(TryCreateListenUri) + .Where(static uri => uri is not null) + .Cast() + .DistinctBy(static uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static IEnumerable BuildUrlsFromPorts(string? explicitHttpPorts, string? explicitHttpsPorts) + { + foreach (var port in SplitEntries(explicitHttpPorts)) + { + if (int.TryParse(port, out var value) && value > 0) + { + yield return $"http://0.0.0.0:{value}"; + } + } + + foreach (var port in SplitEntries(explicitHttpsPorts)) + { + if (int.TryParse(port, out var value) && value > 0) + { + yield return $"https://0.0.0.0:{value}"; + } + } + } + + private static IEnumerable SplitEntries(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? [] + : value.Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static Uri? TryCreateListenUri(string rawUrl) + { + var normalized = NormalizeWildcardHost(rawUrl); + return Uri.TryCreate(normalized, UriKind.Absolute, out var uri) ? uri : null; + } + + private static string NormalizeWildcardHost(string rawUrl) + { + if (string.IsNullOrWhiteSpace(rawUrl)) + { + return string.Empty; + } + + return rawUrl + .Replace("://+", $"://{IPAddress.Any}", StringComparison.Ordinal) + .Replace("://*", $"://{IPAddress.Any}", StringComparison.Ordinal); + } +} diff --git a/src/Router/StellaOps.Gateway.WebService/Configuration/GatewayHealthThresholdPolicy.cs b/src/Router/StellaOps.Gateway.WebService/Configuration/GatewayHealthThresholdPolicy.cs new file mode 100644 index 000000000..32dcc459b --- /dev/null +++ b/src/Router/StellaOps.Gateway.WebService/Configuration/GatewayHealthThresholdPolicy.cs @@ -0,0 +1,36 @@ +using StellaOps.Router.Gateway.Configuration; + +namespace StellaOps.Gateway.WebService.Configuration; + +internal static class GatewayHealthThresholdPolicy +{ + private static readonly TimeSpan DefaultMessagingHeartbeatInterval = TimeSpan.FromSeconds(10); + + internal static void ApplyMinimums(HealthOptions options, GatewayMessagingTransportOptions messaging) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(messaging); + + var heartbeatInterval = GatewayValueParser.ParseDuration( + messaging.HeartbeatInterval, + DefaultMessagingHeartbeatInterval); + + if (heartbeatInterval <= TimeSpan.Zero) + { + heartbeatInterval = DefaultMessagingHeartbeatInterval; + } + + var minimumDegradedThreshold = TimeSpan.FromTicks(heartbeatInterval.Ticks * 2); + var minimumStaleThreshold = TimeSpan.FromTicks(heartbeatInterval.Ticks * 3); + + if (options.DegradedThreshold < minimumDegradedThreshold) + { + options.DegradedThreshold = minimumDegradedThreshold; + } + + if (options.StaleThreshold < minimumStaleThreshold) + { + options.StaleThreshold = minimumStaleThreshold; + } + } +} diff --git a/src/Router/StellaOps.Gateway.WebService/Middleware/IdentityHeaderPolicyMiddleware.cs b/src/Router/StellaOps.Gateway.WebService/Middleware/IdentityHeaderPolicyMiddleware.cs index 4283d939d..d96929f46 100644 --- a/src/Router/StellaOps.Gateway.WebService/Middleware/IdentityHeaderPolicyMiddleware.cs +++ b/src/Router/StellaOps.Gateway.WebService/Middleware/IdentityHeaderPolicyMiddleware.cs @@ -433,6 +433,14 @@ public sealed class IdentityHeaderPolicyMiddleware scopes.Add("scheduler.runs.preview"); scopes.Add("scheduler.runs.manage"); } + + // Legacy operator sessions still mint orch:quota and rely on the gateway to + // resolve the equivalent fine-grained quota scopes before frontdoor auth. + if (scopes.Contains("orch:quota")) + { + scopes.Add("quota.read"); + scopes.Add("quota.admin"); + } } private void StoreIdentityContext(HttpContext context, IdentityContext identity) diff --git a/src/Router/StellaOps.Gateway.WebService/Program.cs b/src/Router/StellaOps.Gateway.WebService/Program.cs index 72651a2fb..14de2dc25 100644 --- a/src/Router/StellaOps.Gateway.WebService/Program.cs +++ b/src/Router/StellaOps.Gateway.WebService/Program.cs @@ -371,6 +371,7 @@ static void ConfigureGatewayOptionsMapping(WebApplicationBuilder builder, Gatewa options.StaleThreshold = GatewayValueParser.ParseDuration(health.StaleThreshold, options.StaleThreshold); options.DegradedThreshold = GatewayValueParser.ParseDuration(health.DegradedThreshold, options.DegradedThreshold); options.CheckInterval = GatewayValueParser.ParseDuration(health.CheckInterval, options.CheckInterval); + GatewayHealthThresholdPolicy.ApplyMinimums(options, gateway.Value.Transports.Messaging); }); builder.Services.AddOptions() @@ -413,19 +414,18 @@ static bool ShouldApplyStellaOpsLocalBinding() static void ConfigureContainerFrontdoorBindings(WebApplicationBuilder builder) { - var currentUrls = builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey) ?? string.Empty; + var currentUrls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls( + builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey), + Environment.GetEnvironmentVariable("ASPNETCORE_URLS"), + Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS"), + Environment.GetEnvironmentVariable("ASPNETCORE_HTTPS_PORTS")); builder.WebHost.ConfigureKestrel((context, kestrel) => { var defaultCert = LoadDefaultCertificate(context.Configuration); - foreach (var rawUrl in currentUrls.Split(';', StringSplitOptions.RemoveEmptyEntries)) + foreach (var uri in currentUrls) { - if (!Uri.TryCreate(rawUrl.Trim(), UriKind.Absolute, out var uri)) - { - continue; - } - var address = ResolveListenAddress(uri.Host); if (string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) { @@ -537,4 +537,3 @@ static string? ResolveAuthorityClaimsUrl(GatewayAuthorityOptions authorityOption return builder.Uri.GetLeftPart(UriPartial.Authority).TrimEnd('/'); } - diff --git a/src/Router/StellaOps.Gateway.WebService/StellaOps.Gateway.WebService.csproj b/src/Router/StellaOps.Gateway.WebService/StellaOps.Gateway.WebService.csproj index 1ead66681..272e970bd 100644 --- a/src/Router/StellaOps.Gateway.WebService/StellaOps.Gateway.WebService.csproj +++ b/src/Router/StellaOps.Gateway.WebService/StellaOps.Gateway.WebService.csproj @@ -6,6 +6,9 @@ enable true + + + diff --git a/src/Router/StellaOps.Gateway.WebService/appsettings.json b/src/Router/StellaOps.Gateway.WebService/appsettings.json index d7c3c517a..57df8946b 100644 --- a/src/Router/StellaOps.Gateway.WebService/appsettings.json +++ b/src/Router/StellaOps.Gateway.WebService/appsettings.json @@ -61,11 +61,12 @@ }, "Health": { "StaleThreshold": "30s", - "DegradedThreshold": "15s", + "DegradedThreshold": "20s", "CheckInterval": "5s" }, "Routes": [ - { "Type": "ReverseProxy", "Path": "/api/v1/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/release-orchestrator" }, + { "Type": "ReverseProxy", "Path": "/api/v1/release-orchestrator", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/release-orchestrator" }, + { "Type": "ReverseProxy", "Path": "/api/v1/approvals", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/approvals" }, { "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex" }, { "Type": "ReverseProxy", "Path": "/api/v1/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local/api/v1/vexlens" }, { "Type": "ReverseProxy", "Path": "/api/v1/notify", "TranslatesTo": "http://notify.stella-ops.local/api/v1/notify" }, @@ -79,9 +80,9 @@ { "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy", "PreserveAuthHeaders": true }, { "Type": "ReverseProxy", "Path": "/api/risk", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk", "PreserveAuthHeaders": true }, { "Type": "ReverseProxy", "Path": "/api/analytics", "TranslatesTo": "http://platform.stella-ops.local/api/analytics" }, - { "Type": "ReverseProxy", "Path": "/api/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/release-orchestrator" }, - { "Type": "ReverseProxy", "Path": "/api/releases", "TranslatesTo": "http://orchestrator.stella-ops.local/api/releases" }, - { "Type": "ReverseProxy", "Path": "/api/approvals", "TranslatesTo": "http://orchestrator.stella-ops.local/api/approvals" }, + { "Type": "ReverseProxy", "Path": "/api/release-orchestrator", "TranslatesTo": "http://jobengine.stella-ops.local/api/release-orchestrator" }, + { "Type": "ReverseProxy", "Path": "/api/releases", "TranslatesTo": "http://jobengine.stella-ops.local/api/releases" }, + { "Type": "ReverseProxy", "Path": "/api/approvals", "TranslatesTo": "http://jobengine.stella-ops.local/api/approvals" }, { "Type": "ReverseProxy", "Path": "/api/v1/platform", "TranslatesTo": "http://platform.stella-ops.local/api/v1/platform" }, { "Type": "ReverseProxy", "Path": "/api/v1/scanner", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/scanner" }, { "Type": "ReverseProxy", "Path": "/api/v1/jobengine", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/jobengine", "PreserveAuthHeaders": true }, diff --git a/src/Router/__Libraries/StellaOps.Microservice.AspNetCore/AspNetRouterRequestDispatcher.cs b/src/Router/__Libraries/StellaOps.Microservice.AspNetCore/AspNetRouterRequestDispatcher.cs index 5108c78b2..5c984a921 100644 --- a/src/Router/__Libraries/StellaOps.Microservice.AspNetCore/AspNetRouterRequestDispatcher.cs +++ b/src/Router/__Libraries/StellaOps.Microservice.AspNetCore/AspNetRouterRequestDispatcher.cs @@ -705,12 +705,12 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch private sealed class TemplateMatcher { private readonly RoutePattern _pattern; - private readonly List<(string Segment, bool IsParameter, string? ParameterName)> _segments; + private readonly List<(string Segment, bool IsParameter, string? ParameterName, bool IsCatchAll)> _segments; public TemplateMatcher(RoutePattern pattern) { _pattern = pattern; - _segments = new List<(string, bool, string?)>(); + _segments = new List<(string, bool, string?, bool)>(); foreach (var segment in pattern.PathSegments) { @@ -719,10 +719,10 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch switch (segment.Parts[0]) { case RoutePatternLiteralPart literal: - _segments.Add((literal.Content, false, null)); + _segments.Add((literal.Content, false, null, false)); break; case RoutePatternParameterPart param: - _segments.Add((param.Name, true, param.Name)); + _segments.Add((param.Name, true, param.Name, param.IsCatchAll)); break; } } @@ -735,7 +735,7 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch RoutePatternParameterPart par => $"{{{par.Name}}}", _ => "" })); - _segments.Add((combined, false, null)); + _segments.Add((combined, false, null, false)); } } } @@ -746,21 +746,22 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch var pathSegments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (pathSegments.Length != _segments.Count) + if (pathSegments.Length != _segments.Count) + { + // Only explicit catch-all parameters may consume extra path segments. + if (_segments.Count > 0 && + _segments[^1].IsParameter && + _segments[^1].IsCatchAll && + pathSegments.Length >= _segments.Count - 1) { - // Check for catch-all - if (_segments.Count > 0 && - _segments[^1].IsParameter && - pathSegments.Length >= _segments.Count - 1) + // Handle catch-all: remaining path goes into last parameter + for (var i = 0; i < _segments.Count - 1; i++) { - // Handle catch-all: remaining path goes into last parameter - for (var i = 0; i < _segments.Count - 1; i++) + var (segment, isParam, paramName, _) = _segments[i]; + if (isParam) { - var (segment, isParam, paramName) = _segments[i]; - if (isParam) - { - routeValues[paramName!] = pathSegments[i]; - } + routeValues[paramName!] = pathSegments[i]; + } else if (!string.Equals(segment, pathSegments[i], StringComparison.OrdinalIgnoreCase)) { return false; @@ -775,14 +776,14 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch return false; } - for (var i = 0; i < _segments.Count; i++) - { - var (segment, isParam, paramName) = _segments[i]; + for (var i = 0; i < _segments.Count; i++) + { + var (segment, isParam, paramName, _) = _segments[i]; - if (isParam) - { - routeValues[paramName!] = pathSegments[i]; - } + if (isParam) + { + routeValues[paramName!] = pathSegments[i]; + } else if (!string.Equals(segment, pathSegments[i], StringComparison.OrdinalIgnoreCase)) { return false; diff --git a/src/Router/__Libraries/StellaOps.Router.AspNet/StellaRouterIntegrationHelper.cs b/src/Router/__Libraries/StellaOps.Router.AspNet/StellaRouterIntegrationHelper.cs index 94d1627a1..1430f4065 100644 --- a/src/Router/__Libraries/StellaOps.Router.AspNet/StellaRouterIntegrationHelper.cs +++ b/src/Router/__Libraries/StellaOps.Router.AspNet/StellaRouterIntegrationHelper.cs @@ -157,11 +157,6 @@ public static class StellaRouterIntegrationHelper throw new InvalidOperationException("Router is enabled but no gateway endpoints are configured."); } - services.TryAddStellaRouter( - serviceName, - version, - routerOptions); - var configuredTransports = routerOptions.Gateways .GroupBy(gateway => gateway.TransportType) .Select(group => (TransportType: group.Key, Gateway: group.First())) @@ -178,6 +173,18 @@ public static class StellaRouterIntegrationHelper resolvedMessagingSection, configuredTransports); + ApplyImplicitHeartbeatInterval( + routerOptions, + configuration, + transportConfiguration, + resolvedRouterOptionsSection, + configuredTransports); + + services.TryAddStellaRouter( + serviceName, + version, + routerOptions); + var pluginLoader = CreateTransportPluginLoader( transportConfiguration, resolvedRouterOptionsSection); @@ -371,6 +378,33 @@ public static class StellaRouterIntegrationHelper .Build(); } + private static void ApplyImplicitHeartbeatInterval( + StellaRouterOptionsBase routerOptions, + IConfiguration configuration, + IConfiguration transportConfiguration, + string routerOptionsSection, + IEnumerable<(TransportType TransportType, StellaRouterGatewayOptionsBase Gateway)> configuredTransports) + { + if (HasExplicitHeartbeatInterval(configuration, routerOptionsSection) || + !configuredTransports.Any(static transport => transport.TransportType == TransportType.Messaging)) + { + return; + } + + var resolvedMessagingSection = BuildResolvedTransportSection(ToTransportPluginName(TransportType.Messaging)); + var fallback = TimeSpan.FromSeconds(routerOptions.HeartbeatIntervalSeconds); + var heartbeatInterval = ParseDuration( + transportConfiguration[$"{resolvedMessagingSection}:HeartbeatInterval"], + fallback); + + if (heartbeatInterval <= TimeSpan.Zero) + { + return; + } + + routerOptions.HeartbeatIntervalSeconds = Math.Max(1, (int)Math.Ceiling(heartbeatInterval.TotalSeconds)); + } + private static void CopySectionValues( IConfigurationSection section, IDictionary destination, @@ -487,6 +521,13 @@ public static class StellaRouterIntegrationHelper } } + private static bool HasExplicitHeartbeatInterval( + IConfiguration configuration, + string routerOptionsSection) + { + return !string.IsNullOrWhiteSpace(configuration[$"{routerOptionsSection}:HeartbeatIntervalSeconds"]); + } + private static RouterTransportPluginLoader CreateTransportPluginLoader( IConfiguration configuration, string routerOptionsSection) diff --git a/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/Extensions/QueueWaitExtensions.cs b/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/Extensions/QueueWaitExtensions.cs index f9b1c0bba..8815ea905 100644 --- a/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/Extensions/QueueWaitExtensions.cs +++ b/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/Extensions/QueueWaitExtensions.cs @@ -9,30 +9,58 @@ namespace StellaOps.Router.Transport.Messaging.Extensions; /// internal static class QueueWaitExtensions { - /// - /// Default fallback timeout when the queue supports Pub/Sub notifications. - /// The wait returns early on notification; this is only a safety net. - /// - private static readonly TimeSpan NotifiableTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan MinimumNotifiableTimeout = TimeSpan.FromSeconds(1); + private static readonly TimeSpan MaximumNotifiableTimeout = TimeSpan.FromSeconds(5); /// /// Fallback delay for queues that do not implement . /// private static readonly TimeSpan PollingFallback = TimeSpan.FromSeconds(1); + /// + /// Resolves the safety-net timeout for notifiable queues. + /// The queue stays push-first; this timeout only covers missed notifications. + /// + internal static TimeSpan ResolveNotifiableTimeout(TimeSpan heartbeatInterval) + { + if (heartbeatInterval <= TimeSpan.Zero) + { + return MaximumNotifiableTimeout; + } + + var derivedSeconds = Math.Max( + MinimumNotifiableTimeout.TotalSeconds, + Math.Floor(heartbeatInterval.TotalSeconds / 3d)); + + var derivedTimeout = TimeSpan.FromSeconds(derivedSeconds); + if (derivedTimeout > MaximumNotifiableTimeout) + { + return MaximumNotifiableTimeout; + } + + return derivedTimeout; + } + /// /// Waits for new messages to become available on the queue. /// public static Task WaitForMessagesAsync( this IMessageQueue queue, + TimeSpan heartbeatInterval, CancellationToken cancellationToken) where TMessage : class { if (queue is INotifiableQueue notifiable) { - return notifiable.WaitForNotificationAsync(NotifiableTimeout, cancellationToken); + return notifiable.WaitForNotificationAsync(ResolveNotifiableTimeout(heartbeatInterval), cancellationToken); } return Task.Delay(PollingFallback, cancellationToken); } + + public static Task WaitForMessagesAsync( + this IMessageQueue queue, + CancellationToken cancellationToken) + where TMessage : class + => WaitForMessagesAsync(queue, TimeSpan.Zero, cancellationToken); } diff --git a/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/MessagingTransportClient.cs b/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/MessagingTransportClient.cs index fd8d4bdf5..8e01204c7 100644 --- a/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/MessagingTransportClient.cs +++ b/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/MessagingTransportClient.cs @@ -206,7 +206,7 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr // Wait for Pub/Sub notification instead of busy-polling. if (leases.Count == 0) { - await _responseQueue.WaitForMessagesAsync(cancellationToken); + await _responseQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -270,7 +270,7 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr // Wait for Pub/Sub notification instead of busy-polling. if (leases.Count == 0) { - await _serviceIncomingQueue.WaitForMessagesAsync(cancellationToken); + await _serviceIncomingQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) diff --git a/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/MessagingTransportServer.cs b/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/MessagingTransportServer.cs index c7f0dd219..3dd754ff7 100644 --- a/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/MessagingTransportServer.cs +++ b/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/MessagingTransportServer.cs @@ -173,7 +173,7 @@ public sealed class MessagingTransportServer : ITransportServer, IDisposable // Wait for Pub/Sub notification instead of busy-polling. if (leases.Count == 0) { - await _requestQueue.WaitForMessagesAsync(cancellationToken); + await _requestQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -217,7 +217,7 @@ public sealed class MessagingTransportServer : ITransportServer, IDisposable // Wait for Pub/Sub notification instead of busy-polling. if (leases.Count == 0) { - await _responseQueue.WaitForMessagesAsync(cancellationToken); + await _responseQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) diff --git a/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/StellaOps.Router.Transport.Messaging.csproj b/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/StellaOps.Router.Transport.Messaging.csproj index 379aa8382..a89e72383 100644 --- a/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/StellaOps.Router.Transport.Messaging.csproj +++ b/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/StellaOps.Router.Transport.Messaging.csproj @@ -9,6 +9,10 @@ StellaOps.Router.Transport.Messaging + + + + diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Authorization/AuthorizationMiddlewareTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Authorization/AuthorizationMiddlewareTests.cs index d976bc63c..8ea8a87ad 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Authorization/AuthorizationMiddlewareTests.cs +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Authorization/AuthorizationMiddlewareTests.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; using Moq; using StellaOps.Gateway.WebService.Authorization; +using StellaOps.Gateway.WebService.Middleware; using StellaOps.Router.Common.Models; using StellaOps.Router.Gateway; using Xunit; @@ -232,6 +233,33 @@ public sealed class AuthorizationMiddlewareTests _next.Verify(n => n(context), Times.Once); } + [Fact] + public async Task InvokeAsync_ScopeRequirement_UsesResolvedGatewayScopes_WhenPresent() + { + var context = CreateHttpContextWithEndpoint(new[] + { + new Claim("scope", "orch:quota") + }); + context.Items[GatewayContextKeys.Scopes] = new HashSet(StringComparer.Ordinal) + { + "orch:quota", + "quota.read", + "quota.admin" + }; + + _claimsStore + .Setup(s => s.GetEffectiveClaims("test-service", "GET", "/api/test")) + .Returns(new List + { + new() { Type = "scope", Value = "quota.read" }, + new() { Type = "scope", Value = "orch:quota" } + }); + + await _middleware.InvokeAsync(context); + + _next.Verify(n => n(context), Times.Once); + } + [Fact] public async Task InvokeAsync_ForbiddenResponse_ContainsErrorDetails() { diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/ContainerFrontdoorBindingResolverTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/ContainerFrontdoorBindingResolverTests.cs new file mode 100644 index 000000000..7388e9f58 --- /dev/null +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/ContainerFrontdoorBindingResolverTests.cs @@ -0,0 +1,47 @@ +using StellaOps.Gateway.WebService.Configuration; + +namespace StellaOps.Gateway.WebService.Tests.Configuration; + +public sealed class ContainerFrontdoorBindingResolverTests +{ + [Fact] + public void ResolveConfiguredUrls_NormalizesWildcardHostsFromExplicitUrls() + { + var urls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls( + serverUrls: null, + explicitUrls: "http://+:80;http://*:8080", + explicitHttpPorts: null, + explicitHttpsPorts: null); + + urls.Select(static uri => uri.AbsoluteUri).Should().BeEquivalentTo( + "http://0.0.0.0/", + "http://0.0.0.0:8080/"); + } + + [Fact] + public void ResolveConfiguredUrls_BuildsUrlsFromPortEnvironment() + { + var urls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls( + serverUrls: null, + explicitUrls: null, + explicitHttpPorts: "80,8080", + explicitHttpsPorts: "8443"); + + urls.Select(static uri => uri.AbsoluteUri).Should().BeEquivalentTo( + "http://0.0.0.0/", + "http://0.0.0.0:8080/", + "https://0.0.0.0:8443/"); + } + + [Fact] + public void ResolveConfiguredUrls_PrefersServerUrlsWhenProvided() + { + var urls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls( + serverUrls: "http://localhost:9090", + explicitUrls: "http://+:80", + explicitHttpPorts: "8080", + explicitHttpsPorts: null); + + urls.Select(static uri => uri.AbsoluteUri).Should().Equal("http://localhost:9090/"); + } +} diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayHealthThresholdPolicyTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayHealthThresholdPolicyTests.cs new file mode 100644 index 000000000..015956a26 --- /dev/null +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayHealthThresholdPolicyTests.cs @@ -0,0 +1,51 @@ +using StellaOps.Gateway.WebService.Configuration; +using StellaOps.Router.Gateway.Configuration; + +namespace StellaOps.Gateway.WebService.Tests.Configuration; + +public sealed class GatewayHealthThresholdPolicyTests +{ + [Fact] + public void ApplyMinimums_RaisesThresholdsToHeartbeatContract() + { + var options = new HealthOptions + { + DegradedThreshold = TimeSpan.FromSeconds(15), + StaleThreshold = TimeSpan.FromSeconds(30), + CheckInterval = TimeSpan.FromSeconds(5) + }; + + var messaging = new GatewayMessagingTransportOptions + { + Enabled = true, + HeartbeatInterval = "10s" + }; + + GatewayHealthThresholdPolicy.ApplyMinimums(options, messaging); + + options.DegradedThreshold.Should().Be(TimeSpan.FromSeconds(20)); + options.StaleThreshold.Should().Be(TimeSpan.FromSeconds(30)); + } + + [Fact] + public void ApplyMinimums_PreservesHigherExplicitThresholds() + { + var options = new HealthOptions + { + DegradedThreshold = TimeSpan.FromSeconds(45), + StaleThreshold = TimeSpan.FromSeconds(90), + CheckInterval = TimeSpan.FromSeconds(5) + }; + + var messaging = new GatewayMessagingTransportOptions + { + Enabled = true, + HeartbeatInterval = "10s" + }; + + GatewayHealthThresholdPolicy.ApplyMinimums(options, messaging); + + options.DegradedThreshold.Should().Be(TimeSpan.FromSeconds(45)); + options.StaleThreshold.Should().Be(TimeSpan.FromSeconds(90)); + } +} diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/IdentityHeaderPolicyMiddlewareTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/IdentityHeaderPolicyMiddlewareTests.cs index 79e28891b..390935ac3 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/IdentityHeaderPolicyMiddlewareTests.cs +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/IdentityHeaderPolicyMiddlewareTests.cs @@ -307,6 +307,26 @@ public sealed class IdentityHeaderPolicyMiddlewareTests Assert.Contains("admin", scopes); } + [Fact] + public async Task InvokeAsync_ExpandsLegacyQuotaScopeIntoResolvedQuotaScopes() + { + var middleware = CreateMiddleware(); + var claims = new[] + { + new Claim(StellaOpsClaimTypes.Subject, "user"), + new Claim(StellaOpsClaimTypes.Scope, "orch:quota") + }; + var context = CreateHttpContext("/api/scan", claims); + + await middleware.InvokeAsync(context); + + Assert.True(_nextCalled); + var scopes = (HashSet)context.Items[GatewayContextKeys.Scopes]!; + Assert.Contains("orch:quota", scopes); + Assert.Contains("quota.read", scopes); + Assert.Contains("quota.admin", scopes); + } + [Fact] public async Task InvokeAsync_ScopesAreSortedDeterministically() { diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/TASKS.md b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/TASKS.md index 63bbd75ba..7ea841e35 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/TASKS.md +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/TASKS.md @@ -11,3 +11,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | | 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. | diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Transport/QueueWaitExtensionsTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Transport/QueueWaitExtensionsTests.cs new file mode 100644 index 000000000..8a2a1982e --- /dev/null +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Transport/QueueWaitExtensionsTests.cs @@ -0,0 +1,65 @@ +using StellaOps.Messaging; +using StellaOps.Messaging.Abstractions; +using StellaOps.Router.Transport.Messaging.Extensions; + +namespace StellaOps.Gateway.WebService.Tests.Transport; + +public sealed class QueueWaitExtensionsTests +{ + [Fact] + public async Task WaitForMessagesAsync_UsesHeartbeatDerivedTimeout_ForNotifiableQueues() + { + var queue = new RecordingNotifiableQueue(); + + await queue.WaitForMessagesAsync(TimeSpan.FromSeconds(10), CancellationToken.None); + + queue.LastTimeout.Should().Be(QueueWaitExtensions.ResolveNotifiableTimeout(TimeSpan.FromSeconds(10))); + } + + [Theory] + [InlineData(10, 3)] + [InlineData(45, 5)] + [InlineData(1, 1)] + public void ResolveNotifiableTimeout_ClampsToExpectedBounds(int heartbeatSeconds, int expectedSeconds) + { + var timeout = QueueWaitExtensions.ResolveNotifiableTimeout(TimeSpan.FromSeconds(heartbeatSeconds)); + + timeout.Should().Be(TimeSpan.FromSeconds(expectedSeconds)); + } + + private sealed class RecordingNotifiableQueue : IMessageQueue, INotifiableQueue + { + public string ProviderName => "test"; + + public string QueueName => "test-queue"; + + public TimeSpan? LastTimeout { get; private set; } + + public ValueTask EnqueueAsync( + TestMessage message, + EnqueueOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public ValueTask>> LeaseAsync( + LeaseRequest request, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public ValueTask>> ClaimExpiredAsync( + ClaimRequest request, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public ValueTask GetPendingCountAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task WaitForNotificationAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + LastTimeout = timeout; + return Task.CompletedTask; + } + } + + private sealed record TestMessage(string Value); +} diff --git a/src/Router/__Tests/StellaOps.Router.AspNet.Tests/AspNetRouterRequestDispatcherTests.cs b/src/Router/__Tests/StellaOps.Router.AspNet.Tests/AspNetRouterRequestDispatcherTests.cs index 439c53d82..13da50741 100644 --- a/src/Router/__Tests/StellaOps.Router.AspNet.Tests/AspNetRouterRequestDispatcherTests.cs +++ b/src/Router/__Tests/StellaOps.Router.AspNet.Tests/AspNetRouterRequestDispatcherTests.cs @@ -281,6 +281,92 @@ public sealed class AspNetRouterRequestDispatcherTests Assert.Contains("\"status\":\"Pending\"", responseBody, StringComparison.Ordinal); } + [Fact] + public async Task DispatchAsync_DoesNotTreatTerminalRouteParameterAsImplicitCatchAll() + { + var builder = WebApplication.CreateBuilder(); + var app = builder.Build(); + + app.MapGet( + "/api/v1/notify/channels/{channelId}", + (string channelId) => Guid.TryParse(channelId, out _) + ? Results.Ok(new { route = "detail", channelId }) + : Results.BadRequest(new { error = "channelId must be a GUID." })); + + app.MapGet( + "/api/v1/notify/channels/{channelId}/health", + (string channelId) => Guid.TryParse(channelId, out _) + ? Results.Ok(new { route = "health", channelId }) + : Results.BadRequest(new { error = "channelId must be a GUID." })); + + var endpointRouteBuilder = (IEndpointRouteBuilder)app; + var endpointDataSource = new StaticEndpointDataSource( + endpointRouteBuilder.DataSources.SelectMany(static dataSource => dataSource.Endpoints).ToArray()); + var dispatcher = new AspNetRouterRequestDispatcher( + app.Services, + endpointDataSource, + new StellaRouterBridgeOptions + { + ServiceName = "notify", + Version = "1.0.0-alpha1", + Region = "local", + AuthorizationTrustMode = GatewayAuthorizationTrustMode.ServiceEnforced + }, + NullLogger.Instance); + + var response = await dispatcher.DispatchAsync(new RequestFrame + { + RequestId = "req-notify-health-1", + Method = "GET", + Path = "/api/v1/notify/channels/e0000001-0000-0000-0000-000000000003/health", + Headers = new Dictionary(), + Payload = ReadOnlyMemory.Empty + }); + + var responseBody = Encoding.UTF8.GetString(response.Payload.ToArray()); + + Assert.Equal(StatusCodes.Status200OK, response.StatusCode); + Assert.Contains("\"route\":\"health\"", responseBody, StringComparison.Ordinal); + Assert.Contains("\"channelId\":\"e0000001-0000-0000-0000-000000000003\"", responseBody, StringComparison.Ordinal); + } + + [Fact] + public async Task DispatchAsync_ExplicitCatchAllRouteStillConsumesRemainingSegments() + { + var builder = WebApplication.CreateBuilder(); + var app = builder.Build(); + app.MapGet("/api/v1/files/{**path}", (string path) => Results.Ok(new { path })); + + var endpointRouteBuilder = (IEndpointRouteBuilder)app; + var endpointDataSource = new StaticEndpointDataSource( + endpointRouteBuilder.DataSources.SelectMany(static dataSource => dataSource.Endpoints).ToArray()); + var dispatcher = new AspNetRouterRequestDispatcher( + app.Services, + endpointDataSource, + new StellaRouterBridgeOptions + { + ServiceName = "files", + Version = "1.0.0-alpha1", + Region = "local", + AuthorizationTrustMode = GatewayAuthorizationTrustMode.ServiceEnforced + }, + NullLogger.Instance); + + var response = await dispatcher.DispatchAsync(new RequestFrame + { + RequestId = "req-files-catchall-1", + Method = "GET", + Path = "/api/v1/files/a/b/c.txt", + Headers = new Dictionary(), + Payload = ReadOnlyMemory.Empty + }); + + var responseBody = Encoding.UTF8.GetString(response.Payload.ToArray()); + + Assert.Equal(StatusCodes.Status200OK, response.StatusCode); + Assert.Contains("\"path\":\"a/b/c.txt\"", responseBody, StringComparison.Ordinal); + } + private static AspNetRouterRequestDispatcher CreateDispatcher(RouteEndpoint endpoint, StellaRouterBridgeOptions options) { var services = new ServiceCollection(); diff --git a/src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaRouterIntegrationHelperTests.cs b/src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaRouterIntegrationHelperTests.cs index 190c840cf..0803bd308 100644 --- a/src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaRouterIntegrationHelperTests.cs +++ b/src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaRouterIntegrationHelperTests.cs @@ -216,11 +216,13 @@ public sealed class StellaRouterIntegrationHelperTests routerOptionsSection: "TimelineIndexer:Router"); await using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; var messaging = provider.GetRequiredService>().Value; var valkey = provider.GetRequiredService>().Value; var transport = provider.GetRequiredService(); Assert.True(result); + Assert.Equal(TimeSpan.FromSeconds(12), options.HeartbeatInterval); Assert.Equal("router:requests:{service}", messaging.RequestQueueTemplate); Assert.Equal("router:responses", messaging.ResponseQueueName); Assert.Equal("timelineindexer", messaging.ConsumerGroup); diff --git a/src/Router/__Tests/StellaOps.Router.AspNet.Tests/TASKS.md b/src/Router/__Tests/StellaOps.Router.AspNet.Tests/TASKS.md index fe9ac72e4..c4a0b9013 100644 --- a/src/Router/__Tests/StellaOps.Router.AspNet.Tests/TASKS.md +++ b/src/Router/__Tests/StellaOps.Router.AspNet.Tests/TASKS.md @@ -8,3 +8,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol | REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaOps.Router.AspNet.Tests.md. | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | | HDR-CASE-01 | DONE | Added the lowercase `content-type` request-frame regression so JSON body dispatch survives Router frame round-trips. | +| LIVE-ROUTER-012-T2 | DONE | 2026-03-09: Added ASP.NET bridge regressions to prevent implicit catch-all matching for terminal route parameters while preserving explicit `{**path}` behavior. |