Repair release investigation workspace contracts

This commit is contained in:
master
2026-03-09 23:19:42 +02:00
parent 3ecafc49a3
commit 359fafa9da
20 changed files with 1806 additions and 284 deletions

View File

@@ -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=<fromDigest>&b=<toDigest>&tenant=<tenantId>`
- **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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<WebApplicationFactory<StellaOps.SbomService.Program>>
{
private const string TenantId = "github.com/acme/change-trace";
private readonly WebApplicationFactory<StellaOps.SbomService.Program> _factory;
public ChangeTraceCompatibilityEndpointsTests(WebApplicationFactory<StellaOps.SbomService.Program> 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<ChangeTraceDocument>();
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<ChangeTraceDocument>();
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<ChangeTraceDocument>();
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<JsonElement>();
payload.GetProperty("error").GetString().Should().Be("invalid traceId");
}
private async Task<SbomUploadResponse> 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<SbomUploadResponse>();
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;
}
}

View File

@@ -1079,6 +1079,103 @@ app.MapGet("/api/v1/lineage/compare", async Task<IResult> (
.RequireAuthorization(SbomPolicies.Read)
.RequireTenant();
app.MapPost("/api/change-traces/build", async Task<IResult> (
[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<IResult> (
[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

View File

@@ -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<ChangeTraceLookup>(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<ChangeTracePackageDeltaDocument> BuildPackageDeltas(
LineageCompareResponse compare,
string riskTrend)
{
var deltas = new List<ChangeTracePackageDeltaDocument>();
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<ChangeTraceSymbolDeltaDocument>(),
Bytes = Array.Empty<ChangeTraceByteDeltaDocument>(),
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<ChangeTraceSymbolDeltaDocument>(),
Bytes = Array.Empty<ChangeTraceByteDeltaDocument>(),
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<string>? fixedVulnerabilities = null,
IReadOnlyList<string>? introducedVulnerabilities = null)
{
var score = introducedCount - resolvedCount;
var afterScore = Math.Clamp(50 + (resolvedCount - introducedCount), 0, 100);
var proofSteps = new List<string>
{
$"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<string>())
{
proofSteps.Add($"resolved {vulnerability}");
}
foreach (var vulnerability in introducedVulnerabilities ?? Array.Empty<string>())
{
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<ChangeTracePackageDeltaDocument> 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<string> Policies { get; init; }
public required IReadOnlyList<string> 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<ChangeTraceSymbolDeltaDocument> Symbols { get; init; }
public required IReadOnlyList<ChangeTraceByteDeltaDocument> 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<string> 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; }
}

View File

@@ -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',

View File

@@ -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<ChangeTraceViewerComponent>;
let routeParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let queryParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let changeTraceService: jasmine.SpyObj<ChangeTraceService>;
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>;
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,
);
});
});

View File

@@ -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: `
<div class="change-trace-viewer">
@@ -97,11 +101,30 @@ import { ChangeTraceService } from './services/change-trace.service';
@if (!trace() && !loading() && !error()) {
<div class="empty-state">
<div class="empty-content">
<h2>No Change Trace Loaded</h2>
<p>Load a change trace file or navigate to a specific trace ID.</p>
<button class="btn btn-primary" (click)="fileInput.click()">
Load Change Trace File
</button>
<h2>{{ emptyStateTitle() }}</h2>
<p>{{ emptyStateMessage() }}</p>
<div class="empty-actions">
@if (hasComparisonContext()) {
<a
class="btn btn-secondary"
[routerLink]="['/releases/investigation/deploy-diff']"
[queryParams]="comparisonQueryParams()"
>
Open Deploy Diff
</a>
} @else {
<a
class="btn btn-secondary"
[routerLink]="['/releases/deployments']"
[queryParams]="scopeQueryParams()"
>
Open Deployments
</a>
}
<button class="btn btn-primary" (click)="fileInput.click()">
Load Change Trace File
</button>
</div>
</div>
</div>
}
@@ -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<PackageDelta | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(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<Record<string, string>>({});
readonly comparisonQueryParams = signal<Record<string, string>>({});
// 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);
}
}

View File

@@ -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<ChangeTrace> {
return this.http.post<ChangeTrace>(`${this.apiUrl}/build`, {
fromScanId,
toScanId,
fromDigest,
toDigest,
tenantId,
includeByteDiff,
});
}

View File

@@ -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<DeployDiffPanelComponent>;
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();
});
});
});

View File

@@ -456,6 +456,9 @@ export class DeployDiffPanelComponent implements OnInit {
/** Optional to version label */
readonly toLabel = input<string | undefined>();
/** Tenant context for the comparison */
readonly tenantId = input<string>('demo-prod');
/** Current signer identity (for override) */
readonly currentSigner = input<SignerIdentity | undefined>();
@@ -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<void> {
async fetchDiff(from: string, to: string, tenantId: string): Promise<void> {
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 */

View File

@@ -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;
}
/**

View File

@@ -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<DeployDiffPage>;
let queryParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
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');
});
});

View File

@@ -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';
<nav class="breadcrumb" aria-label="Breadcrumb">
<ol class="breadcrumb-list">
<li class="breadcrumb-item">
<a routerLink="/deploy" class="breadcrumb-link">Deployments</a>
<a
[routerLink]="['/releases/deployments']"
[queryParams]="scopeQueryParams()"
class="breadcrumb-link"
>
Deployments
</a>
</li>
<li class="breadcrumb-separator" aria-hidden="true">/</li>
<li class="breadcrumb-item breadcrumb-item--current" aria-current="page">
@@ -42,6 +51,7 @@ import { DeployAction, SignerIdentity } from '../models/deploy-diff.models';
<app-deploy-diff-panel
[fromDigest]="fromDigest()!"
[toDigest]="toDigest()!"
[tenantId]="tenantId()"
[fromLabel]="fromLabel()"
[toLabel]="toLabel()"
[currentSigner]="currentSigner()"
@@ -55,21 +65,34 @@ import { DeployAction, SignerIdentity } from '../models/deploy-diff.models';
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<h2>Missing Parameters</h2>
<h2>No Comparison Selected</h2>
<p>
To view a deployment diff, provide both <code>from</code> and <code>to</code> digest parameters.
Open this workspace from a deployment or provide both <code>from</code> and <code>to</code> digest parameters.
</p>
<div class="example-url">
<strong>Example:</strong>
<code>/deploy/diff?from=sha256:abc...&amp;to=sha256:def...</code>
<code>/releases/investigation/deploy-diff?from=sha256:abc...&amp;to=sha256:def...</code>
</div>
<div class="empty-actions">
<a
[routerLink]="['/releases/deployments']"
[queryParams]="scopeQueryParams()"
class="back-link"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Open Deployments
</a>
<a
[routerLink]="['/releases/overview']"
[queryParams]="scopeQueryParams()"
class="secondary-link"
>
Open Releases Overview
</a>
</div>
<a routerLink="/deploy" class="back-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Back to Deployments
</a>
</div>
}
</div>
@@ -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<string | undefined>(undefined);
/** Tenant context for lineage compare */
readonly tenantId = signal<string>('demo-prod');
/** Scope query params preserved across investigation navigation */
readonly scopeQueryParams = signal<Record<string, string>>({});
/** Current user/signer - in production would come from auth service */
readonly currentSigner = signal<SignerIdentity>({
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;
}

View File

@@ -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();
}));
});
});
});

View File

@@ -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<SbomDiffResult> {
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<SbomDiffResult>(
`${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);
}
}

View File

@@ -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;
}

View File

@@ -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"