diff --git a/docs/features/checked/web/a-b-deploy-diff-panel.md b/docs/features/checked/web/a-b-deploy-diff-panel.md index 62a4e314c..67542f62f 100644 --- a/docs/features/checked/web/a-b-deploy-diff-panel.md +++ b/docs/features/checked/web/a-b-deploy-diff-panel.md @@ -9,6 +9,8 @@ VERIFIED ## Description Deploy diff UI provides deterministic A/B SBOM comparison with policy-hit context, loading/error states, and inline release action controls. +As of 2026-03-09 the panel is no longer wired to the dead `/api/v1/sbom/diff` route. The canonical comparison source is SbomService lineage compare, and the surrounding Releases workspace now degrades to an actionable `No Comparison Selected` state instead of a hard route failure when no digests are present. + ## Implementation Details - **Feature directory**: `src/Web/StellaOps.Web/src/app/features/deploy-diff/` - **Route module**: `src/Web/StellaOps.Web/src/app/features/deploy-diff/deploy-diff.routes.ts` @@ -16,11 +18,16 @@ Deploy diff UI provides deterministic A/B SBOM comparison with policy-hit contex - `src/Web/StellaOps.Web/src/app/features/deploy-diff/pages/deploy-diff.page.ts` - `src/Web/StellaOps.Web/src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.ts` - `src/Web/StellaOps.Web/src/app/features/deploy-diff/services/deploy-diff.service.ts` +- **Canonical backend dependency**: + - `GET /api/v1/lineage/compare?a=&b=&tenant=` - **Focused tests**: - - `src/Web/StellaOps.Web/src/tests/deploy_diff/deploy-diff-panel.component.spec.ts` + - `src/Web/StellaOps.Web/src/app/features/deploy-diff/services/deploy-diff.service.spec.ts` + - `src/Web/StellaOps.Web/src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts` + - `src/Web/StellaOps.Web/src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts` ## Follow-up Notes -- Primary shell route map now mounts `/deploy/diff` via `src/Web/StellaOps.Web/src/app/app.routes.ts`, enabling strict end-user Tier 2 replay. +- Primary Releases shell mounts the canonical workspace at `/releases/investigation/deploy-diff`. +- Direct shell navigation without digests is a supported workspace state, not an error path. ## Verification - Date: 2026-02-10 @@ -30,18 +37,12 @@ Deploy diff UI provides deterministic A/B SBOM comparison with policy-hit contex - `tier1-build-check.json`: pass - `tier2-e2e-check.json`: pass - - ## Recheck (run-003) - Date (UTC): 2026-02-10 - Status: VERIFIED (replayed) - Tier 1 evidence: Angular build passed and checked-web suite passed 145/145. - Tier 2 evidence: docs/qa/feature-checks/runs/web/a-b-deploy-diff-panel/run-003/tier2-e2e-check.json. - - - - ## Recheck (run-004) - Date (UTC): 2026-02-10 - Status: VERIFIED (replayed) @@ -55,3 +56,12 @@ Deploy diff UI provides deterministic A/B SBOM comparison with policy-hit contex - Tier 2 evidence: `docs/qa/feature-checks/runs/web/a-b-deploy-diff-panel/run-006/tier2-ui-check.json` - Notes: Playwright now covers positive deploy-diff rendering plus missing-parameter and API-error user paths; route is mounted in the primary shell map. +## Recheck (2026-03-09) +- Status: VERIFIED (lineage compare contract repair) +- Tier 1 evidence: + - `npx ng test --watch=false --ts-config tsconfig.spec.features.json --include=src/app/features/deploy-diff/services/deploy-diff.service.spec.ts --include=src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts --include=src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts` +- Tier 2 target: + - live `https://stella-ops.local/releases/investigation/deploy-diff` +- Notes: + - direct route loads an actionable workspace when digests are missing + - loaded comparisons are normalized from lineage compare into the deploy-diff UI model diff --git a/docs/features/checked/web/release-investigation-routes.md b/docs/features/checked/web/release-investigation-routes.md index 50f862d37..43d468bf0 100644 --- a/docs/features/checked/web/release-investigation-routes.md +++ b/docs/features/checked/web/release-investigation-routes.md @@ -15,10 +15,15 @@ Integrated disconnected release-investigation route families (timeline, deploy-d ## Canonical URL Contract - `/releases/investigation/timeline` - Investigation timeline overview - `/releases/investigation/timeline/:correlationId` - Correlated event drill-in -- `/releases/investigation/deploy-diff` - Deployment diff (query params: from, to) -- `/releases/investigation/change-trace` - Change trace viewer +- `/releases/investigation/deploy-diff` - Deployment diff workspace; direct load shows a recovery state when no `from`/`to` digests are present +- `/releases/investigation/change-trace` - Change trace workspace; direct load shows a recovery state until a comparison or trace id is selected - `/releases/investigation/change-trace/:traceId` - Specific trace detail +## Direct-Load Workspace Contract +- `/releases/investigation/deploy-diff` no longer fails with `Missing Parameters`. Direct navigation now shows `No Comparison Selected` plus recovery actions back to `/releases/deployments` and `/releases/overview`. +- `/releases/investigation/change-trace` no longer renders an inert `No Change Trace Loaded` shell. Direct navigation now shows `No Comparison Selected` plus recovery actions to `/releases/deployments`, or back to deploy-diff when `from`/`to` digests are already present. +- Both workspaces preserve tenant/scope query context when it exists and fall back to the canonical `demo-prod` tenant on a fresh shell load. + ## Timeline Decision **Bounded-secondary-route** (not absorb-into-run-workspace). The investigation timeline is a correlation-based tool that spans multiple services by correlationId, which is conceptually different from the run workspace's timeline tab showing run execution flow. Mounting it under `/releases/investigation/timeline` avoids URL collision and keeps both capabilities distinct. @@ -30,6 +35,10 @@ Integrated disconnected release-investigation route families (timeline, deploy-d - `src/Web/StellaOps.Web/src/app/features/deploy-diff/deploy-diff.routes.ts` - Updated canonical URL reference - `src/Web/StellaOps.Web/src/app/features/change-trace/change-trace.routes.ts` - Added breadcrumb, title, sprint ref - **Tests**: `src/Web/StellaOps.Web/src/app/routes/releases.routes.spec.ts` +- **Recheck (2026-03-09)**: + - `deploy-diff` is now backed by the live lineage compare contract instead of the dead `/api/v1/sbom/diff` path. + - `change-trace` is now backed by the restored `/api/change-traces/build` and `/api/change-traces/{traceId}` compatibility endpoints in SbomService. + - Focused verification passed on `src/Web/StellaOps.Web/src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts` and `src/Web/StellaOps.Web/src/app/features/change-trace/change-trace-viewer.component.spec.ts`. ## Deliberately Excluded Legacy Behaviors - The old timeline route at `/timeline` (standalone top-level) is not revived diff --git a/docs/implplan/SPRINT_20260309_016_SbomService_release_investigation_workspace_contract_repair.md b/docs/implplan/SPRINT_20260309_016_SbomService_release_investigation_workspace_contract_repair.md new file mode 100644 index 000000000..5fa6a7297 --- /dev/null +++ b/docs/implplan/SPRINT_20260309_016_SbomService_release_investigation_workspace_contract_repair.md @@ -0,0 +1,91 @@ +# Sprint 20260309_016 - Release Investigation Workspace Contract Repair + +## Topic & Scope +- Replace the broken release-investigation route contract with a self-sufficient workspace that no longer depends on orphaned query params or dead API paths. +- Restore canonical behavior for `/releases/investigation/deploy-diff` and `/releases/investigation/change-trace` on a fresh live stack where comparison data may be absent. +- Keep the repair scoped to release-investigation surfaces, the SbomService compatibility layer they depend on, and the docs that describe the contract. +- Working directory: `src/SbomService/`. +- Allowed coordination edits: `src/Web/StellaOps.Web/src/app/features/deploy-diff/`, `src/Web/StellaOps.Web/src/app/features/change-trace/`, `src/Web/StellaOps.Web/scripts/`, `docs/features/checked/web/`, and `docs/modules/sbom-service/`. +- Expected evidence: focused .NET tests, focused Angular tests, rebuilt `sbomservice` + web bundle, live Playwright recheck artifacts. + +## Dependencies & Concurrency +- Depends on the current `stella-ops.local` compose stack already rebuilt from source on 2026-03-09. +- Safe to run in parallel with unrelated search/runtime/auth work as long as those edits do not overwrite the touched release-investigation files. +- Do not modify unrelated dirty files from other agents. + +## Documentation Prerequisites +- `docs/modules/sbom-service/architecture.md` +- `docs/features/checked/web/release-investigation-routes.md` +- `docs/features/checked/web/a-b-deploy-diff-panel.md` +- `docs/qa/feature-checks/FLOW.md` + +## Delivery Tracker + +### SBOM-RIW-001 - Define canonical workspace/default-context contract +Status: DONE +Dependency: none +Owners: Product Manager, Developer +Task description: +- Replace the legacy assumption that the deploy-diff route is only valid when a caller injects `from` and `to` query parameters. The canonical Releases-owned workspace must remain useful when opened directly from the shell on a fresh setup. +- Decide and document how the investigation pages behave when comparison data is unavailable in the live stack: they must show explicit product states and recovery paths, not placeholder errors. + +Completion criteria: +- [ ] Release-investigation docs describe the new direct-load behavior and the live-data fallback behavior. +- [ ] The selected contract is reflected consistently in both web and sbomservice implementations. + +### SBOM-RIW-002 - Rebase deploy diff on the live lineage compare contract +Status: DONE +Dependency: SBOM-RIW-001 +Owners: Developer, Test Automation +Task description: +- Remove the dead `/api/v1/sbom/diff` dependency from the deploy-diff feature. +- Use the live lineage compare capability as the canonical comparison source and normalize it into the deploy-diff UI model. +- Ensure the direct route either loads a comparison or lands in an explicit, user-actionable empty state instead of `Missing Parameters`. + +Completion criteria: +- [ ] Deploy-diff uses a live contract that exists in sbomservice. +- [ ] Direct navigation to `/releases/investigation/deploy-diff` no longer renders the legacy missing-parameter failure state. +- [ ] Focused frontend tests cover both loaded and no-comparison states. + +### SBOM-RIW-003 - Restore change trace compatibility API and viewer behavior +Status: DONE +Dependency: SBOM-RIW-001 +Owners: Developer, Test Automation +Task description: +- Implement the missing `/api/change-traces` compatibility layer in SbomService instead of leaving the gateway to route into a void. +- Make the change-trace viewer support canonical direct-load behavior and a deterministic empty state when there is no active comparison context. + +Completion criteria: +- [ ] `/api/change-traces/build` and compatible read behavior exist in sbomservice with focused tests. +- [ ] `/releases/investigation/change-trace` no longer renders the legacy inert empty shell on direct load. +- [ ] Live Playwright verifies the viewer loads meaningful state and recovery actions. + +### SBOM-RIW-004 - Verify live route behavior after rebuild +Status: DONE +Dependency: SBOM-RIW-002 +Owners: QA, Test Automation +Task description: +- Rebuild the touched targets, redeploy only the changed services, rerun the focused Playwright surfaces, and capture the before/after evidence. +- Do not mark the iteration done until the live shell confirms the repaired routes and their primary actions. + +Completion criteria: +- [x] Focused .NET and Angular tests pass. +- [x] `sbomservice` and web assets are rebuilt and redeployed. +- [x] Live Playwright evidence shows the repaired routes and actions behaving correctly. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-09 | Sprint created after live Playwright confirmed `/releases/investigation/deploy-diff` rendered `Missing Parameters` and `/releases/investigation/change-trace` rendered `No Change Trace Loaded`; live stack inspection showed the mounted routes depended on dead or missing contracts. | Developer | +| 2026-03-09 | Rebased deploy-diff on lineage compare, restored the `/api/change-traces` compatibility layer, and added focused verification: `dotnet test src/SbomService/StellaOps.SbomService.Tests/StellaOps.SbomService.Tests.csproj -v minimal -- --filter-class StellaOps.SbomService.Tests.ChangeTraceCompatibilityEndpointsTests` passed 3/3; `npx ng test --watch=false --ts-config tsconfig.spec.features.json --include=src/app/features/deploy-diff/services/deploy-diff.service.spec.ts --include=src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts --include=src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts --include=src/app/features/change-trace/change-trace-viewer.component.spec.ts` passed 30/30. | Developer | +| 2026-03-09 | Rebuilt `sbomservice`, rebuilt and resynced the web bundle into `compose_console-dist`, then reran `node .\\src\\Web\\StellaOps.Web\\scripts\\live-frontdoor-changed-surfaces.mjs`; live frontdoor verification passed for the repaired release-investigation routes and their recovery actions on `https://stella-ops.local`. | Developer | + +## Decisions & Risks +- Decision: release-investigation routes are Releases-owned workspaces with canonical direct-load behavior rather than query-only leaf pages. +- Risk: the current live stack has empty release/SBOM comparison projections, so the workspace must degrade cleanly when no comparison exists instead of pretending data is present. +- Risk: other agents have unrelated dirty files in `src/Web/StellaOps.Web/` and platform services; only stage touched release-investigation files for the eventual commit. +- Contract note: `/api/change-traces/*` is restored as a deterministic compatibility layer over lineage compare, not as a second persistent change-trace store. + +## Next Checkpoints +- Implement the workspace/default-context contract and the SbomService compatibility API in this iteration. +- Rebuild `sbomservice` and the web bundle, sync the web assets into `compose_console-dist`, rerun the focused Playwright sweep, and commit the scoped repair. diff --git a/docs/modules/sbom-service/architecture.md b/docs/modules/sbom-service/architecture.md index 724700e11..1e8dcf85d 100644 --- a/docs/modules/sbom-service/architecture.md +++ b/docs/modules/sbom-service/architecture.md @@ -50,6 +50,9 @@ Operational rules: - `GET /sbom/ledger/range` – query versions within a time range. - `GET /sbom/ledger/diff` – component/version/license diff between two versions. - `GET /sbom/ledger/lineage` – parent/child lineage edges for an artifact chain. +- `GET /api/v1/lineage/compare?a=...&b=...&tenant=...` – canonical release-investigation comparison endpoint returning normalized component, VEX, and reachability deltas for deploy-diff. +- `POST /api/change-traces/build` – compatibility endpoint that materializes a release-investigation change trace from `fromDigest`, `toDigest`, and tenant context. +- `GET /api/change-traces/{traceId}` – stateless compatibility read endpoint; rehydrates the change trace from an encoded trace id and the current lineage compare result. - `GET /console/sboms` – Console catalog with filters (artifact, license, scope, asset tags), cursor pagination, evaluation metadata, immutable JSON projection for drawer views. - `GET /components/lookup?purl=...` – component neighborhood for global search/Graph overlays; returns caches hints + tenant enforcement. - `POST /entrypoints` / `GET /entrypoints` – manage entrypoint/service node overrides feeding Cartographer relevance; deterministic defaults when unset. @@ -88,6 +91,11 @@ Operational rules: - Current implementation uses an in-memory event store/publisher (with clock abstraction) plus `/internal/sbom/events` + `/internal/sbom/events/backfill` to validate envelopes until the PostgreSQL-backed outbox is wired. - Entrypoint/service node overrides are exposed via `/entrypoints` (tenant-scoped) and should be mirrored into Cartographer relevance jobs when the outbox lands. +## 5.1) Release Investigation Compatibility +- The Releases workspace consumes lineage compare as the source of truth for A/B deploy comparison. +- `/api/change-traces/*` exists as a compatibility layer for the web change-trace viewer and gateway routing. It does not persist trace documents; trace ids encode the tenant, digest pair, and byte-diff mode, and the service deterministically rebuilds the document on read. +- When no lineage comparison exists for the selected digests, the service returns `404` so the web workspace can surface an explicit recovery state instead of pretending data exists. + ## 6) Determinism & offline posture - Stable ordering for projections and paths; timestamps in UTC ISO-8601; hash inputs canonicalised. - Add-only evolution for schemas; LNM v1 fixtures published alongside API docs and replayable tests. diff --git a/src/SbomService/StellaOps.SbomService.Tests/ChangeTraceCompatibilityEndpointsTests.cs b/src/SbomService/StellaOps.SbomService.Tests/ChangeTraceCompatibilityEndpointsTests.cs new file mode 100644 index 000000000..3b1ada923 --- /dev/null +++ b/src/SbomService/StellaOps.SbomService.Tests/ChangeTraceCompatibilityEndpointsTests.cs @@ -0,0 +1,153 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using StellaOps.SbomService.Models; +using StellaOps.SbomService.Services; +using StellaOps.TestKit; +using Xunit; + +namespace StellaOps.SbomService.Tests; + +public sealed class ChangeTraceCompatibilityEndpointsTests : IClassFixture> +{ + private const string TenantId = "github.com/acme/change-trace"; + + private readonly WebApplicationFactory _factory; + + public ChangeTraceCompatibilityEndpointsTests(WebApplicationFactory factory) + { + _factory = factory.WithWebHostBuilder(_ => { }); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task Build_endpoint_returns_deterministic_change_trace_for_uploaded_artifacts() + { + var client = CreateAuthenticatedClient(); + var firstUpload = await UploadAsync(client, "1.0.0"); + var secondUpload = await UploadAsync(client, "1.1.0"); + + var response = await client.PostAsJsonAsync("/api/change-traces/build", new ChangeTraceBuildRequest + { + TenantId = TenantId, + FromDigest = firstUpload.Digest, + ToDigest = secondUpload.Digest, + IncludeByteDiff = false, + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + + payload.Should().NotBeNull(); + payload!.TraceId.Should().NotBeNullOrWhiteSpace(); + payload.Subject.FromDigest.Should().Be(firstUpload.Digest); + payload.Subject.ToDigest.Should().Be(secondUpload.Digest); + payload.Deltas.Should().ContainSingle(); + payload.Deltas[0].ChangeType.Should().BeOneOf("upgraded", "patched"); + payload.Summary.ChangedPackages.Should().Be(1); + payload.Commitment.Should().NotBeNull(); + payload.Commitment!.Sha256.Should().StartWith("sha256:"); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task Get_endpoint_rehydrates_trace_from_trace_id() + { + var client = CreateAuthenticatedClient(); + var firstUpload = await UploadAsync(client, "2.0.0"); + var secondUpload = await UploadAsync(client, "2.0.1"); + + var buildResponse = await client.PostAsJsonAsync("/api/change-traces/build", new ChangeTraceBuildRequest + { + TenantId = TenantId, + FromDigest = firstUpload.Digest, + ToDigest = secondUpload.Digest, + IncludeByteDiff = false, + }); + buildResponse.EnsureSuccessStatusCode(); + + var builtTrace = await buildResponse.Content.ReadFromJsonAsync(); + builtTrace.Should().NotBeNull(); + + var getResponse = await client.GetAsync($"/api/change-traces/{Uri.EscapeDataString(builtTrace!.TraceId)}"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var rehydratedTrace = await getResponse.Content.ReadFromJsonAsync(); + rehydratedTrace.Should().NotBeNull(); + rehydratedTrace!.TraceId.Should().Be(builtTrace.TraceId); + rehydratedTrace.Subject.FromDigest.Should().Be(firstUpload.Digest); + rehydratedTrace.Subject.ToDigest.Should().Be(secondUpload.Digest); + rehydratedTrace.Summary.ChangedPackages.Should().Be(1); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task Get_endpoint_rejects_invalid_trace_id() + { + var client = CreateAuthenticatedClient(); + + var response = await client.GetAsync("/api/change-traces/not-a-valid-trace-id"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("error").GetString().Should().Be("invalid traceId"); + } + + private async Task UploadAsync(HttpClient client, string version) + { + var response = await client.PostAsJsonAsync( + "/sbom/upload", + new SbomUploadRequest + { + ArtifactRef = "acme/change-trace:demo", + Sbom = JsonDocument.Parse($$""" + { + "spdxVersion": "SPDX-2.3", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "sample", + "dataLicense": "CC0-1.0", + "packages": [ + { + "SPDXID": "SPDXRef-Package-lodash", + "name": "lodash", + "versionInfo": "{{version}}", + "licenseDeclared": "MIT", + "externalRefs": [ + { + "referenceType": "purl", + "referenceLocator": "pkg:npm/lodash@{{version}}", + "referenceCategory": "PACKAGE-MANAGER" + } + ] + } + ] + } + """).RootElement.Clone(), + Source = new SbomUploadSource + { + Tool = "syft", + Version = "1.0.0", + CiContext = new SbomUploadCiContext + { + BuildId = $"build-{version}", + Repository = TenantId, + }, + }, + }); + + response.EnsureSuccessStatusCode(); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + return payload!; + } + + private HttpClient CreateAuthenticatedClient() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Tenant-Id", TenantId); + client.DefaultRequestHeaders.Add("X-User-Id", "change-trace-test"); + return client; + } +} diff --git a/src/SbomService/StellaOps.SbomService/Program.cs b/src/SbomService/StellaOps.SbomService/Program.cs index 33706c0b4..5a7fae86b 100644 --- a/src/SbomService/StellaOps.SbomService/Program.cs +++ b/src/SbomService/StellaOps.SbomService/Program.cs @@ -1079,6 +1079,103 @@ app.MapGet("/api/v1/lineage/compare", async Task ( .RequireAuthorization(SbomPolicies.Read) .RequireTenant(); +app.MapPost("/api/change-traces/build", async Task ( + [FromBody] ChangeTraceBuildRequest request, + [FromServices] ILineageCompareService compareService, + CancellationToken cancellationToken) => +{ + var tenantId = request.TenantId?.Trim(); + var fromDigest = request.FromDigest?.Trim(); + var toDigest = request.ToDigest?.Trim(); + + if (string.IsNullOrWhiteSpace(fromDigest)) + { + fromDigest = request.FromScanId?.Trim(); + } + + if (string.IsNullOrWhiteSpace(toDigest)) + { + toDigest = request.ToScanId?.Trim(); + } + + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(new { error = "tenantId is required" }); + } + + if (string.IsNullOrWhiteSpace(fromDigest) || string.IsNullOrWhiteSpace(toDigest)) + { + return Results.BadRequest(new { error = "fromDigest and toDigest are required" }); + } + + var compareResult = await compareService.CompareAsync( + fromDigest, + toDigest, + tenantId, + new LineageCompareOptions + { + IncludeSbomDiff = true, + IncludeVexDeltas = true, + IncludeReachabilityDeltas = true, + IncludeAttestations = true, + IncludeReplayHashes = true, + }, + cancellationToken); + + if (compareResult is null) + { + return Results.NotFound(new { error = "comparison data not found for the specified artifacts" }); + } + + return Results.Ok(ChangeTraceCompatibilityService.BuildDocument( + compareResult, + tenantId, + request.IncludeByteDiff)); +}) + .WithName("BuildChangeTrace") + .WithDescription("Builds a deterministic change trace document for the supplied tenant and artifact digest pair. Accepts modern fromDigest/toDigest fields and legacy fromScanId/toScanId compatibility fields.") + .RequireAuthorization(SbomPolicies.Read) + .RequireTenant(); + +app.MapGet("/api/change-traces/{traceId}", async Task ( + [FromRoute] string traceId, + [FromServices] ILineageCompareService compareService, + CancellationToken cancellationToken) => +{ + if (!ChangeTraceCompatibilityService.TryDecodeTraceId(traceId, out var lookup)) + { + return Results.BadRequest(new { error = "invalid traceId" }); + } + + var compareResult = await compareService.CompareAsync( + lookup.FromDigest, + lookup.ToDigest, + lookup.TenantId, + new LineageCompareOptions + { + IncludeSbomDiff = true, + IncludeVexDeltas = true, + IncludeReachabilityDeltas = true, + IncludeAttestations = true, + IncludeReplayHashes = true, + }, + cancellationToken); + + if (compareResult is null) + { + return Results.NotFound(new { error = "comparison data not found for the traceId" }); + } + + return Results.Ok(ChangeTraceCompatibilityService.BuildDocument( + compareResult, + lookup.TenantId, + lookup.IncludeByteDiff)); +}) + .WithName("GetChangeTrace") + .WithDescription("Reconstructs a deterministic change trace document from a shareable traceId without requiring persisted trace blobs.") + .RequireAuthorization(SbomPolicies.Read) + .RequireTenant(); + // ----------------------------------------------------------------------------- // Replay Verification API (LIN-BE-033) // Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii diff --git a/src/SbomService/StellaOps.SbomService/Services/ChangeTraceCompatibilityService.cs b/src/SbomService/StellaOps.SbomService/Services/ChangeTraceCompatibilityService.cs new file mode 100644 index 000000000..6a8f8d4b9 --- /dev/null +++ b/src/SbomService/StellaOps.SbomService/Services/ChangeTraceCompatibilityService.cs @@ -0,0 +1,512 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StellaOps.SbomService.Services; + +internal static class ChangeTraceCompatibilityService +{ + public static string EncodeTraceId(string tenantId, string fromDigest, string toDigest, bool includeByteDiff) + { + var payload = JsonSerializer.Serialize(new ChangeTraceLookup( + TenantId: tenantId, + FromDigest: fromDigest, + ToDigest: toDigest, + IncludeByteDiff: includeByteDiff)); + + return Base64UrlEncode(Encoding.UTF8.GetBytes(payload)); + } + + public static bool TryDecodeTraceId(string traceId, out ChangeTraceLookup lookup) + { + lookup = default!; + + try + { + var json = Encoding.UTF8.GetString(Base64UrlDecode(traceId)); + var decoded = JsonSerializer.Deserialize(json); + if (decoded is null + || string.IsNullOrWhiteSpace(decoded.TenantId) + || string.IsNullOrWhiteSpace(decoded.FromDigest) + || string.IsNullOrWhiteSpace(decoded.ToDigest)) + { + return false; + } + + lookup = decoded; + return true; + } + catch + { + return false; + } + } + + public static ChangeTraceDocument BuildDocument( + LineageCompareResponse compare, + string tenantId, + bool includeByteDiff) + { + var traceId = EncodeTraceId(tenantId, compare.FromDigest, compare.ToDigest, includeByteDiff); + var riskTrend = (compare.Summary.RiskTrend ?? "unchanged").Trim().ToLowerInvariant(); + var deltas = BuildPackageDeltas(compare, riskTrend); + + var subject = new ChangeTraceSubjectDocument + { + Type = "artifact.compare", + ImageRef = compare.ToArtifact?.Name + ?? compare.FromArtifact?.Name + ?? compare.ToDigest, + Digest = compare.ToDigest, + FromDigest = compare.FromDigest, + ToDigest = compare.ToDigest, + FromScanId = compare.FromDigest, + ToScanId = compare.ToDigest, + }; + + var basis = new ChangeTraceBasisDocument + { + AnalyzedAt = compare.ComputedAt, + Policies = new[] + { + "lineage.compare", + "release-investigation.workspace", + }, + DiffMethods = includeByteDiff ? new[] { "pkg", "byte" } : new[] { "pkg" }, + EngineVersion = "sbomservice-lineage-compare", + EngineDigest = compare.ReplayHashes?.ToReplayHash ?? compare.ReplayHashes?.FromReplayHash, + }; + + var summary = new ChangeTraceSummaryDocument + { + ChangedPackages = deltas.Count, + PackagesAdded = deltas.Count(delta => string.Equals(delta.ChangeType, "added", StringComparison.Ordinal)), + PackagesRemoved = deltas.Count(delta => string.Equals(delta.ChangeType, "removed", StringComparison.Ordinal)), + ChangedSymbols = 0, + ChangedBytes = 0, + RiskDelta = riskTrend switch + { + "improved" => -1, + "degraded" => 1, + _ => 0, + }, + Verdict = riskTrend switch + { + "improved" => "risk_down", + "degraded" => "risk_up", + _ => "neutral", + }, + }; + + var document = new ChangeTraceDocument + { + TraceId = traceId, + Schema = "stella.change-trace/v1", + Subject = subject, + Basis = basis, + Deltas = deltas, + Summary = summary, + }; + + document.Commitment = new ChangeTraceCommitmentDocument + { + Algorithm = "sha256", + Sha256 = ComputeCommitmentHash(document), + }; + + return document; + } + + private static IReadOnlyList BuildPackageDeltas( + LineageCompareResponse compare, + string riskTrend) + { + var deltas = new List(); + var sbomDiff = compare.SbomDiff; + if (sbomDiff is null) + { + return deltas; + } + + deltas.AddRange(sbomDiff.Added.Select((change, index) => + BuildPackageDelta(change, null, change.Version, "added", riskTrend, change.Vulnerabilities?.Count ?? 0, 0, index))); + deltas.AddRange(sbomDiff.Removed.Select((change, index) => + BuildPackageDelta(change, change.Version, null, "removed", riskTrend, 0, change.Vulnerabilities?.Count ?? 0, index))); + deltas.AddRange(sbomDiff.Modified.Select((change, index) => + BuildModifiedPackageDelta(change, riskTrend, index))); + + return deltas; + } + + private static ChangeTracePackageDeltaDocument BuildPackageDelta( + LineageComponentChange change, + string? fromVersion, + string? toVersion, + string changeType, + string riskTrend, + int introducedVulns, + int resolvedVulns, + int index) + { + return new ChangeTracePackageDeltaDocument + { + Purl = change.Purl, + FromVersion = fromVersion, + ToVersion = toVersion, + ChangeType = changeType, + Symbols = Array.Empty(), + Bytes = Array.Empty(), + TrustDelta = BuildTrustDelta( + riskTrend, + introducedVulns, + resolvedVulns, + changeType, + fromVersion, + toVersion, + index), + }; + } + + private static ChangeTracePackageDeltaDocument BuildModifiedPackageDelta( + LineageComponentModification change, + string riskTrend, + int index) + { + var introducedCount = change.IntroducedVulnerabilities?.Count ?? 0; + var resolvedCount = change.FixedVulnerabilities?.Count ?? 0; + + return new ChangeTracePackageDeltaDocument + { + Purl = change.Purl, + FromVersion = change.FromVersion, + ToVersion = change.ToVersion, + ChangeType = ResolveModifiedChangeType(change), + Symbols = Array.Empty(), + Bytes = Array.Empty(), + TrustDelta = BuildTrustDelta( + riskTrend, + introducedCount, + resolvedCount, + "modified", + change.FromVersion, + change.ToVersion, + index, + change.UpgradeType, + change.FixedVulnerabilities, + change.IntroducedVulnerabilities), + }; + } + + private static ChangeTraceTrustDeltaDocument BuildTrustDelta( + string riskTrend, + int introducedCount, + int resolvedCount, + string changeType, + string? fromVersion, + string? toVersion, + int index, + string? upgradeType = null, + IReadOnlyList? fixedVulnerabilities = null, + IReadOnlyList? introducedVulnerabilities = null) + { + var score = introducedCount - resolvedCount; + var afterScore = Math.Clamp(50 + (resolvedCount - introducedCount), 0, 100); + var proofSteps = new List + { + $"delta[{index}] compared {fromVersion ?? "none"} -> {toVersion ?? "none"} ({changeType})", + }; + + if (!string.IsNullOrWhiteSpace(upgradeType)) + { + proofSteps.Add($"version change classified as {upgradeType}"); + } + + foreach (var vulnerability in fixedVulnerabilities ?? Array.Empty()) + { + proofSteps.Add($"resolved {vulnerability}"); + } + + foreach (var vulnerability in introducedVulnerabilities ?? Array.Empty()) + { + proofSteps.Add($"introduced {vulnerability}"); + } + + proofSteps.Add(riskTrend switch + { + "improved" => "comparison verdict risk_down", + "degraded" => "comparison verdict risk_up", + _ => "comparison verdict neutral", + }); + + return new ChangeTraceTrustDeltaDocument + { + Score = score, + BeforeScore = 50, + AfterScore = afterScore, + ReachabilityImpact = riskTrend switch + { + "improved" => "reduced", + "degraded" => "increased", + _ => "unchanged", + }, + ExploitabilityImpact = riskTrend switch + { + "improved" => "down", + "degraded" => "up", + _ => "unchanged", + }, + ProofSteps = proofSteps, + }; + } + + private static string ResolveModifiedChangeType(LineageComponentModification change) + { + if (string.Equals(change.FromVersion, change.ToVersion, StringComparison.Ordinal)) + { + return "rebuilt"; + } + + if (string.Equals(change.UpgradeType, "patch", StringComparison.OrdinalIgnoreCase)) + { + return "patched"; + } + + if (LooksLikeDowngrade(change.FromVersion, change.ToVersion)) + { + return "downgraded"; + } + + return "upgraded"; + } + + private static bool LooksLikeDowngrade(string fromVersion, string toVersion) + { + var fromParts = fromVersion.Split('.'); + var toParts = toVersion.Split('.'); + var length = Math.Max(fromParts.Length, toParts.Length); + + for (var index = 0; index < length; index++) + { + var fromPart = index < fromParts.Length && int.TryParse(fromParts[index], out var fromValue) + ? fromValue + : 0; + var toPart = index < toParts.Length && int.TryParse(toParts[index], out var toValue) + ? toValue + : 0; + + if (toPart < fromPart) + { + return true; + } + + if (toPart > fromPart) + { + return false; + } + } + + return false; + } + + private static string ComputeCommitmentHash(ChangeTraceDocument document) + { + var payload = JsonSerializer.SerializeToUtf8Bytes(new + { + document.TraceId, + document.Schema, + document.Subject, + document.Basis, + document.Deltas, + document.Summary, + }); + + var hash = SHA256.HashData(payload); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + private static string Base64UrlEncode(byte[] value) + { + return Convert.ToBase64String(value) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private static byte[] Base64UrlDecode(string value) + { + var normalized = value + .Replace('-', '+') + .Replace('_', '/'); + + switch (normalized.Length % 4) + { + case 2: + normalized += "=="; + break; + case 3: + normalized += "="; + break; + } + + return Convert.FromBase64String(normalized); + } +} + +public sealed record ChangeTraceBuildRequest +{ + public string? TenantId { get; init; } + + public string? FromDigest { get; init; } + + public string? ToDigest { get; init; } + + public string? FromScanId { get; init; } + + public string? ToScanId { get; init; } + + public bool IncludeByteDiff { get; init; } +} + +public sealed record ChangeTraceLookup( + string TenantId, + string FromDigest, + string ToDigest, + bool IncludeByteDiff); + +public sealed record ChangeTraceDocument +{ + public required string TraceId { get; init; } + + public required string Schema { get; init; } + + public required ChangeTraceSubjectDocument Subject { get; init; } + + public required ChangeTraceBasisDocument Basis { get; init; } + + public required IReadOnlyList Deltas { get; init; } + + public required ChangeTraceSummaryDocument Summary { get; init; } + + public ChangeTraceCommitmentDocument? Commitment { get; set; } +} + +public sealed record ChangeTraceSubjectDocument +{ + public required string Type { get; init; } + + public required string ImageRef { get; init; } + + public required string Digest { get; init; } + + public required string FromDigest { get; init; } + + public required string ToDigest { get; init; } + + public string? FromScanId { get; init; } + + public string? ToScanId { get; init; } +} + +public sealed record ChangeTraceBasisDocument +{ + public required DateTimeOffset AnalyzedAt { get; init; } + + public required IReadOnlyList Policies { get; init; } + + public required IReadOnlyList DiffMethods { get; init; } + + public required string EngineVersion { get; init; } + + public string? EngineDigest { get; init; } +} + +public sealed record ChangeTracePackageDeltaDocument +{ + public required string Purl { get; init; } + + public string? FromVersion { get; init; } + + public string? ToVersion { get; init; } + + public required string ChangeType { get; init; } + + public required IReadOnlyList Symbols { get; init; } + + public required IReadOnlyList Bytes { get; init; } + + public required ChangeTraceTrustDeltaDocument TrustDelta { get; init; } +} + +public sealed record ChangeTraceSymbolDeltaDocument +{ + public required string SymbolName { get; init; } + + public required string ChangeType { get; init; } + + public int SizeDelta { get; init; } + + public string? FromHash { get; init; } + + public string? ToHash { get; init; } + + public double Confidence { get; init; } + + public string? MatchMethod { get; init; } + + public string? Explanation { get; init; } +} + +public sealed record ChangeTraceByteDeltaDocument +{ + public int Offset { get; init; } + + public int Size { get; init; } + + public required string FromHash { get; init; } + + public required string ToHash { get; init; } + + public string? Section { get; init; } + + public string? Context { get; init; } +} + +public sealed record ChangeTraceTrustDeltaDocument +{ + public int Score { get; init; } + + public int BeforeScore { get; init; } + + public int AfterScore { get; init; } + + public required string ReachabilityImpact { get; init; } + + public required string ExploitabilityImpact { get; init; } + + public required IReadOnlyList ProofSteps { get; init; } +} + +public sealed record ChangeTraceSummaryDocument +{ + public int ChangedPackages { get; init; } + + public int PackagesAdded { get; init; } + + public int PackagesRemoved { get; init; } + + public int ChangedSymbols { get; init; } + + public int ChangedBytes { get; init; } + + public int RiskDelta { get; init; } + + public required string Verdict { get; init; } +} + +public sealed record ChangeTraceCommitmentDocument +{ + public required string Sha256 { get; init; } + + public required string Algorithm { get; init; } +} diff --git a/src/Web/StellaOps.Web/scripts/live-frontdoor-changed-surfaces.mjs b/src/Web/StellaOps.Web/scripts/live-frontdoor-changed-surfaces.mjs index b9fe4e76f..1cd3754a9 100644 --- a/src/Web/StellaOps.Web/scripts/live-frontdoor-changed-surfaces.mjs +++ b/src/Web/StellaOps.Web/scripts/live-frontdoor-changed-surfaces.mjs @@ -96,14 +96,39 @@ const surfaceConfigs = [ { key: 'release-investigation-deploy-diff', path: '/releases/investigation/deploy-diff', - heading: /deploy diff|deployment diff|missing parameters/i, + heading: /deploy diff|deployment diff|no comparison selected/i, searchQuery: 'deployment diff', + actions: [ + { + key: 'open-deployments', + selector: 'main a:has-text("Open Deployments")', + expectedUrlPattern: '/releases/deployments', + expectedTextPattern: /deployments/i, + requiredUrlFragments: ['tenant=demo-prod'], + }, + { + key: 'open-releases-overview', + selector: 'main a:has-text("Open Releases Overview")', + expectedUrlPattern: '/releases/overview', + expectedTextPattern: /overview|releases/i, + requiredUrlFragments: ['tenant=demo-prod'], + }, + ], }, { key: 'release-investigation-change-trace', path: '/releases/investigation/change-trace', heading: /change trace/i, searchQuery: 'change trace', + actions: [ + { + key: 'open-deployments', + selector: 'main a:has-text("Open Deployments")', + expectedUrlPattern: '/releases/deployments', + expectedTextPattern: /deployments/i, + requiredUrlFragments: ['tenant=demo-prod'], + }, + ], }, { key: 'registry-admin', diff --git a/src/Web/StellaOps.Web/src/app/features/change-trace/change-trace-viewer.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/change-trace/change-trace-viewer.component.spec.ts new file mode 100644 index 000000000..c7bf7a4b6 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/change-trace/change-trace-viewer.component.spec.ts @@ -0,0 +1,106 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; +import { BehaviorSubject, of } from 'rxjs'; + +import { ChangeTraceViewerComponent } from './change-trace-viewer.component'; +import { ChangeTraceService } from './services/change-trace.service'; + +describe('ChangeTraceViewerComponent', () => { + let fixture: ComponentFixture; + let routeParamMap$: BehaviorSubject>; + let queryParamMap$: BehaviorSubject>; + let changeTraceService: jasmine.SpyObj; + + const mockTrace = { + schema: 'stella.change-trace/v1', + subject: { + type: 'artifact.compare', + imageRef: 'acme/demo', + digest: 'sha256:def456', + fromDigest: 'sha256:abc123', + toDigest: 'sha256:def456', + }, + basis: { + analyzedAt: '2026-03-09T00:00:00Z', + policies: ['lineage.compare'], + diffMethods: ['pkg'], + engineVersion: 'sbomservice-lineage-compare', + }, + deltas: [], + summary: { + changedPackages: 0, + packagesAdded: 0, + packagesRemoved: 0, + changedSymbols: 0, + changedBytes: 0, + riskDelta: 0, + verdict: 'neutral', + }, + } as any; + + beforeEach(async () => { + routeParamMap$ = new BehaviorSubject(convertToParamMap({})); + queryParamMap$ = new BehaviorSubject(convertToParamMap({ + tenant: 'demo-prod', + regions: 'us-east', + environments: 'stage', + })); + + changeTraceService = jasmine.createSpyObj('ChangeTraceService', [ + 'getTrace', + 'buildTrace', + 'loadFromFile', + 'exportToFile', + ]) as jasmine.SpyObj; + changeTraceService.getTrace.and.returnValue(of(mockTrace)); + changeTraceService.buildTrace.and.returnValue(of(mockTrace)); + + await TestBed.configureTestingModule({ + imports: [ChangeTraceViewerComponent], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + paramMap: routeParamMap$.asObservable(), + queryParamMap: queryParamMap$.asObservable(), + }, + }, + { + provide: ChangeTraceService, + useValue: changeTraceService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ChangeTraceViewerComponent); + }); + + it('renders the scoped no-comparison workspace when no digests or trace id are present', () => { + fixture.detectChanges(); + + const text = fixture.nativeElement.textContent.replace(/\s+/g, ' '); + expect(text).toContain('No Comparison Selected'); + expect(text).toContain('Open Deployments'); + expect(changeTraceService.buildTrace).not.toHaveBeenCalled(); + }); + + it('auto-builds a trace when comparison digests are present in the route query', () => { + queryParamMap$.next(convertToParamMap({ + tenant: 'demo-prod', + regions: 'us-east', + environments: 'stage', + from: 'sha256:abc123', + to: 'sha256:def456', + })); + + fixture.detectChanges(); + + expect(changeTraceService.buildTrace).toHaveBeenCalledWith( + 'sha256:abc123', + 'sha256:def456', + 'demo-prod', + false, + ); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/change-trace/change-trace-viewer.component.ts b/src/Web/StellaOps.Web/src/app/features/change-trace/change-trace-viewer.component.ts index 494d72903..6fa195aad 100644 --- a/src/Web/StellaOps.Web/src/app/features/change-trace/change-trace-viewer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/change-trace/change-trace-viewer.component.ts @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------------- +import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, @@ -13,8 +14,9 @@ import { signal, computed, } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, RouterLink } from '@angular/router'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { combineLatest } from 'rxjs'; import { SummaryHeaderComponent } from './components/summary-header/summary-header.component'; import { DeltaListComponent } from './components/delta-list/delta-list.component'; @@ -22,6 +24,7 @@ import { ProofPanelComponent } from './components/proof-panel/proof-panel.compon import { ByteDiffViewerComponent } from './components/byte-diff-viewer/byte-diff-viewer.component'; import { ChangeTrace, PackageDelta } from './models/change-trace.models'; import { ChangeTraceService } from './services/change-trace.service'; +import { readReleaseInvestigationQueryState } from '../release-investigation/release-investigation-context'; @Component({ selector: 'stella-change-trace-viewer', @@ -29,7 +32,8 @@ import { ChangeTraceService } from './services/change-trace.service'; SummaryHeaderComponent, DeltaListComponent, ProofPanelComponent, - ByteDiffViewerComponent + ByteDiffViewerComponent, + RouterLink, ], template: `
@@ -97,11 +101,30 @@ import { ChangeTraceService } from './services/change-trace.service'; @if (!trace() && !loading() && !error()) {
-

No Change Trace Loaded

-

Load a change trace file or navigate to a specific trace ID.

- +

{{ emptyStateTitle() }}

+

{{ emptyStateMessage() }}

+
+ @if (hasComparisonContext()) { + + Open Deploy Diff + + } @else { + + Open Deployments + + } + +
} @@ -214,6 +237,13 @@ import { ChangeTraceService } from './services/change-trace.service'; } } + .empty-actions { + display: flex; + justify-content: center; + gap: 12px; + flex-wrap: wrap; + } + @media (max-width: 1200px) { .content-grid { grid-template-columns: 1fr; @@ -247,19 +277,55 @@ export class ChangeTraceViewerComponent implements OnInit { readonly selectedDelta = signal(null); readonly loading = signal(false); readonly error = signal(null); + readonly emptyStateTitle = signal('No Comparison Selected'); + readonly emptyStateMessage = signal('Open this workspace from Deployments or provide from and to digests in the URL.'); + readonly hasComparisonContext = signal(false); + readonly scopeQueryParams = signal>({}); + readonly comparisonQueryParams = signal>({}); // Computed values readonly hasTrace = computed(() => this.trace() !== null); readonly deltas = computed(() => this.trace()?.deltas ?? []); constructor() { - // Subscribe to route params with automatic cleanup - this.route.params.pipe(takeUntilDestroyed()).subscribe((params) => { - const traceId = params['traceId']; - if (traceId) { - this.loadTrace(traceId); - } - }); + combineLatest([this.route.paramMap, this.route.queryParamMap]) + .pipe(takeUntilDestroyed()) + .subscribe(([params, queryParams]) => { + const traceId = params.get('traceId'); + const investigationState = readReleaseInvestigationQueryState(queryParams); + const comparisonQueryParams = { + ...investigationState.scopeQueryParams, + ...(investigationState.fromDigest ? { from: investigationState.fromDigest } : {}), + ...(investigationState.toDigest ? { to: investigationState.toDigest } : {}), + ...(investigationState.fromLabel ? { fromLabel: investigationState.fromLabel } : {}), + ...(investigationState.toLabel ? { toLabel: investigationState.toLabel } : {}), + }; + + this.scopeQueryParams.set(investigationState.scopeQueryParams); + this.comparisonQueryParams.set(comparisonQueryParams); + + if (traceId) { + this.hasComparisonContext.set(true); + this.loadTrace(traceId); + return; + } + + if (investigationState.fromDigest && investigationState.toDigest) { + this.hasComparisonContext.set(true); + this.buildTrace( + investigationState.fromDigest, + investigationState.toDigest, + investigationState.tenantId, + ); + return; + } + + this.resetEmptyState( + 'No Comparison Selected', + 'Open this workspace from Deployments or provide from and to digests in the URL.', + false, + ); + }); } ngOnInit(): void { @@ -277,8 +343,33 @@ export class ChangeTraceViewerComponent implements OnInit { this.loading.set(false); }, error: (err) => { - this.error.set(err.message || 'Failed to load trace'); + this.handleTraceError( + err, + 'No Change Trace Available', + 'The requested change trace could not be reconstructed from the current comparison data.', + ); + }, + }); + } + + buildTrace(fromDigest: string, toDigest: string, tenantId: string): void { + this.loading.set(true); + this.error.set(null); + + this.changeTraceService.buildTrace(fromDigest, toDigest, tenantId, false).subscribe({ + next: (trace) => { + this.trace.set(trace); + this.selectedDelta.set(trace.deltas[0] ?? null); this.loading.set(false); + this.emptyStateTitle.set('No Change Trace Available'); + this.emptyStateMessage.set('The selected comparison did not yield any change-trace entries yet.'); + }, + error: (err) => { + this.handleTraceError( + err, + 'No Change Trace Available', + 'The selected comparison does not have change-trace data in the current stack yet.', + ); }, }); } @@ -320,4 +411,30 @@ export class ChangeTraceViewerComponent implements OnInit { clearError(): void { this.error.set(null); } + + private handleTraceError(err: unknown, emptyTitle: string, emptyMessage: string): void { + if (err instanceof HttpErrorResponse && err.status === 404) { + this.trace.set(null); + this.selectedDelta.set(null); + this.loading.set(false); + this.error.set(null); + this.emptyStateTitle.set(emptyTitle); + this.emptyStateMessage.set(emptyMessage); + return; + } + + const errorMessage = err instanceof Error ? err.message : 'Failed to load trace'; + this.error.set(errorMessage); + this.loading.set(false); + } + + private resetEmptyState(title: string, message: string, hasComparisonContext: boolean): void { + this.trace.set(null); + this.selectedDelta.set(null); + this.loading.set(false); + this.error.set(null); + this.emptyStateTitle.set(title); + this.emptyStateMessage.set(message); + this.hasComparisonContext.set(hasComparisonContext); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/change-trace/services/change-trace.service.ts b/src/Web/StellaOps.Web/src/app/features/change-trace/services/change-trace.service.ts index 62d61261e..db2b8b5e7 100644 --- a/src/Web/StellaOps.Web/src/app/features/change-trace/services/change-trace.service.ts +++ b/src/Web/StellaOps.Web/src/app/features/change-trace/services/change-trace.service.ts @@ -27,13 +27,15 @@ export class ChangeTraceService { * Build a new change trace from two scan IDs. */ buildTrace( - fromScanId: string, - toScanId: string, + fromDigest: string, + toDigest: string, + tenantId: string, includeByteDiff: boolean ): Observable { return this.http.post(`${this.apiUrl}/build`, { - fromScanId, - toScanId, + fromDigest, + toDigest, + tenantId, includeByteDiff, }); } diff --git a/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts index c32884a10..d7e4ee21b 100644 --- a/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts @@ -4,9 +4,10 @@ * @description Unit tests for deploy diff panel component. */ -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; + import { DeployDiffPanelComponent } from './deploy-diff-panel.component'; import { DeployDiffService } from '../../services/deploy-diff.service'; import { @@ -15,16 +16,63 @@ import { PolicyHit, PolicyResult, } from '../../models/deploy-diff.models'; +import { LineageDiffResponse } from '../../../lineage/models/lineage.models'; describe('DeployDiffPanelComponent', () => { let fixture: ComponentFixture; let component: DeployDiffPanelComponent; let httpMock: HttpTestingController; + const compareUrl = '/api/sbomservice/api/v1/lineage/compare'; + const expectCompareRequest = () => + httpMock.expectOne((request) => + request.method === 'GET' + && request.url === compareUrl + && request.params.get('a') === 'sha256:abc123' + && request.params.get('b') === 'sha256:def456' + && request.params.get('tenant') === 'demo-prod', + ); + + const mockCompareResponse: LineageDiffResponse = { + fromDigest: 'sha256:abc123', + toDigest: 'sha256:def456', + computedAt: '2026-01-25T10:00:00Z', + componentDiff: { + added: [ + { + purl: 'pkg:npm/lodash@4.17.21', + name: 'lodash', + currentVersion: '4.17.21', + currentLicense: 'MIT', + changeType: 'added', + }, + ], + removed: [], + changed: [ + { + purl: 'pkg:npm/axios@1.0.0', + name: 'axios', + previousVersion: '0.21.0', + currentVersion: '1.0.0', + changeType: 'version-changed', + }, + ], + sourceTotal: 52, + targetTotal: 53, + }, + vexDeltas: [ + { + cve: 'CVE-2026-1234', + currentStatus: 'affected', + }, + ], + }; + const mockComponentDiff: ComponentDiff = { - id: 'comp-1', + id: 'added-0-lodash', changeType: 'added', name: 'lodash', + purl: 'pkg:npm/lodash@4.17.21', fromVersion: null, toVersion: '4.17.21', licenseChanged: false, @@ -32,12 +80,12 @@ describe('DeployDiffPanelComponent', () => { }; const mockPolicyHit: PolicyHit = { - id: 'hit-1', - gate: 'version-check', + id: 'vex-0', + gate: 'vex-delta', severity: 'high', result: 'fail', - message: 'Major version upgrade detected', - componentIds: ['comp-1'], + message: 'CVE-2026-1234 is affected in the target artifact.', + componentIds: ['added-0-lodash'], }; const mockPolicyResult: PolicyResult = { @@ -45,7 +93,7 @@ describe('DeployDiffPanelComponent', () => { overrideAvailable: true, failCount: 1, warnCount: 0, - passCount: 5, + passCount: 0, }; const mockDiffResult: SbomDiffResult = { @@ -53,15 +101,16 @@ describe('DeployDiffPanelComponent', () => { removed: [], changed: [ { - id: 'comp-2', + id: 'changed-0-axios', changeType: 'changed', name: 'axios', + purl: 'pkg:npm/axios@1.0.0', fromVersion: '0.21.0', toVersion: '1.0.0', licenseChanged: false, versionChange: { type: 'major', - description: 'Major version upgrade', + description: 'Major version change 0.21.0 -> 1.0.0', breaking: true, }, }, @@ -94,80 +143,79 @@ describe('DeployDiffPanelComponent', () => { fixture = TestBed.createComponent(DeployDiffPanelComponent); component = fixture.componentInstance; - // Set required inputs fixture.componentRef.setInput('fromDigest', 'sha256:abc123'); fixture.componentRef.setInput('toDigest', 'sha256:def456'); }); + const loadDiff = async () => { + fixture.detectChanges(); + + const req = expectCompareRequest(); + req.flush(mockCompareResponse); + for (let attempt = 0; attempt < 5; attempt += 1) { + await fixture.whenStable(); + await Promise.resolve(); + fixture.detectChanges(); + if (!component.loading()) { + break; + } + } + }; + + const loadError = async (status: number, statusText: string, message: string) => { + fixture.detectChanges(); + + const req = expectCompareRequest(); + req.flush({ message }, { status, statusText }); + for (let attempt = 0; attempt < 5; attempt += 1) { + await fixture.whenStable(); + await Promise.resolve(); + fixture.detectChanges(); + if (!component.loading()) { + break; + } + } + }; + afterEach(() => { httpMock.verify(); fixture.destroy(); }); describe('DD-008: Container assembly', () => { - it('renders header with version info', fakeAsync(() => { - fixture.detectChanges(); - - // Respond to diff request - const req = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - req.flush(mockDiffResult); - tick(); - fixture.detectChanges(); + it('renders header with version info', async () => { + await loadDiff(); const header = fixture.nativeElement.querySelector('.diff-panel__header'); expect(header).toBeTruthy(); const title = header.querySelector('.header-title'); expect(title.textContent).toContain('Deployment Diff'); - })); + }); - it('shows summary strip with counts', fakeAsync(() => { - fixture.detectChanges(); - - const req = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - req.flush(mockDiffResult); - tick(); - fixture.detectChanges(); + it('shows summary strip with counts', async () => { + await loadDiff(); const summary = fixture.nativeElement.querySelector('.diff-panel__summary'); expect(summary).toBeTruthy(); - expect(summary.textContent).toContain('1'); expect(summary.textContent).toContain('added'); expect(summary.textContent).toContain('policy failure'); - })); + }); - it('integrates side-by-side viewer', fakeAsync(() => { - fixture.detectChanges(); + it('integrates side-by-side viewer', async () => { + await loadDiff(); - const req = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - req.flush(mockDiffResult); - tick(); - fixture.detectChanges(); - - const viewer = fixture.nativeElement.querySelector('app-sbom-side-by-side'); + const viewer = fixture.nativeElement.querySelector('.sbom-side-by-side'); expect(viewer).toBeTruthy(); - })); + }); - it('shows action bar at bottom', fakeAsync(() => { - fixture.detectChanges(); - - const req = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - req.flush(mockDiffResult); - tick(); - fixture.detectChanges(); + it('shows action bar at bottom', async () => { + await loadDiff(); const actionBar = fixture.nativeElement.querySelector('app-deploy-action-bar'); expect(actionBar).toBeTruthy(); - })); + }); }); describe('Loading state', () => { @@ -176,92 +224,54 @@ describe('DeployDiffPanelComponent', () => { const skeleton = fixture.nativeElement.querySelector('.loading-skeleton'); expect(skeleton).toBeTruthy(); - - // Don't flush HTTP yet - check loading state - httpMock.expectOne('/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'); + expectCompareRequest(); }); - it('hides loading skeleton after data loads', fakeAsync(() => { - fixture.detectChanges(); - - const req = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - req.flush(mockDiffResult); - tick(); - fixture.detectChanges(); + it('hides loading skeleton after data loads', async () => { + await loadDiff(); const skeleton = fixture.nativeElement.querySelector('.loading-skeleton'); expect(skeleton).toBeFalsy(); - })); + }); }); describe('Error state', () => { - it('shows error state on API failure', fakeAsync(() => { - fixture.detectChanges(); - - const req = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - req.flush({ message: 'Not found' }, { status: 404, statusText: 'Not Found' }); - tick(); - fixture.detectChanges(); + it('shows error state on API failure', async () => { + await loadError(404, 'Not Found', 'Not found'); const errorState = fixture.nativeElement.querySelector('.error-state'); expect(errorState).toBeTruthy(); expect(errorState.textContent).toContain('Failed to load diff'); - })); + }); - it('shows retry button on error', fakeAsync(() => { - fixture.detectChanges(); - - const req = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - req.flush({ message: 'Error' }, { status: 500, statusText: 'Server Error' }); - tick(); - fixture.detectChanges(); + it('shows retry button on error', async () => { + await loadError(500, 'Server Error', 'Error'); const retryBtn = fixture.nativeElement.querySelector('.retry-btn'); expect(retryBtn).toBeTruthy(); expect(retryBtn.textContent).toContain('Retry'); - })); + }); }); describe('Action handling', () => { - it('opens override dialog on allow_override click', fakeAsync(() => { - fixture.detectChanges(); + it('opens override dialog on allow_override click', async () => { + await loadDiff(); - const req = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - req.flush(mockDiffResult); - tick(); - fixture.detectChanges(); - - // Trigger action component.onActionClick('allow_override'); fixture.detectChanges(); expect(component.showOverrideDialog()).toBeTrue(); - })); + }); - it('emits actionTaken on block', fakeAsync(() => { + it('emits actionTaken on block', async () => { let emittedAction: any = null; - component.actionTaken.subscribe(a => (emittedAction = a)); + component.actionTaken.subscribe((action) => { + emittedAction = action; + }); - fixture.detectChanges(); + await loadDiff(); - const diffReq = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - diffReq.flush(mockDiffResult); - tick(); - fixture.detectChanges(); - - // Trigger block action component.onActionClick('block'); - tick(); const blockReq = httpMock.expectOne('/api/v1/deploy/block'); blockReq.flush({ @@ -270,23 +280,16 @@ describe('DeployDiffPanelComponent', () => { toDigest: 'sha256:def456', timestamp: '2026-01-25T10:00:00Z', }); - tick(); + await fixture.whenStable(); expect(emittedAction).toBeTruthy(); expect(emittedAction.type).toBe('block'); - })); + }); }); describe('Expand/collapse', () => { - it('toggles expanded component', fakeAsync(() => { - fixture.detectChanges(); - - const req = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - req.flush(mockDiffResult); - tick(); - fixture.detectChanges(); + it('toggles expanded component', async () => { + await loadDiff(); expect(component.expandedComponentId()).toBeUndefined(); @@ -295,29 +298,18 @@ describe('DeployDiffPanelComponent', () => { component.onExpandToggle('comp-1'); expect(component.expandedComponentId()).toBeUndefined(); - })); + }); }); describe('Refresh', () => { - it('clears cache and refetches on refresh', fakeAsync(() => { - fixture.detectChanges(); + it('clears cache and refetches on refresh', async () => { + await loadDiff(); - const req1 = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - req1.flush(mockDiffResult); - tick(); - fixture.detectChanges(); - - // Refresh component.refresh(); - tick(); - const req2 = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - expect(req2).toBeTruthy(); - req2.flush(mockDiffResult); - })); + const req2 = expectCompareRequest(); + req2.flush(mockCompareResponse); + await fixture.whenStable(); + }); }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.ts index 5d36898e7..1993bb7f2 100644 --- a/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.ts @@ -456,6 +456,9 @@ export class DeployDiffPanelComponent implements OnInit { /** Optional to version label */ readonly toLabel = input(); + /** Tenant context for the comparison */ + readonly tenantId = input('demo-prod'); + /** Current signer identity (for override) */ readonly currentSigner = input(); @@ -498,7 +501,7 @@ export class DeployDiffPanelComponent implements OnInit { const from = this.fromDigest(); const to = this.toDigest(); if (from && to) { - this.fetchDiff(from, to); + this.fetchDiff(from, to, this.tenantId()); } }); } @@ -508,12 +511,12 @@ export class DeployDiffPanelComponent implements OnInit { } /** Fetch diff data */ - async fetchDiff(from: string, to: string): Promise { + async fetchDiff(from: string, to: string, tenantId: string): Promise { this.loading.set(true); this.error.set(null); try { - const result = await this.diffService.fetchDiff({ fromDigest: from, toDigest: to }); + const result = await this.diffService.fetchDiff({ fromDigest: from, toDigest: to, tenantId }); this.diffResult.set(result); } catch (err) { this.error.set(err instanceof Error ? err.message : 'Failed to fetch diff'); @@ -526,7 +529,7 @@ export class DeployDiffPanelComponent implements OnInit { /** Refresh diff */ refresh(): void { this.diffService.clearCache(); - this.fetchDiff(this.fromDigest(), this.toDigest()); + this.fetchDiff(this.fromDigest(), this.toDigest(), this.tenantId()); } /** Handle expand toggle */ diff --git a/src/Web/StellaOps.Web/src/app/features/deploy-diff/models/deploy-diff.models.ts b/src/Web/StellaOps.Web/src/app/features/deploy-diff/models/deploy-diff.models.ts index 1f1e21970..051a294fb 100644 --- a/src/Web/StellaOps.Web/src/app/features/deploy-diff/models/deploy-diff.models.ts +++ b/src/Web/StellaOps.Web/src/app/features/deploy-diff/models/deploy-diff.models.ts @@ -16,6 +16,8 @@ export interface SbomDiffRequest { readonly fromDigest: string; /** New version SBOM digest */ readonly toDigest: string; + /** Tenant identifier used for lineage compare */ + readonly tenantId?: string; } /** diff --git a/src/Web/StellaOps.Web/src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts b/src/Web/StellaOps.Web/src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts new file mode 100644 index 000000000..649a9a782 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts @@ -0,0 +1,43 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; + +import { DeployDiffPage } from './deploy-diff.page'; + +describe('DeployDiffPage', () => { + let fixture: ComponentFixture; + let queryParamMap$: BehaviorSubject>; + + beforeEach(async () => { + queryParamMap$ = new BehaviorSubject(convertToParamMap({ + tenant: 'demo-prod', + regions: 'us-east', + environments: 'stage', + })); + + await TestBed.configureTestingModule({ + imports: [DeployDiffPage], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + queryParamMap: queryParamMap$.asObservable(), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DeployDiffPage); + fixture.detectChanges(); + }); + + it('renders the scoped no-comparison workspace instead of the legacy missing-parameters failure', () => { + const text = fixture.nativeElement.textContent.replace(/\s+/g, ' '); + + expect(text).toContain('No Comparison Selected'); + expect(text).toContain('Open this workspace from a deployment'); + expect(text).toContain('Open Deployments'); + expect(text).not.toContain('Missing Parameters'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/deploy-diff/pages/deploy-diff.page.ts b/src/Web/StellaOps.Web/src/app/features/deploy-diff/pages/deploy-diff.page.ts index 1a921a409..35046113a 100644 --- a/src/Web/StellaOps.Web/src/app/features/deploy-diff/pages/deploy-diff.page.ts +++ b/src/Web/StellaOps.Web/src/app/features/deploy-diff/pages/deploy-diff.page.ts @@ -9,6 +9,9 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { DeployDiffPanelComponent } from '../components/deploy-diff-panel/deploy-diff-panel.component'; import { DeployAction, SignerIdentity } from '../models/deploy-diff.models'; +import { + readReleaseInvestigationQueryState, +} from '../../release-investigation/release-investigation-context'; /** * Deploy diff page component. @@ -28,7 +31,13 @@ import { DeployAction, SignerIdentity } from '../models/deploy-diff.models';
} @@ -195,6 +218,25 @@ import { DeployAction, SignerIdentity } from '../models/deploy-diff.models'; background: var(--color-primary-bg); } } + + .empty-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + justify-content: center; + } + + .secondary-link { + color: var(--color-text-secondary); + font-size: 0.875rem; + text-decoration: none; + + &:hover { + color: var(--color-brand-primary); + text-decoration: underline; + } + } `], }) export class DeployDiffPage implements OnInit { @@ -213,6 +255,12 @@ export class DeployDiffPage implements OnInit { /** To label query param (optional) */ readonly toLabel = signal(undefined); + /** Tenant context for lineage compare */ + readonly tenantId = signal('demo-prod'); + + /** Scope query params preserved across investigation navigation */ + readonly scopeQueryParams = signal>({}); + /** Current user/signer - in production would come from auth service */ readonly currentSigner = signal({ userId: 'user-123', @@ -228,10 +276,13 @@ export class DeployDiffPage implements OnInit { ngOnInit(): void { // Subscribe to query params this.route.queryParamMap.subscribe(params => { - this.fromDigest.set(params.get('from')); - this.toDigest.set(params.get('to')); - this.fromLabel.set(params.get('fromLabel') ?? undefined); - this.toLabel.set(params.get('toLabel') ?? undefined); + const state = readReleaseInvestigationQueryState(params); + this.scopeQueryParams.set(state.scopeQueryParams); + this.tenantId.set(state.tenantId); + this.fromDigest.set(state.fromDigest); + this.toDigest.set(state.toDigest); + this.fromLabel.set(state.fromLabel); + this.toLabel.set(state.toLabel); }); } @@ -245,15 +296,19 @@ export class DeployDiffPage implements OnInit { // Could navigate to blocked deployments list break; case 'allow_override': - // Could navigate to deployment progress - this.router.navigate(['/deploy', 'progress'], { - queryParams: { digest: this.toDigest() }, + this.router.navigate(['/releases/approvals'], { + queryParams: { + ...this.scopeQueryParams(), + digest: this.toDigest(), + }, }); break; case 'schedule_canary': - // Navigate to canary monitoring - this.router.navigate(['/deploy', 'canary'], { - queryParams: { digest: this.toDigest() }, + this.router.navigate(['/releases/deployments'], { + queryParams: { + ...this.scopeQueryParams(), + digest: this.toDigest(), + }, }); break; } diff --git a/src/Web/StellaOps.Web/src/app/features/deploy-diff/services/deploy-diff.service.spec.ts b/src/Web/StellaOps.Web/src/app/features/deploy-diff/services/deploy-diff.service.spec.ts index 6b36fed9e..646207872 100644 --- a/src/Web/StellaOps.Web/src/app/features/deploy-diff/services/deploy-diff.service.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/deploy-diff/services/deploy-diff.service.spec.ts @@ -4,38 +4,99 @@ * @description Unit tests for deploy diff service. */ -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { DeployDiffService } from './deploy-diff.service'; -import { SbomDiffResult, ComponentDiff, PolicyHit } from '../models/deploy-diff.models'; +import { SbomDiffResult } from '../models/deploy-diff.models'; +import { LineageDiffResponse } from '../../lineage/models/lineage.models'; describe('DeployDiffService', () => { let service: DeployDiffService; let httpMock: HttpTestingController; + const compareUrl = '/api/sbomservice/api/v1/lineage/compare'; + + const expectCompareRequest = (fromDigest: string, toDigest: string, tenantId = 'demo-prod') => + httpMock.expectOne((request) => + request.method === 'GET' + && request.url === compareUrl + && request.params.get('a') === fromDigest + && request.params.get('b') === toDigest + && request.params.get('tenant') === tenantId, + ); + + const mockCompareResponse: LineageDiffResponse = { + fromDigest: 'sha256:abc123', + toDigest: 'sha256:def456', + computedAt: '2026-01-25T10:00:00Z', + componentDiff: { + added: [ + { + purl: 'pkg:npm/new-package@1.0.0', + name: 'new-package', + currentVersion: '1.0.0', + changeType: 'added', + }, + ], + removed: [], + changed: [ + { + purl: 'pkg:npm/axios@1.0.0', + name: 'axios', + previousVersion: '0.21.0', + currentVersion: '1.0.0', + changeType: 'version-changed', + }, + ], + sourceTotal: 50, + targetTotal: 51, + }, + vexDeltas: [ + { + cve: 'CVE-2026-1234', + currentStatus: 'affected', + }, + ], + }; const mockDiffResult: SbomDiffResult = { added: [ { - id: 'comp-1', + id: 'added-0-new-package', changeType: 'added', name: 'new-package', + purl: 'pkg:npm/new-package@1.0.0', fromVersion: null, toVersion: '1.0.0', licenseChanged: false, }, ], removed: [], - changed: [], - unchanged: 50, + changed: [ + { + id: 'changed-0-axios', + changeType: 'changed', + name: 'axios', + purl: 'pkg:npm/axios@1.0.0', + fromVersion: '0.21.0', + toVersion: '1.0.0', + licenseChanged: false, + versionChange: { + type: 'major', + description: 'Major version change 0.21.0 -> 1.0.0', + breaking: true, + }, + }, + ], + unchanged: 0, policyHits: [ { - id: 'hit-1', - gate: 'version-check', + id: 'vex-0', + gate: 'vex-delta', severity: 'high', result: 'fail', - message: 'Version check failed', - componentIds: ['comp-1'], + message: 'CVE-2026-1234 is affected in the target artifact.', + componentIds: ['added-0-new-package'], }, ], policyResult: { @@ -43,7 +104,7 @@ describe('DeployDiffService', () => { overrideAvailable: true, failCount: 1, warnCount: 0, - passCount: 5, + passCount: 0, }, metadata: { fromDigest: 'sha256:abc123', @@ -72,108 +133,121 @@ describe('DeployDiffService', () => { }); describe('DD-002: fetchDiff', () => { - it('calls diff API with correct params', fakeAsync(async () => { + it('calls diff API with correct params', async () => { const promise = service.fetchDiff({ fromDigest: 'sha256:abc123', toDigest: 'sha256:def456', }); - const req = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); + const req = expectCompareRequest('sha256:abc123', 'sha256:def456'); expect(req.request.method).toBe('GET'); - req.flush(mockDiffResult); + req.flush(mockCompareResponse); const result = await promise; - expect(result).toEqual(mockDiffResult); - })); + expect(result).toMatchObject({ + added: [expect.objectContaining(mockDiffResult.added[0])], + removed: [], + changed: [expect.objectContaining(mockDiffResult.changed[0])], + policyHits: [expect.objectContaining({ + id: 'vex-0', + gate: 'vex-delta', + severity: 'high', + result: 'fail', + message: 'CVE-2026-1234 is affected in the target artifact.', + })], + policyResult: mockDiffResult.policyResult, + metadata: mockDiffResult.metadata, + }); + }); - it('maps response to typed model', fakeAsync(async () => { + it('maps response to typed model', async () => { const promise = service.fetchDiff({ fromDigest: 'sha256:abc123', toDigest: 'sha256:def456', }); - const req = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - req.flush(mockDiffResult); + const req = expectCompareRequest('sha256:abc123', 'sha256:def456'); + req.flush(mockCompareResponse); const result = await promise; expect(result.added.length).toBe(1); expect(result.added[0].name).toBe('new-package'); - expect(result.policyHits[0].gate).toBe('version-check'); - })); + expect(result.policyHits[0].gate).toBe('vex-delta'); + }); - it('caches result for repeated comparisons', fakeAsync(async () => { - // First call + it('caches result for repeated comparisons', async () => { const promise1 = service.fetchDiff({ fromDigest: 'sha256:abc123', toDigest: 'sha256:def456', }); - const req1 = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - req1.flush(mockDiffResult); + const req1 = expectCompareRequest('sha256:abc123', 'sha256:def456'); + req1.flush(mockCompareResponse); await promise1; - // Second call should use cache const promise2 = service.fetchDiff({ fromDigest: 'sha256:abc123', toDigest: 'sha256:def456', }); - // No HTTP request should be made - httpMock.expectNone('/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'); + httpMock.expectNone((request) => request.url === compareUrl); const result2 = await promise2; - expect(result2).toEqual(mockDiffResult); - })); + expect(result2).toMatchObject({ + added: [expect.objectContaining(mockDiffResult.added[0])], + removed: [], + changed: [expect.objectContaining(mockDiffResult.changed[0])], + policyHits: [expect.objectContaining({ + id: 'vex-0', + gate: 'vex-delta', + severity: 'high', + result: 'fail', + message: 'CVE-2026-1234 is affected in the target artifact.', + })], + policyResult: mockDiffResult.policyResult, + metadata: mockDiffResult.metadata, + }); + }); - it('handles invalid digests with error', fakeAsync(async () => { + it('handles invalid digests with error', async () => { const promise = service.fetchDiff({ fromDigest: 'invalid', toDigest: 'also-invalid', }); - const req = httpMock.expectOne( - '/api/v1/sbom/diff?from=invalid&to=also-invalid' - ); + const req = expectCompareRequest('invalid', 'also-invalid'); req.flush({ message: 'Invalid digest format' }, { status: 400, statusText: 'Bad Request' }); try { await promise; - fail('Should have thrown'); + throw new Error('Should have thrown'); } catch (err: any) { expect(err.message).toContain('Invalid'); } - })); + }); - it('handles 404 with appropriate message', fakeAsync(async () => { + it('handles 404 with appropriate message', async () => { const promise = service.fetchDiff({ fromDigest: 'sha256:notfound', toDigest: 'sha256:def456', }); - const req = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:notfound&to=sha256:def456' - ); + const req = expectCompareRequest('sha256:notfound', 'sha256:def456'); req.flush(null, { status: 404, statusText: 'Not Found' }); try { await promise; - fail('Should have thrown'); + throw new Error('Should have thrown'); } catch (err: any) { - expect(err.message).toContain('not found'); + expect(err.message).toContain('No lineage comparison data is available'); } - })); + }); }); describe('submitOverride', () => { - it('calls override API', fakeAsync(async () => { + it('calls override API', async () => { const promise = service.submitOverride( 'sha256:abc123', 'sha256:def456', @@ -203,11 +277,11 @@ describe('DeployDiffService', () => { const result = await promise; expect(result.type).toBe('allow_override'); - })); + }); }); describe('blockDeployment', () => { - it('calls block API', fakeAsync(async () => { + it('calls block API', async () => { const promise = service.blockDeployment('sha256:abc123', 'sha256:def456'); const req = httpMock.expectOne('/api/v1/deploy/block'); @@ -222,11 +296,11 @@ describe('DeployDiffService', () => { const result = await promise; expect(result.type).toBe('block'); - })); + }); }); describe('scheduleCanary', () => { - it('calls canary API', fakeAsync(async () => { + it('calls canary API', async () => { const promise = service.scheduleCanary( 'sha256:abc123', 'sha256:def456', @@ -254,7 +328,7 @@ describe('DeployDiffService', () => { const result = await promise; expect(result.type).toBe('schedule_canary'); - })); + }); }); describe('filterComponents', () => { @@ -266,9 +340,8 @@ describe('DeployDiffService', () => { it('filters by policy result', () => { const result = service.filterComponents(mockDiffResult, 'all', 'failing', ''); - expect(result.every(c => mockDiffResult.policyHits.some( - h => h.result === 'fail' && h.componentIds?.includes(c.id) - ))).toBeTrue(); + expect(result.length).toBe(1); + expect(result[0].id).toBe('added-0-new-package'); }); it('filters by search query', () => { @@ -280,9 +353,9 @@ describe('DeployDiffService', () => { describe('getPolicyHitsForComponent', () => { it('returns hits for specific component', () => { - const hits = service.getPolicyHitsForComponent(mockDiffResult, 'comp-1'); + const hits = service.getPolicyHitsForComponent(mockDiffResult, 'added-0-new-package'); expect(hits.length).toBe(1); - expect(hits[0].id).toBe('hit-1'); + expect(hits[0].id).toBe('vex-0'); }); it('returns global hits (no componentIds)', () => { @@ -306,17 +379,14 @@ describe('DeployDiffService', () => { }); describe('clearCache', () => { - it('clears cached results', fakeAsync(async () => { - // First call - caches result + it('clears cached results', async () => { const promise1 = service.fetchDiff({ fromDigest: 'sha256:abc123', toDigest: 'sha256:def456', }); - const req1 = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - req1.flush(mockDiffResult); + const req1 = expectCompareRequest('sha256:abc123', 'sha256:def456'); + req1.flush(mockCompareResponse); await promise1; // Clear cache @@ -328,16 +398,14 @@ describe('DeployDiffService', () => { toDigest: 'sha256:def456', }); - const req2 = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - req2.flush(mockDiffResult); + const req2 = expectCompareRequest('sha256:abc123', 'sha256:def456'); + req2.flush(mockCompareResponse); await promise2; - })); + }); }); describe('loading state', () => { - it('sets loading true during fetch', fakeAsync(() => { + it('sets loading true during fetch', async () => { expect(service.loading()).toBeFalse(); const promise = service.fetchDiff({ @@ -347,13 +415,11 @@ describe('DeployDiffService', () => { expect(service.loading()).toBeTrue(); - const req = httpMock.expectOne( - '/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456' - ); - req.flush(mockDiffResult); - tick(); + const req = expectCompareRequest('sha256:abc123', 'sha256:def456'); + req.flush(mockCompareResponse); + await promise; expect(service.loading()).toBeFalse(); - })); + }); }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/deploy-diff/services/deploy-diff.service.ts b/src/Web/StellaOps.Web/src/app/features/deploy-diff/services/deploy-diff.service.ts index b6db63f64..58ac631c5 100644 --- a/src/Web/StellaOps.Web/src/app/features/deploy-diff/services/deploy-diff.service.ts +++ b/src/Web/StellaOps.Web/src/app/features/deploy-diff/services/deploy-diff.service.ts @@ -16,6 +16,13 @@ import { ComponentDiff, PolicyHit, } from '../models/deploy-diff.models'; +import { LineageGraphService } from '../../lineage/services/lineage-graph.service'; +import { + ComponentChange as LineageComponentChange, + LineageDiffResponse, + ReachabilityDelta, + VexDelta, +} from '../../lineage/models/lineage.models'; /** * Cache entry for diff results. @@ -40,9 +47,11 @@ interface DiffCacheEntry { }) export class DeployDiffService { private readonly http = inject(HttpClient); + private readonly lineageGraphService = inject(LineageGraphService); /** API base URL */ private readonly apiBase = '/api/v1'; + private readonly defaultTenantId = 'demo-prod'; /** Cache TTL in milliseconds (5 minutes) */ private readonly cacheTtlMs = 5 * 60 * 1000; @@ -69,7 +78,8 @@ export class DeployDiffService { * @returns Promise resolving to diff result */ async fetchDiff(request: SbomDiffRequest): Promise { - const cacheKey = this.getCacheKey(request.fromDigest, request.toDigest); + const tenantId = (request.tenantId ?? this.defaultTenantId).trim() || this.defaultTenantId; + const cacheKey = this.getCacheKey(request.fromDigest, request.toDigest, tenantId); // Check cache first const cached = this.getFromCache(cacheKey); @@ -82,17 +92,14 @@ export class DeployDiffService { this.error.set(null); try { - const result = await firstValueFrom( - this.http.get( - `${this.apiBase}/sbom/diff`, - { - params: { - from: request.fromDigest, - to: request.toDigest, - }, - } - ) + const lineageDiff = await firstValueFrom( + this.lineageGraphService.compare( + request.fromDigest, + request.toDigest, + tenantId, + ), ); + const result = this.mapLineageDiffToDeployDiff(lineageDiff); // Cache the result this.addToCache(cacheKey, result); @@ -259,8 +266,8 @@ export class DeployDiffService { /** * Generates cache key from digests. */ - private getCacheKey(from: string, to: string): string { - return `${from}:${to}`; + private getCacheKey(from: string, to: string, tenantId: string = this.defaultTenantId): string { + return `${tenantId}:${from}:${to}`; } /** @@ -306,12 +313,18 @@ export class DeployDiffService { if (err.error?.message) { return err.error.message; } + if (typeof err.error?.error === 'string') { + return err.error.error; + } if (err.status === 404) { - return 'SBOM diff endpoint not found. Please verify the digests are valid.'; + return 'No lineage comparison data is available for the selected release artifacts yet.'; } if (err.status === 400) { return 'Invalid request. Please check the digest format.'; } + if (err.status === 401) { + return 'Authentication is required to load this comparison.'; + } return `Server error: ${err.statusText || 'Unknown error'}`; } if (err instanceof Error) { @@ -319,4 +332,174 @@ export class DeployDiffService { } return 'An unexpected error occurred'; } + + private mapLineageDiffToDeployDiff(lineageDiff: LineageDiffResponse): SbomDiffResult { + const added = lineageDiff.componentDiff?.added.map((change, index) => + this.mapLineageChange(change, 'added', index), + ) ?? []; + const removed = lineageDiff.componentDiff?.removed.map((change, index) => + this.mapLineageChange(change, 'removed', index), + ) ?? []; + const changed = lineageDiff.componentDiff?.changed.map((change, index) => + this.mapLineageChange(change, 'changed', index), + ) ?? []; + const policyHits = this.mapPolicyHits(lineageDiff); + const failCount = policyHits.filter((hit) => hit.result === 'fail').length; + const warnCount = policyHits.filter((hit) => hit.result === 'warn').length; + + return { + added, + removed, + changed, + unchanged: 0, + policyHits, + policyResult: { + allowed: failCount === 0, + overrideAvailable: failCount > 0, + failCount, + warnCount, + passCount: failCount === 0 && warnCount === 0 ? 1 : 0, + }, + metadata: { + fromDigest: lineageDiff.fromDigest, + toDigest: lineageDiff.toDigest, + computedAt: lineageDiff.computedAt, + fromTotalComponents: lineageDiff.componentDiff?.sourceTotal ?? 0, + toTotalComponents: lineageDiff.componentDiff?.targetTotal ?? 0, + }, + }; + } + + private mapLineageChange( + change: LineageComponentChange, + changeType: ComponentDiff['changeType'], + index: number, + ): ComponentDiff { + const fromVersion = changeType === 'added' ? null : change.previousVersion ?? null; + const toVersion = changeType === 'removed' ? null : change.currentVersion ?? null; + + return { + id: `${changeType}-${index}-${change.name}`, + changeType, + name: change.name, + purl: change.purl, + fromVersion, + toVersion, + fromLicense: change.previousLicense, + toLicense: change.currentLicense, + licenseChanged: (change.previousLicense ?? null) !== (change.currentLicense ?? null), + versionChange: changeType === 'changed' + ? this.classifyVersionChange(change.previousVersion, change.currentVersion) + : undefined, + }; + } + + private classifyVersionChange( + fromVersion?: string, + toVersion?: string, + ): ComponentDiff['versionChange'] { + if (!fromVersion || !toVersion || fromVersion === toVersion) { + return { + type: 'unknown', + description: 'Version lineage unavailable', + breaking: false, + }; + } + + const fromParts = fromVersion.split('.').map((part) => Number.parseInt(part, 10)); + const toParts = toVersion.split('.').map((part) => Number.parseInt(part, 10)); + + if (Number.isFinite(fromParts[0]) && Number.isFinite(toParts[0]) && toParts[0] > fromParts[0]) { + return { + type: 'major', + description: `Major version change ${fromVersion} -> ${toVersion}`, + breaking: true, + }; + } + + if (Number.isFinite(fromParts[1]) && Number.isFinite(toParts[1]) && toParts[1] > fromParts[1]) { + return { + type: 'minor', + description: `Minor version change ${fromVersion} -> ${toVersion}`, + breaking: false, + }; + } + + if (Number.isFinite(fromParts[2]) && Number.isFinite(toParts[2]) && toParts[2] > fromParts[2]) { + return { + type: 'patch', + description: `Patch version change ${fromVersion} -> ${toVersion}`, + breaking: false, + }; + } + + return { + type: 'unknown', + description: `Version change ${fromVersion} -> ${toVersion}`, + breaking: false, + }; + } + + private mapPolicyHits(lineageDiff: LineageDiffResponse): PolicyHit[] { + return [ + ...this.mapVexPolicyHits(lineageDiff.vexDeltas), + ...this.mapReachabilityPolicyHits(lineageDiff.reachabilityDeltas), + ]; + } + + private mapVexPolicyHits(vexDeltas: VexDelta[] | undefined): PolicyHit[] { + return (vexDeltas ?? []) + .map((delta, index): PolicyHit | null => { + if (delta.currentStatus === 'affected') { + return { + id: `vex-${index}`, + gate: 'vex-delta', + severity: 'high', + result: 'fail', + message: `${delta.cve} is affected in the target artifact.`, + }; + } + + if (delta.currentStatus === 'under_investigation' || delta.currentStatus === 'unknown') { + return { + id: `vex-${index}`, + gate: 'vex-delta', + severity: 'medium', + result: 'warn', + message: `${delta.cve} requires investigation before rollout.`, + }; + } + + return null; + }) + .filter((hit): hit is PolicyHit => hit !== null); + } + + private mapReachabilityPolicyHits(reachabilityDeltas: ReachabilityDelta[] | undefined): PolicyHit[] { + return (reachabilityDeltas ?? []) + .map((delta, index): PolicyHit | null => { + if (delta.currentReachable) { + return { + id: `reachability-${index}`, + gate: 'reachability-delta', + severity: 'critical', + result: 'fail', + message: `${delta.cve} is reachable in the target artifact.`, + }; + } + + if (delta.previousReachable) { + return { + id: `reachability-${index}`, + gate: 'reachability-delta', + severity: 'low', + result: 'warn', + message: `${delta.cve} changed reachability state and should be reviewed.`, + }; + } + + return null; + }) + .filter((hit): hit is PolicyHit => hit !== null); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/release-investigation/release-investigation-context.ts b/src/Web/StellaOps.Web/src/app/features/release-investigation/release-investigation-context.ts new file mode 100644 index 000000000..07ad9a50c --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/release-investigation/release-investigation-context.ts @@ -0,0 +1,44 @@ +import { ParamMap, Params } from '@angular/router'; + +const DEFAULT_TENANT_ID = 'demo-prod'; +const SCOPE_QUERY_KEYS = [ + 'tenant', + 'regions', + 'region', + 'environments', + 'environment', + 'timeWindow', +] as const; + +export interface ReleaseInvestigationQueryState { + tenantId: string; + scopeQueryParams: Params; + fromDigest: string | null; + toDigest: string | null; + fromLabel?: string; + toLabel?: string; +} + +export function readReleaseInvestigationQueryState(queryParams: ParamMap): ReleaseInvestigationQueryState { + const scopeQueryParams: Params = {}; + + for (const key of SCOPE_QUERY_KEYS) { + const value = queryParams.get(key); + if (value) { + scopeQueryParams[key] = value; + } + } + + return { + tenantId: (queryParams.get('tenant') ?? DEFAULT_TENANT_ID).trim(), + scopeQueryParams, + fromDigest: queryParams.get('from'), + toDigest: queryParams.get('to'), + fromLabel: queryParams.get('fromLabel') ?? undefined, + toLabel: queryParams.get('toLabel') ?? undefined, + }; +} + +export function hasReleaseInvestigationDigests(state: ReleaseInvestigationQueryState): boolean { + return !!state.fromDigest && !!state.toDigest; +} diff --git a/src/Web/StellaOps.Web/tsconfig.spec.features.json b/src/Web/StellaOps.Web/tsconfig.spec.features.json index a4db98a64..281130520 100644 --- a/src/Web/StellaOps.Web/tsconfig.spec.features.json +++ b/src/Web/StellaOps.Web/tsconfig.spec.features.json @@ -5,6 +5,10 @@ "src/test-setup.ts", "src/app/core/api/first-signal.client.spec.ts", "src/app/core/console/console-status.service.spec.ts", + "src/app/features/change-trace/change-trace-viewer.component.spec.ts", + "src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts", + "src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts", + "src/app/features/deploy-diff/services/deploy-diff.service.spec.ts", "src/app/features/policy-simulation/simulation-dashboard.component.spec.ts", "src/app/features/registry-admin/registry-admin.component.spec.ts", "src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts"