diff --git a/devops/compose/router-gateway-local.json b/devops/compose/router-gateway-local.json index 07b0271dd..4a332ee0f 100644 --- a/devops/compose/router-gateway-local.json +++ b/devops/compose/router-gateway-local.json @@ -345,12 +345,6 @@ "TranslatesTo": "http://attestor.stella-ops.local/api/v1/witnesses", "PreserveAuthHeaders": true }, - { - "Type": "Microservice", - "Path": "/v1/evidence-packs", - "TranslatesTo": "https://evidencelocker.stella-ops.local/v1/evidence-packs", - "PreserveAuthHeaders": true - }, { "Type": "Microservice", "Path": "/v1/runs", diff --git a/docs-archived/implplan/SPRINT_20260308_001_Router_audit_bundle_frontdoor_route_deduplication.md b/docs-archived/implplan/SPRINT_20260308_001_Router_audit_bundle_frontdoor_route_deduplication.md new file mode 100644 index 000000000..7cda397a5 --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260308_001_Router_audit_bundle_frontdoor_route_deduplication.md @@ -0,0 +1,76 @@ +# Sprint 20260308-001 - Router Audit Bundle Frontdoor Route Deduplication + +## Topic & Scope +- Repair the live `stella-ops.local` audit-bundle frontdoor so `/v1/audit-bundles` resolves to ExportCenter instead of a stale shadow route. +- Fail gateway startup fast when exact duplicate non-regex route paths are configured, because first-match-wins shadowing is otherwise silent. +- Keep the work scoped to Router route-table/config validation plus targeted Router tests and live Playwright retest evidence from the Web surface. +- Working directory: `src/Router`. +- Expected evidence: targeted Router tests, live Playwright capture for `/triage/audit-bundles/new`, and sprint execution log updates. + +## Dependencies & Concurrency +- Upstream live evidence came from `docs/implplan/SPRINT_20260306_003_FE_playwright_setup_reset_iteration_loop.md`; that sprint stays QA-first and records Web-side triage only. +- Safe parallelism: avoid unrelated Router transport or messaging files; scope is limited to `StellaOps.Gateway.WebService`, its tests, and this sprint file. +- Concurrency note: no active Router sprint for audit-bundle routing was present when this fix started, but other agents may still be modifying unrelated Web/search slices. + +## Documentation Prerequisites +- `docs/code-of-conduct/CODE_OF_CONDUCT.md` +- `docs/07_HIGH_LEVEL_ARCHITECTURE.md` +- `docs/modules/platform/architecture-overview.md` +- `docs/modules/router/architecture.md` +- `src/Router/AGENTS.md` +- `src/Router/StellaOps.Gateway.WebService/AGENTS.md` + +## Delivery Tracker + +### ROUTER-AUDIT-001 - Remove stale duplicate audit-bundle frontdoor mapping +Status: DONE +Dependency: none +Owners: Developer +Task description: +- Remove the stale `/v1/audit-bundles` route entry that currently points at EvidenceLocker so the gateway forwards audit-bundle traffic to ExportCenter. +- Clean the other exact duplicate reverse-proxy entries discovered during triage so startup validation can become strict without breaking the committed route table. + +Completion criteria: +- [x] `src/Router/StellaOps.Gateway.WebService/appsettings.json` contains no exact duplicate non-regex paths. +- [x] `/v1/audit-bundles` resolves to ExportCenter in the live stack after redeploy. + +### ROUTER-AUDIT-002 - Fail fast on duplicate exact route paths +Status: DONE +Dependency: ROUTER-AUDIT-001 +Owners: Developer, Test Automation +Task description: +- Harden `GatewayOptionsValidator` so exact duplicate non-regex route paths are rejected at startup with a deterministic error message. +- Add focused Router coverage proving the validator catches the duplicate-path shadowing class that broke audit bundles. + +Completion criteria: +- [x] Targeted Router tests cover duplicate exact route rejection. +- [x] Startup validation fails before first request dispatch when duplicate exact paths are present. + +### ROUTER-AUDIT-003 - Retest live audit-bundle creation through the gateway +Status: DONE +Dependency: ROUTER-AUDIT-002 +Owners: QA, Developer +Task description: +- Rebuild and redeploy the router/gateway slice, then rerun the authenticated Playwright audit-bundle create flow against `https://stella-ops.local`. +- Confirm the frontdoor returns a queued/completed job instead of the previous empty `400`. + +Completion criteria: +- [x] Live Playwright evidence shows the create action no longer fails immediately. +- [x] The sprint log records the exact commands and outcomes. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-08 | Sprint created after Web Playwright triage proved the browser was sending valid DPoP-bound `/v1/audit-bundles` requests while the gateway route table still contained duplicate exact paths and first-match shadowing. | Developer | +| 2026-03-08 | Removed the stale EvidenceLocker `/v1/audit-bundles` frontdoor entry, cleaned the remaining exact duplicates in `appsettings.json`, tightened `GatewayOptionsValidator`, and verified Router coverage with `dotnet test src/Router/__Tests/StellaOps.Gateway.WebService.Tests/StellaOps.Gateway.WebService.Tests.csproj -v normal` plus `dotnet test src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaOps.Router.AspNet.Tests.csproj -v normal` (`44/44` passing in the ASP.NET dispatcher suite). | Developer | +| 2026-03-08 | Rebuilt and redeployed the router gateway, then replayed the authenticated browser flow on `https://stella-ops.local/triage/audit-bundles/new?artifactId=asset-review-prod`. The frontdoor POST to `/v1/audit-bundles` returned `202 Accepted`; the remaining service-local `400` was fixed in downstream sprint `SPRINT_20260308_002_ExportCenter_audit_bundle_http_body_binding.md`, after which the same browser retest completed successfully. | Developer | + +## Decisions & Risks +- Decision: treat the remaining audit-bundle failure as a Router frontdoor defect, not a Web defect, because live request capture confirmed `Authorization: DPoP ...` plus a valid `DPoP` proof on the browser POST. +- Decision: remove exact duplicate route entries from committed gateway config before enabling duplicate-path validation, so startup hardening does not strand the current route table. +- Decision: keep the downstream ExportCenter body-binding fix in its own sprint so the Router working-directory contract stays honest; this sprint closes once the gateway frontdoor is clean and the authenticated browser retest proves `/v1/audit-bundles` is no longer shadowed. +- Risk: `src/Router/StellaOps.Gateway.WebService/AGENTS.md` references `docs/modules/gateway/architecture.md` and `docs/modules/gateway/openapi.md`, but those files are currently absent. +- Mitigation: continue with the Router architecture dossier plus concrete source/runtime evidence for this bug, and record the missing-doc gap here for later documentation cleanup. + +## Next Checkpoints +- Archived after the Router frontdoor fix, redeploy, and live browser retest passed. diff --git a/docs/features/checked/web/audit-bundle-create-modal.md b/docs/features/checked/web/audit-bundle-create-modal.md index 1a31c0982..9b5c0a64c 100644 --- a/docs/features/checked/web/audit-bundle-create-modal.md +++ b/docs/features/checked/web/audit-bundle-create-modal.md @@ -54,3 +54,15 @@ Audit bundle creation flow is implemented with deterministic wizard progression - Status: VERIFIED (strict Tier 2 UI replay) - Tier 2 evidence: docs/qa/feature-checks/runs/web/audit-bundle-create-modal/run-006/tier2-ui-check.json. +## Recheck (run-007) +- Date (UTC): 2026-03-08T12:23:30Z +- Status: VERIFIED (live gateway and service retest) +- Tier 1 evidence: + - `dotnet test src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/StellaOps.ExportCenter.Tests.csproj -v normal` passed `930/930`. + - `dotnet test src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaOps.Router.AspNet.Tests.csproj -v normal` passed `44/44`. +- Tier 2 evidence: + - `node src/Web/StellaOps.Web/output/playwright/repro-audit-bundle-live.cjs` + - Authenticated DPoP browser POST to `https://stella-ops.local/v1/audit-bundles` returned `202 Accepted`. + - Follow-up GET for `/v1/audit-bundles/bndl-c1045e529d8c48998257fbe6850948fb` returned `200` with `status: Completed`. + - The create page rendered the completed bundle hash and enabled the download action. + diff --git a/src/Router/StellaOps.Gateway.WebService/Configuration/GatewayOptionsValidator.cs b/src/Router/StellaOps.Gateway.WebService/Configuration/GatewayOptionsValidator.cs index 6d6a3a530..76db30ca5 100644 --- a/src/Router/StellaOps.Gateway.WebService/Configuration/GatewayOptionsValidator.cs +++ b/src/Router/StellaOps.Gateway.WebService/Configuration/GatewayOptionsValidator.cs @@ -45,6 +45,8 @@ public static class GatewayOptionsValidator private static void ValidateRoutes(List routes) { + var exactPathIndices = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < routes.Count; i++) { var route = routes[i]; @@ -66,6 +68,17 @@ public static class GatewayOptionsValidator throw new InvalidOperationException($"{prefix}: Path is not a valid regex pattern: {ex.Message}"); } } + else + { + var normalizedPath = NormalizePath(route.Path); + if (exactPathIndices.TryGetValue(normalizedPath, out var existingIndex)) + { + throw new InvalidOperationException( + $"{prefix}: Duplicate route path '{normalizedPath}' already defined by Route[{existingIndex}]."); + } + + exactPathIndices[normalizedPath] = i; + } switch (route.Type) { @@ -124,4 +137,16 @@ public static class GatewayOptionsValidator } } } + + private static string NormalizePath(string value) + { + var normalized = value.Trim(); + if (!normalized.StartsWith('/')) + { + normalized = "/" + normalized; + } + + normalized = normalized.TrimEnd('/'); + return string.IsNullOrEmpty(normalized) ? "/" : normalized; + } } diff --git a/src/Router/StellaOps.Gateway.WebService/Dockerfile b/src/Router/StellaOps.Gateway.WebService/Dockerfile index 305fd2f9b..f2423a234 100644 --- a/src/Router/StellaOps.Gateway.WebService/Dockerfile +++ b/src/Router/StellaOps.Gateway.WebService/Dockerfile @@ -1,9 +1,9 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base WORKDIR /app EXPOSE 8080 EXPOSE 8443 -FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY . . RUN dotnet publish src/Router/StellaOps.Gateway.WebService/StellaOps.Gateway.WebService.csproj -c Release -o /app/publish diff --git a/src/Router/StellaOps.Gateway.WebService/Program.cs b/src/Router/StellaOps.Gateway.WebService/Program.cs index 668090ed7..72651a2fb 100644 --- a/src/Router/StellaOps.Gateway.WebService/Program.cs +++ b/src/Router/StellaOps.Gateway.WebService/Program.cs @@ -24,7 +24,10 @@ using StellaOps.Router.Gateway.Middleware; using StellaOps.Router.Gateway.OpenApi; using StellaOps.Router.Gateway.RateLimit; using StellaOps.Router.Gateway.Routing; +using System.Net; +using System.Net.Sockets; using System.Runtime.Loader; +using System.Security.Cryptography.X509Certificates; var builder = WebApplication.CreateBuilder(args); @@ -157,7 +160,14 @@ var routerEnabled = builder.Services.AddRouterMicroservice( builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration); -builder.TryAddStellaOpsLocalBinding("router"); +if (ShouldApplyStellaOpsLocalBinding()) +{ + builder.TryAddStellaOpsLocalBinding("router"); +} +else +{ + ConfigureContainerFrontdoorBindings(builder); +} var app = builder.Build(); app.LogStellaOpsLocalHostname("router"); @@ -378,6 +388,116 @@ static void ConfigureGatewayOptionsMapping(WebApplicationBuilder builder, Gatewa } +static bool ShouldApplyStellaOpsLocalBinding() +{ + var runningInContainer = string.Equals( + Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), + "true", + StringComparison.OrdinalIgnoreCase); + + if (!runningInContainer) + { + return true; + } + + // Compose-published container runs already define the frontdoor port contract. + // Respect explicit container port settings instead of replacing them with 80/443. + var explicitUrls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS"); + var explicitHttpPorts = Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS"); + var explicitHttpsPorts = Environment.GetEnvironmentVariable("ASPNETCORE_HTTPS_PORTS"); + + return string.IsNullOrWhiteSpace(explicitUrls) + && string.IsNullOrWhiteSpace(explicitHttpPorts) + && string.IsNullOrWhiteSpace(explicitHttpsPorts); +} + +static void ConfigureContainerFrontdoorBindings(WebApplicationBuilder builder) +{ + var currentUrls = builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey) ?? string.Empty; + + builder.WebHost.ConfigureKestrel((context, kestrel) => + { + var defaultCert = LoadDefaultCertificate(context.Configuration); + + foreach (var rawUrl in currentUrls.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + 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)) + { + kestrel.Listen(address, uri.Port, listenOptions => + { + if (defaultCert is not null) + { + listenOptions.UseHttps(defaultCert); + } + else + { + listenOptions.UseHttps(); + } + }); + continue; + } + + kestrel.Listen(address, uri.Port); + } + + if (defaultCert is not null && IsPortAvailable(443, IPAddress.Any)) + { + kestrel.ListenAnyIP(443, listenOptions => listenOptions.UseHttps(defaultCert)); + } + }); +} + +static X509Certificate2? LoadDefaultCertificate(IConfiguration configuration) +{ + var certPath = configuration["Kestrel:Certificates:Default:Path"]; + var certPass = configuration["Kestrel:Certificates:Default:Password"]; + if (string.IsNullOrWhiteSpace(certPath) || !File.Exists(certPath)) + { + return null; + } + + return X509CertificateLoader.LoadPkcs12FromFile(certPath, certPass); +} + +static IPAddress ResolveListenAddress(string host) +{ + if (string.IsNullOrWhiteSpace(host) || + string.Equals(host, "*", StringComparison.OrdinalIgnoreCase) || + string.Equals(host, "+", StringComparison.OrdinalIgnoreCase) || + string.Equals(host, "0.0.0.0", StringComparison.OrdinalIgnoreCase)) + { + return IPAddress.Any; + } + + if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return IPAddress.Loopback; + } + + return IPAddress.Parse(host); +} + +static bool IsPortAvailable(int port, IPAddress address) +{ + try + { + using var listener = new TcpListener(address, port); + listener.Start(); + listener.Stop(); + return true; + } + catch + { + return false; + } +} + static string? ResolveAuthorityClaimsUrl(GatewayAuthorityOptions authorityOptions) { if (!string.IsNullOrWhiteSpace(authorityOptions.ClaimsOverridesUrl)) @@ -418,5 +538,3 @@ static string? ResolveAuthorityClaimsUrl(GatewayAuthorityOptions authorityOption return builder.Uri.GetLeftPart(UriPartial.Authority).TrimEnd('/'); } - - diff --git a/src/Router/StellaOps.Gateway.WebService/appsettings.json b/src/Router/StellaOps.Gateway.WebService/appsettings.json index 1cf1422ba..0fb1a6245 100644 --- a/src/Router/StellaOps.Gateway.WebService/appsettings.json +++ b/src/Router/StellaOps.Gateway.WebService/appsettings.json @@ -72,10 +72,9 @@ { "Type": "ReverseProxy", "Path": "/api/v1/notifier", "TranslatesTo": "http://notifier.stella-ops.local/api/v1/notifier" }, { "Type": "ReverseProxy", "Path": "/api/v1/concelier", "TranslatesTo": "http://concelier.stella-ops.local/api/v1/concelier" }, { "Type": "ReverseProxy", "Path": "/api/cvss", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/cvss", "PreserveAuthHeaders": true }, - { "Type": "ReverseProxy", "Path": "/v1/evidence-packs", "TranslatesTo": "http://evidencelocker.stella-ops.local/v1/evidence-packs" }, + { "Type": "ReverseProxy", "Path": "/v1/evidence-packs", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs" }, { "Type": "ReverseProxy", "Path": "/v1/runs", "TranslatesTo": "http://orchestrator.stella-ops.local/v1/runs" }, { "Type": "ReverseProxy", "Path": "/v1/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai" }, - { "Type": "ReverseProxy", "Path": "/v1/audit-bundles", "TranslatesTo": "http://evidencelocker.stella-ops.local/v1/audit-bundles" }, { "Type": "ReverseProxy", "Path": "/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local" }, { "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 }, @@ -88,13 +87,11 @@ { "Type": "ReverseProxy", "Path": "/api/v1/findings", "TranslatesTo": "http://findings.stella-ops.local/api/v1/findings", "PreserveAuthHeaders": true }, { "Type": "ReverseProxy", "Path": "/api/v1/integrations", "TranslatesTo": "http://integrations.stella-ops.local/api/v1/integrations", "PreserveAuthHeaders": true }, { "Type": "ReverseProxy", "Path": "/api/v1/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy" }, - { "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy", "PreserveAuthHeaders": true }, { "Type": "ReverseProxy", "Path": "/api/v1/reachability", "TranslatesTo": "http://reachgraph.stella-ops.local/api/v1/reachability" }, { "Type": "ReverseProxy", "Path": "/api/v1/attestor", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestor" }, { "Type": "ReverseProxy", "Path": "/api/v1/attestations", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestations" }, { "Type": "ReverseProxy", "Path": "/api/v1/sbom", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sbom" }, { "Type": "ReverseProxy", "Path": "/api/v1/signals", "TranslatesTo": "http://signals.stella-ops.local/api/v1/signals" }, - { "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex" }, { "Type": "ReverseProxy", "Path": "/api/v1/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/orchestrator" }, { "Type": "ReverseProxy", "Path": "/api/v1/authority/quotas", "TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas", "PreserveAuthHeaders": true }, { "Type": "ReverseProxy", "Path": "/api/v1/authority", "TranslatesTo": "http://authority.stella-ops.local/api/v1/authority", "PreserveAuthHeaders": true }, @@ -105,7 +102,6 @@ { "Type": "ReverseProxy", "Path": "/api/v1/search", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search" }, { "Type": "ReverseProxy", "Path": "/api/v1/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai" }, { "Type": "ReverseProxy", "Path": "/api/v1/advisory", "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory" }, - { "Type": "ReverseProxy", "Path": "/api/v1/concelier", "TranslatesTo": "http://concelier.stella-ops.local/api/v1/concelier" }, { "Type": "ReverseProxy", "Path": "/api/v1/vulnerabilities", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/vulnerabilities" }, { "Type": "ReverseProxy", "Path": "/api/v1/watchlist", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/watchlist" }, { "Type": "ReverseProxy", "Path": "/api/v1/resolve", "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/resolve" }, diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayOptionsValidatorTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayOptionsValidatorTests.cs index aeb2ed5ca..add60f75b 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayOptionsValidatorTests.cs +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayOptionsValidatorTests.cs @@ -313,6 +313,30 @@ public sealed class GatewayOptionsValidatorTests Assert.Contains("regex", exception.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void Validate_DuplicateExactRoutePath_Throws() + { + var options = CreateValidOptions(); + options.Routes.Add(new StellaOpsRoute + { + Type = StellaOpsRouteType.ReverseProxy, + Path = "/v1/audit-bundles", + TranslatesTo = "http://exportcenter.stella-ops.local/v1/audit-bundles" + }); + options.Routes.Add(new StellaOpsRoute + { + Type = StellaOpsRouteType.ReverseProxy, + Path = "/v1/audit-bundles", + TranslatesTo = "http://evidencelocker.stella-ops.local/v1/audit-bundles" + }); + + var exception = Assert.Throws(() => + GatewayOptionsValidator.Validate(options)); + + Assert.Contains("Duplicate route path", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("/v1/audit-bundles", exception.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void Validate_ValidRegex_DoesNotThrow() { diff --git a/src/Router/__Tests/StellaOps.Router.AspNet.Tests/AspNetRouterRequestDispatcherTests.cs b/src/Router/__Tests/StellaOps.Router.AspNet.Tests/AspNetRouterRequestDispatcherTests.cs index 6477e340e..439c53d82 100644 --- a/src/Router/__Tests/StellaOps.Router.AspNet.Tests/AspNetRouterRequestDispatcherTests.cs +++ b/src/Router/__Tests/StellaOps.Router.AspNet.Tests/AspNetRouterRequestDispatcherTests.cs @@ -1,9 +1,11 @@ using System.Text; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Matching; using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Primitives; @@ -198,6 +200,87 @@ public sealed class AspNetRouterRequestDispatcherTests Assert.Contains("\"timeWindow\":\"24h\"", responseBody, StringComparison.Ordinal); } + [Fact] + public async Task DispatchAsync_BindsAuditBundleCreateRequest_WithAcceptedUnionResult() + { + var builder = WebApplication.CreateBuilder(); + var app = builder.Build(); + app.MapPost( + "/v1/audit-bundles", + Results, BadRequest> ( + [FromBody] AuditBundleCreateRequest body) => + { + if (string.IsNullOrWhiteSpace(body.Subject.Name)) + { + return TypedResults.BadRequest( + new AuditBundleErrorEnvelope( + new AuditBundleErrorDetail("INVALID_REQUEST", "Subject name is required"))); + } + + return TypedResults.Accepted( + $"/v1/audit-bundles/{body.Subject.Name}", + new AuditBundleAcceptedResponse( + $"bndl-{body.Subject.Name}", + "Pending", + $"/v1/audit-bundles/{body.Subject.Name}", + 30)); + }); + + 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 = "exportcenter", + Version = "1.0.0-alpha1", + Region = "local", + AuthorizationTrustMode = GatewayAuthorizationTrustMode.ServiceEnforced + }, + NullLogger.Instance); + + var transportFrame = FrameConverter.ToFrame(new RequestFrame + { + RequestId = "req-audit-1", + Method = "POST", + Path = "/v1/audit-bundles", + Headers = new Dictionary + { + ["content-type"] = "application/json" + }, + Payload = Encoding.UTF8.GetBytes(""" + { + "subject": { + "type": "IMAGE", + "name": "asset-review-prod", + "digest": { + "sha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + }, + "includeContent": { + "vulnReports": true, + "sbom": true, + "vexDecisions": true, + "policyEvaluations": true, + "attestations": true + } + } + """) + }); + + var roundTrippedRequest = FrameConverter.ToRequestFrame(transportFrame); + Assert.NotNull(roundTrippedRequest); + + var response = await dispatcher.DispatchAsync(roundTrippedRequest!); + var responseBody = Encoding.UTF8.GetString(response.Payload.ToArray()); + + Assert.Equal(StatusCodes.Status202Accepted, response.StatusCode); + Assert.Contains("\"bundleId\":\"bndl-asset-review-prod\"", responseBody, StringComparison.Ordinal); + Assert.Contains("\"status\":\"Pending\"", responseBody, StringComparison.Ordinal); + } + private static AspNetRouterRequestDispatcher CreateDispatcher(RouteEndpoint endpoint, StellaRouterBridgeOptions options) { var services = new ServiceCollection(); @@ -227,4 +310,27 @@ public sealed class AspNetRouterRequestDispatcherTests } private sealed record PreferencesBody(string[] Regions, string[] Environments, string TimeWindow); + private sealed record AuditBundleCreateRequest( + AuditBundleSubjectRef Subject, + AuditBundleTimeWindow? TimeWindow, + AuditBundleContentSelection IncludeContent, + string? CallbackUrl = null); + private sealed record AuditBundleSubjectRef( + string Type, + string Name, + IReadOnlyDictionary Digest); + private sealed record AuditBundleTimeWindow(DateTimeOffset? From, DateTimeOffset? To); + private sealed record AuditBundleContentSelection( + bool VulnReports = true, + bool Sbom = true, + bool VexDecisions = true, + bool PolicyEvaluations = true, + bool Attestations = true); + private sealed record AuditBundleAcceptedResponse( + string BundleId, + string Status, + string StatusUrl, + int? EstimatedCompletionSeconds); + private sealed record AuditBundleErrorEnvelope(AuditBundleErrorDetail Error); + private sealed record AuditBundleErrorDetail(string Code, string Message); }