fix(router): ship audit bundle frontdoor cutover
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ public static class GatewayOptionsValidator
|
||||
|
||||
private static void ValidateRoutes(List<StellaOpsRoute> routes)
|
||||
{
|
||||
var exactPathIndices = new Dictionary<string, int>(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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('/');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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<InvalidOperationException>(() =>
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -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<Accepted<AuditBundleAcceptedResponse>, BadRequest<AuditBundleErrorEnvelope>> (
|
||||
[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<AspNetRouterRequestDispatcher>.Instance);
|
||||
|
||||
var transportFrame = FrameConverter.ToFrame(new RequestFrame
|
||||
{
|
||||
RequestId = "req-audit-1",
|
||||
Method = "POST",
|
||||
Path = "/v1/audit-bundles",
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["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<string, string> 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user