From 885292811544c61b4d02077365fdfef018aefc33 Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 8 Mar 2026 14:29:33 +0200 Subject: [PATCH] fix(exportcenter): ship audit bundle http binding --- ...rtCenter_audit_bundle_http_body_binding.md | 73 +++++++++ .../Models/ExportModels.cs | 129 +++++++++++++--- .../AuditBundleHttpEndpointTests.cs | 79 ++++++++++ .../AuditBundleProgramHttpIntegrationTests.cs | 143 ++++++++++++++++++ .../AuditBundleRouterDispatchTests.cs | 111 ++++++++++++++ .../AuditBundle/AuditBundleEndpoints.cs | 22 ++- 6 files changed, 537 insertions(+), 20 deletions(-) create mode 100644 docs-archived/implplan/SPRINT_20260308_002_ExportCenter_audit_bundle_http_body_binding.md create mode 100644 src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/AuditBundle/AuditBundleHttpEndpointTests.cs create mode 100644 src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/AuditBundle/AuditBundleProgramHttpIntegrationTests.cs create mode 100644 src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/AuditBundle/AuditBundleRouterDispatchTests.cs diff --git a/docs-archived/implplan/SPRINT_20260308_002_ExportCenter_audit_bundle_http_body_binding.md b/docs-archived/implplan/SPRINT_20260308_002_ExportCenter_audit_bundle_http_body_binding.md new file mode 100644 index 000000000..f7ba426d5 --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260308_002_ExportCenter_audit_bundle_http_body_binding.md @@ -0,0 +1,73 @@ +# Sprint 20260308-002 - ExportCenter Audit Bundle HTTP Body Binding + +## Topic & Scope +- Repair the live `POST /v1/audit-bundles` endpoint in ExportCenter so valid JSON bodies bind and queue audit-bundle jobs instead of returning an empty `400`. +- Cover the real failure mode with an HTTP-level regression test; the existing router-dispatch test proves transport dispatch but does not exercise ASP.NET Core body binding. +- Keep the work scoped to the ExportCenter audit-bundle API models, endpoint tests, and this sprint file. +- Working directory: `src/ExportCenter/StellaOps.ExportCenter`. +- Expected evidence: focused ExportCenter tests, direct live `curl` to the ExportCenter container, gateway/browser retest linkage, and sprint execution log updates. + +## Dependencies & Concurrency +- Downstream of [SPRINT_20260308_001_Router_audit_bundle_frontdoor_route_deduplication.md](/C:/dev/New%20folder/git.stella-ops.org/docs/implplan/SPRINT_20260308_001_Router_audit_bundle_frontdoor_route_deduplication.md): Router now dispatches `/v1/audit-bundles` to ExportCenter correctly, exposing the service-local `400`. +- Safe parallelism: avoid unrelated ExportCenter export-profile or lineage work; scope is limited to the audit-bundle request/response path and targeted tests. + +## Documentation Prerequisites +- `docs/code-of-conduct/CODE_OF_CONDUCT.md` +- `docs/code-of-conduct/TESTING_PRACTICES.md` +- `docs/README.md` +- `docs/07_HIGH_LEVEL_ARCHITECTURE.md` +- `docs/modules/platform/architecture-overview.md` + +## Delivery Tracker + +### EXPORT-AUDIT-001 - Make audit bundle request DTOs bind correctly over HTTP +Status: DONE +Dependency: none +Owners: Developer +Task description: +- Replace the fragile audit-bundle request DTO shapes with body-binding-safe models while preserving the committed JSON contract used by the Angular client. +- Keep existing handler/test construction ergonomics so unit tests and service logic remain readable. + +Completion criteria: +- [x] `POST /v1/audit-bundles` accepts the current UI payload shape in automated HTTP coverage and in the authenticated browser path. +- [x] The audit-bundle JSON contract remains camelCase-compatible for existing callers. + +### EXPORT-AUDIT-002 - Add an HTTP-level regression test for audit bundle creation +Status: DONE +Dependency: EXPORT-AUDIT-001 +Owners: Developer, Test Automation +Task description: +- Add a focused ExportCenter test that boots the mapped audit-bundle endpoint on a local ephemeral HTTP listener and posts a real JSON body. +- Keep the existing router-dispatch test, but document that it is transport-focused and does not replace direct HTTP coverage. + +Completion criteria: +- [x] A focused ExportCenter test fails before the fix and passes after it. +- [x] Test assertions verify `202 Accepted` and a queued audit-bundle response payload. + +### EXPORT-AUDIT-003 - Verify the fixed service directly and through the gateway +Status: DONE +Dependency: EXPORT-AUDIT-002 +Owners: Developer, QA +Task description: +- Rebuild and redeploy the ExportCenter service, then verify `POST /v1/audit-bundles` directly against the published container address and through the authenticated browser path on `https://stella-ops.local/triage/audit-bundles/new`. +- Record the exact commands and outcomes so the Router sprint can consume the same evidence for its final retest gate. + +Completion criteria: +- [x] Direct live service behavior is covered by the full-program HTTP regression plus live in-network transport checks after republishing the container. +- [x] The authenticated browser retest no longer fails immediately on audit-bundle creation. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-08 | Sprint created after direct `curl` to `http://127.1.0.40/v1/audit-bundles` reproduced the same empty `400` seen through the gateway, proving the remaining defect is service-local body binding rather than Router dispatch. | Developer | +| 2026-03-08 | Replaced the fragile audit-bundle DTOs with body-binding-safe record classes, switched the live endpoint to explicit `ReadFromJsonAsync` handling, and added focused HTTP coverage (`AuditBundleHttpEndpointTests`, `AuditBundleProgramHttpIntegrationTests`) while keeping the router-dispatch regression. `dotnet test src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/StellaOps.ExportCenter.Tests.csproj -v normal` passed `930/930`; `dotnet build src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/StellaOps.ExportCenter.WebService.csproj -c Release` passed. | Developer | +| 2026-03-08 | Republished the ExportCenter WebService into the local compose container, reran the authenticated browser script `node src/Web/StellaOps.Web/output/playwright/repro-audit-bundle-live.cjs`, and verified the live POST to `https://stella-ops.local/v1/audit-bundles` returned `202 Accepted`. The follow-up status poll reached `Completed` and the UI enabled the download action for bundle `bndl-c1045e529d8c48998257fbe6850948fb`. | Developer | + +## Decisions & Risks +- Decision: keep the Router sprint honest and fix the downstream ExportCenter defect in a separate sprint because the Router working-directory contract is limited to `src/Router`. +- Decision: keep host-published unauthenticated `curl` out of the final pass criteria after the republished service resumed enforcing auth on `127.1.0.40`; the authoritative live proof is the authenticated browser flow plus the full-program HTTP integration regression. +- Risk: changing shared audit-bundle DTOs in `StellaOps.ExportCenter.Client` could affect existing tests or callers that construct these models directly. +- Mitigation: preserve the JSON property names and add compatibility constructors where current tests rely on positional construction. + +## Next Checkpoints +- Archived after the HTTP regressions, republished container, and live browser retest passed. diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Client/Models/ExportModels.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Client/Models/ExportModels.cs index 88be60f52..bffb4cd78 100644 --- a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Client/Models/ExportModels.cs +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Client/Models/ExportModels.cs @@ -181,17 +181,54 @@ public sealed record BundleActorRefDto( /// /// Subject reference for audit bundle. /// -public sealed record BundleSubjectRefDto( - [property: JsonPropertyName("type")] string Type, - [property: JsonPropertyName("name")] string Name, - [property: JsonPropertyName("digest")] IReadOnlyDictionary Digest); +public sealed record BundleSubjectRefDto +{ + [JsonConstructor] + public BundleSubjectRefDto() + { + } + + public BundleSubjectRefDto(string type, string name, IReadOnlyDictionary? digest) + { + Type = type; + Name = name; + Digest = digest is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(digest, StringComparer.OrdinalIgnoreCase); + } + + [JsonPropertyName("type")] + public string Type { get; init; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("digest")] + public Dictionary Digest { get; init; } = new(StringComparer.OrdinalIgnoreCase); +} /// /// Time window filter for included content. /// -public sealed record BundleTimeWindowDto( - [property: JsonPropertyName("from")] DateTimeOffset? From, - [property: JsonPropertyName("to")] DateTimeOffset? To); +public sealed record BundleTimeWindowDto +{ + [JsonConstructor] + public BundleTimeWindowDto() + { + } + + public BundleTimeWindowDto(DateTimeOffset? from, DateTimeOffset? to) + { + From = from; + To = to; + } + + [JsonPropertyName("from")] + public DateTimeOffset? From { get; init; } + + [JsonPropertyName("to")] + public DateTimeOffset? To { get; init; } +} /// /// Artifact entry within an audit bundle. @@ -232,21 +269,77 @@ public sealed record BundleIntegrityDto( /// /// Request to create an audit bundle. /// -public sealed record CreateAuditBundleRequest( - [property: JsonPropertyName("subject")] BundleSubjectRefDto Subject, - [property: JsonPropertyName("timeWindow")] BundleTimeWindowDto? TimeWindow, - [property: JsonPropertyName("includeContent")] AuditBundleContentSelection IncludeContent, - [property: JsonPropertyName("callbackUrl")] string? CallbackUrl = null); +public sealed record CreateAuditBundleRequest +{ + [JsonConstructor] + public CreateAuditBundleRequest() + { + } + + public CreateAuditBundleRequest( + BundleSubjectRefDto? Subject, + BundleTimeWindowDto? TimeWindow, + AuditBundleContentSelection? IncludeContent, + string? CallbackUrl = null) + { + this.Subject = Subject; + this.TimeWindow = TimeWindow; + this.IncludeContent = IncludeContent ?? new AuditBundleContentSelection(); + this.CallbackUrl = CallbackUrl; + } + + [JsonPropertyName("subject")] + public BundleSubjectRefDto? Subject { get; init; } + + [JsonPropertyName("timeWindow")] + public BundleTimeWindowDto? TimeWindow { get; init; } + + [JsonPropertyName("includeContent")] + public AuditBundleContentSelection IncludeContent { get; init; } = new(); + + [JsonPropertyName("callbackUrl")] + public string? CallbackUrl { get; init; } +} /// /// Content selection for audit bundle creation. /// -public sealed record AuditBundleContentSelection( - [property: JsonPropertyName("vulnReports")] bool VulnReports = true, - [property: JsonPropertyName("sbom")] bool Sbom = true, - [property: JsonPropertyName("vexDecisions")] bool VexDecisions = true, - [property: JsonPropertyName("policyEvaluations")] bool PolicyEvaluations = true, - [property: JsonPropertyName("attestations")] bool Attestations = true); +public sealed record AuditBundleContentSelection +{ + [JsonConstructor] + public AuditBundleContentSelection() + { + } + + public AuditBundleContentSelection( + bool VulnReports = true, + bool Sbom = true, + bool VexDecisions = true, + bool PolicyEvaluations = true, + bool Attestations = true) + { + this.VulnReports = VulnReports; + this.Sbom = Sbom; + this.VexDecisions = VexDecisions; + this.PolicyEvaluations = PolicyEvaluations; + this.Attestations = Attestations; + } + + [JsonPropertyName("vulnReports")] + public bool VulnReports { get; init; } = true; + + [JsonPropertyName("sbom")] + public bool Sbom { get; init; } = true; + + [JsonPropertyName("vexDecisions")] + public bool VexDecisions { get; init; } = true; + + [JsonPropertyName("policyEvaluations")] + public bool PolicyEvaluations { get; init; } = true; + + [JsonPropertyName("attestations")] + public bool Attestations { get; init; } = true; +} /// /// Response from creating an audit bundle. diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/AuditBundle/AuditBundleHttpEndpointTests.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/AuditBundle/AuditBundleHttpEndpointTests.cs new file mode 100644 index 000000000..88341607c --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/AuditBundle/AuditBundleHttpEndpointTests.cs @@ -0,0 +1,79 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using StellaOps.Auth.ServerIntegration; +using StellaOps.ExportCenter.WebService.AuditBundle; + +namespace StellaOps.ExportCenter.Tests.AuditBundle; + +public sealed class AuditBundleHttpEndpointTests +{ + [Fact] + [Trait("Intent", "Operational")] + [Trait("Category", "Integration")] + public async Task HttpPost_CreateAuditBundle_ReturnsAccepted() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddLogging(); + builder.Services.AddAuthorizationBuilder() + .AddPolicy( + StellaOpsResourceServerPolicies.ExportOperator, + static policy => policy.RequireAssertion(static _ => true)); + builder.Services.AddAuditBundleJobHandler(); + + await using var app = builder.Build(); + app.MapAuditBundleEndpoints(); + + await app.StartAsync(); + try + { + var baseAddress = new Uri(app.Urls.Single(), UriKind.Absolute); + + using var client = new HttpClient + { + BaseAddress = baseAddress + }; + + var response = await client.PostAsJsonAsync( + "/v1/audit-bundles", + new + { + subject = new + { + type = "IMAGE", + name = "asset-review-prod", + digest = new Dictionary + { + ["sha256"] = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + }, + includeContent = new + { + vulnReports = true, + sbom = true, + vexDecisions = true, + policyEvaluations = true, + attestations = true + } + }); + + var payload = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + + using var document = JsonDocument.Parse(payload); + Assert.True(document.RootElement.TryGetProperty("bundleId", out var bundleId)); + Assert.StartsWith("bndl-", bundleId.GetString(), StringComparison.Ordinal); + Assert.Equal("Pending", document.RootElement.GetProperty("status").GetString()); + } + finally + { + await app.StopAsync(); + } + } +} diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/AuditBundle/AuditBundleProgramHttpIntegrationTests.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/AuditBundle/AuditBundleProgramHttpIntegrationTests.cs new file mode 100644 index 000000000..83692ed4f --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/AuditBundle/AuditBundleProgramHttpIntegrationTests.cs @@ -0,0 +1,143 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using StellaOps.Auth.Abstractions; +using StellaOps.Auth.ServerIntegration; +using StellaOps.ExportCenter.WebService.ExceptionReport; +using StellaOps.Policy.Exceptions.Repositories; + +namespace StellaOps.ExportCenter.Tests.AuditBundle; + +public sealed class AuditBundleProgramHttpIntegrationTests +{ + [Fact] + [Trait("Intent", "Operational")] + [Trait("Category", "Integration")] + public async Task HttpPost_CreateAuditBundle_ThroughProgramPipeline_ReturnsAccepted() + { + await using var factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.UseEnvironment("Development"); + builder.ConfigureAppConfiguration((_, configurationBuilder) => + { + configurationBuilder.AddInMemoryCollection(new Dictionary + { + ["Router:Enabled"] = "false", + ["Export:AllowInMemoryRepositories"] = "true" + }); + }); + + builder.ConfigureTestServices(services => + { + services.RemoveAll>(); + services.RemoveAll>(); + services.RemoveAll>(); + services.RemoveAll>(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); + + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = TestExportAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestExportAuthHandler.SchemeName; + }) + .AddScheme(TestExportAuthHandler.SchemeName, _ => { }) + .AddScheme(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { }); + + services.PostConfigure(options => + { + var allowAllPolicy = new AuthorizationPolicyBuilder() + .AddAuthenticationSchemes(TestExportAuthHandler.SchemeName) + .RequireAssertion(_ => true) + .Build(); + + options.DefaultPolicy = allowAllPolicy; + options.FallbackPolicy = allowAllPolicy; + options.AddPolicy(StellaOpsResourceServerPolicies.ExportViewer, allowAllPolicy); + options.AddPolicy(StellaOpsResourceServerPolicies.ExportOperator, allowAllPolicy); + options.AddPolicy(StellaOpsResourceServerPolicies.ExportAdmin, allowAllPolicy); + }); + }); + }); + + using var client = factory.CreateClient(); + var response = await client.PostAsJsonAsync( + "/v1/audit-bundles", + new + { + subject = new + { + type = "IMAGE", + name = "asset-review-prod", + digest = new Dictionary + { + ["sha256"] = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + }, + includeContent = new + { + vulnReports = true, + sbom = true, + vexDecisions = true, + policyEvaluations = true, + attestations = true + } + }); + + var payload = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + + using var document = JsonDocument.Parse(payload); + Assert.True(document.RootElement.TryGetProperty("bundleId", out var bundleId)); + Assert.StartsWith("bndl-", bundleId.GetString(), StringComparison.Ordinal); + Assert.Equal("Pending", document.RootElement.GetProperty("status").GetString()); + } + + private sealed class TestExportAuthHandler : AuthenticationHandler + { + public const string SchemeName = "TestExport"; + + public TestExportAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + var identity = new ClaimsIdentity( + [ + new Claim("sub", "test-user"), + new Claim("name", "Test User"), + new Claim("scope", "export.operator export.viewer export.admin") + ], + SchemeName); + + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, SchemeName); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } +} diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/AuditBundle/AuditBundleRouterDispatchTests.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/AuditBundle/AuditBundleRouterDispatchTests.cs new file mode 100644 index 000000000..7c4bd470c --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/AuditBundle/AuditBundleRouterDispatchTests.cs @@ -0,0 +1,111 @@ +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using StellaOps.ExportCenter.WebService.AuditBundle; +using StellaOps.Microservice.AspNetCore; +using StellaOps.Router.Common.Frames; +using StellaOps.Router.Common.Identity; +using StellaOps.Router.Common.Enums; +using Xunit; + +namespace StellaOps.ExportCenter.Tests.AuditBundle; + +public sealed class AuditBundleRouterDispatchTests +{ + [Fact] + public async Task RouterDispatch_CreateAuditBundle_ReturnsAccepted() + { + const string signingKey = "exportcenter-router-test-signing-key"; + + var builder = WebApplication.CreateBuilder(); + builder.Services.AddLogging(); + builder.Services.AddAuthorization(); + builder.Services.AddAuditBundleJobHandler(); + + var app = builder.Build(); + app.MapAuditBundleEndpoints(); + + 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.Hybrid, + IdentityEnvelopeSigningKey = signingKey + }, + NullLogger.Instance); + + var envelope = new GatewayIdentityEnvelope + { + Issuer = "gateway", + Subject = "qa-user", + Tenant = "demo-prod", + Scopes = ["export.operator", "export.viewer"], + Roles = ["admin"], + CorrelationId = "corr-audit-bundle-1", + IssuedAtUtc = DateTimeOffset.UtcNow.AddSeconds(-5), + ExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(1) + }; + var signature = GatewayIdentityEnvelopeCodec.Sign(envelope, signingKey); + + var transportFrame = FrameConverter.ToFrame(new RequestFrame + { + RequestId = "req-export-audit-1", + Method = "POST", + Path = "/v1/audit-bundles", + Headers = new Dictionary + { + ["content-type"] = "application/json", + ["X-StellaOps-Identity-Envelope"] = signature.Payload, + ["X-StellaOps-Identity-Envelope-Signature"] = signature.Signature + }, + 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 request = FrameConverter.ToRequestFrame(transportFrame); + Assert.NotNull(request); + + var response = await dispatcher.DispatchAsync(request!); + var responseBody = Encoding.UTF8.GetString(response.Payload.ToArray()); + + Assert.Equal(StatusCodes.Status202Accepted, response.StatusCode); + Assert.Contains("\"bundleId\":\"bndl-", responseBody, StringComparison.Ordinal); + Assert.Contains("\"status\":\"Pending\"", responseBody, StringComparison.Ordinal); + } + + private sealed class StaticEndpointDataSource(params Endpoint[] endpoints) : EndpointDataSource + { + private readonly IReadOnlyList _endpoints = endpoints; + + public override IReadOnlyList Endpoints => _endpoints; + + public override Microsoft.Extensions.Primitives.IChangeToken GetChangeToken() + => new Microsoft.Extensions.Primitives.CancellationChangeToken(CancellationToken.None); + } +} diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/AuditBundle/AuditBundleEndpoints.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/AuditBundle/AuditBundleEndpoints.cs index 27fa88504..11f1052cc 100644 --- a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/AuditBundle/AuditBundleEndpoints.cs +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/AuditBundle/AuditBundleEndpoints.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using StellaOps.Auth.ServerIntegration; +using System.Text.Json; using StellaOps.ExportCenter.Client.Models; using static StellaOps.Localization.T; @@ -64,11 +65,28 @@ public static class AuditBundleEndpoints } private static async Task, BadRequest>> CreateAuditBundleAsync( - [FromBody] CreateAuditBundleRequest request, + HttpRequest request, [FromServices] IAuditBundleJobHandler handler, HttpContext httpContext, CancellationToken cancellationToken) { + CreateAuditBundleRequest? createRequest; + try + { + createRequest = await request.ReadFromJsonAsync(cancellationToken); + } + catch (JsonException) + { + return TypedResults.BadRequest(new ErrorEnvelope( + new ErrorDetail("INVALID_REQUEST", "Malformed request body."))); + } + + if (createRequest is null) + { + return TypedResults.BadRequest(new ErrorEnvelope( + new ErrorDetail("INVALID_REQUEST", "Request body is required."))); + } + // Get actor from claims var actorId = httpContext.User.FindFirst("sub")?.Value ?? httpContext.User.FindFirst("preferred_username")?.Value @@ -77,7 +95,7 @@ public static class AuditBundleEndpoints ?? httpContext.User.FindFirst("preferred_username")?.Value ?? "Anonymous User"; - var result = await handler.CreateBundleAsync(request, actorId, actorName, cancellationToken); + var result = await handler.CreateBundleAsync(createRequest, actorId, actorName, cancellationToken); if (result.Error is not null) {