Repair release investigation workspace contracts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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...&to=sha256:def...</code>
|
||||
<code>/releases/investigation/deploy-diff?from=sha256:abc...&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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user