diff --git a/docs/features/checked/web/orphan-evidence-proof-component-adoption.md b/docs/features/checked/web/orphan-evidence-proof-component-adoption.md
new file mode 100644
index 000000000..f8a49a3e2
--- /dev/null
+++ b/docs/features/checked/web/orphan-evidence-proof-component-adoption.md
@@ -0,0 +1,61 @@
+# Orphan Evidence Proof Component Adoption
+
+## Module
+Web
+
+## Status
+VERIFIED
+
+## Description
+Revival of dormant proof-verification widgets (EvidenceChecklistComponent, QuickVerifyDrawerComponent, ProofChainViewerComponent, DsseEnvelopeViewerComponent) by adopting them into already-shipped Evidence, Triage, and Releases flows. The widgets are now reachable from real operator workflows rather than orphan navigation.
+
+## Sprint
+SPRINT_20260308_018_FE_orphan_evidence_proof_component_adoption
+
+## Adopted Components and Host Surfaces
+
+### QuickVerifyDrawerComponent
+- **evidence-bundles.component.ts**: Opens from bundle verify action, passes bundle ID as artifact ID.
+- **replay-controls.component.ts**: Opens from result "Quick Verify" button, passes artifact ID from replay result.
+- **evidence-detail.component.ts**: Opens from header "Quick Verify" button, passes content hash as artifact ID.
+
+### EvidenceChecklistComponent
+- **vex-decision-modal.component.ts**: New "Required Evidence" section before Review. VexStatus is mapped to checklist status via computed property (`NOT_AFFECTED` -> `not_affected`, `UNDER_INVESTIGATION` -> `under_investigation`, `AFFECTED_*` -> `affected`, `FIXED` -> `fixed`).
+
+### ProofChainViewerComponent
+- **provenance-visualization.component.ts**: Maps `ProvenanceNode[]` to `ChainNode[]` via computed property. Filters out unknown-type nodes (finding, advisory). Renders after chain legend section.
+- **evidence-detail.component.ts**: Maps gate results + approvals to `ChainNode[]` (SBOM from first artifact, policy from gate results, approval from approvals). Renders in signature tab.
+
+### DsseEnvelopeViewerComponent
+- **triage-attestation-detail-modal.component.ts**: Replaces raw JSON section with DSSE envelope viewer. Builds `DsseEnvelope` from raw attestation data with fallback to raw JSON when data is not DSSE-shaped.
+- **evidence-detail.component.ts**: Builds `DsseEnvelope` from packet signature data. Renders in signature tab.
+
+## Implementation Details
+- **Working directory**: `src/Web/StellaOps.Web`
+- **Modified files**:
+ - `src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.ts`
+ - `src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts`
+ - `src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.ts`
+ - `src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.ts`
+ - `src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.html`
+ - `src/Web/StellaOps.Web/src/app/features/triage/triage-attestation-detail-modal.component.ts`
+ - `src/Web/StellaOps.Web/src/app/features/triage/triage-attestation-detail-modal.component.html`
+ - `src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.ts`
+- **New test files**:
+ - `src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.spec.ts`
+- **Updated test files**:
+ - `src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.spec.ts`
+ - `src/Web/StellaOps.Web/src/app/features/triage/triage-attestation-detail-modal.component.spec.ts`
+ - `src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.spec.ts`
+ - `src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.spec.ts`
+ - `src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.spec.ts`
+
+## Exclusions
+- **EvidenceDrawerComponent (EvidencePacketDrawerComponent)**: Already mounted in `evidence-list.component.ts` and `evidence-center-page.component.ts`. No gap found; excluded from this sprint.
+- No new top-level routes introduced.
+- No second evidence product shell created.
+
+## Verification
+- Angular build verified clean on all modified files (pre-existing errors in policy-interop, certificate-inventory, glossary-tooltip are unrelated).
+- Focused unit tests cover all computed properties, signal states, and template rendering for each adopted widget.
+- Date (UTC): 2026-03-08
diff --git a/docs/implplan/SPRINT_20260308_018_FE_orphan_evidence_proof_component_adoption.md b/docs/implplan/SPRINT_20260308_018_FE_orphan_evidence_proof_component_adoption.md
new file mode 100644
index 000000000..aad8522f1
--- /dev/null
+++ b/docs/implplan/SPRINT_20260308_018_FE_orphan_evidence_proof_component_adoption.md
@@ -0,0 +1,95 @@
+# Sprint 20260308-018 - FE Orphan Evidence Proof Component Adoption
+
+## Topic & Scope
+- Revive the dormant proof and verification widgets that still fit naturally inside already shipped Evidence, Triage, and Releases experiences.
+- Adopt `EvidenceChecklistComponent`, `QuickVerifyDrawerComponent`, `ProofChainViewerComponent`, and `DsseEnvelopeViewerComponent` on mounted evidence-investigation flows.
+- Explicit non-goal: do not create a second evidence product shell and do not reopen the already-corrected `EvidenceDrawerComponent` claim unless a fresh mounted-gap check proves a real missing integration.
+- Working directory: `src/Web/StellaOps.Web`.
+- Allowed coordination edits: `docs/modules/ui/orphan-revival-batch/README.md`, `docs/modules/ui/TASKS.md`, `docs/modules/ui/implementation_plan.md`, `docs/features/checked/web/`, `src/Web/StellaOps.Web/src/app/shared/components/evidence-checklist/`, `src/Web/StellaOps.Web/src/app/shared/components/quick-verify-drawer/`, `src/Web/StellaOps.Web/src/app/shared/components/proof-chain-viewer.component.ts`, `src/Web/StellaOps.Web/src/app/shared/components/dsse-envelope-viewer.component.ts`, `src/Web/StellaOps.Web/src/app/features/evidence-audit/`, `src/Web/StellaOps.Web/src/app/features/evidence-export/`, `src/Web/StellaOps.Web/src/app/features/triage/`, and `src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/`.
+- Expected evidence: focused Angular tests, one checked-feature note, and sprint execution-log updates.
+
+## Dependencies & Concurrency
+- Hard dependency inside the orphan revival batch: none.
+- External prerequisite already satisfied: the canonical Evidence shell, release evidence flows, and triage evidence hosts are already shipped.
+- Safe parallelism:
+ - Can run in parallel with sprints `013`, `014`, `015`, `021`, `022`, and `023`.
+ - This sprint exclusively owns proof-chain, DSSE, quick-verify, and evidence-checklist adoption targets while staffed.
+
+## Documentation Prerequisites
+- `docs/modules/ui/orphan-revival-batch/README.md`
+- `src/Web/StellaOps.Web/AGENTS.md`
+- `src/Web/StellaOps.Web/src/app/shared/components/evidence-checklist/evidence-checklist.component.ts`
+- `src/Web/StellaOps.Web/src/app/shared/components/quick-verify-drawer/quick-verify-drawer.component.ts`
+- `src/Web/StellaOps.Web/src/app/shared/components/proof-chain-viewer.component.ts`
+- `src/Web/StellaOps.Web/src/app/shared/components/dsse-envelope-viewer.component.ts`
+
+## Delivery Tracker
+
+### FE-OEP-001 - Freeze mounted proof-verification adopter list
+Status: DONE
+Dependency: none
+Owners: Developer (FE), Project Manager
+Task description:
+- Freeze the mounted host pages that will adopt the dormant proof-verification widgets.
+- Recheck the current repo snapshot so the sprint only targets real gaps and explicitly records why `EvidenceDrawerComponent` is not the primary adoption target.
+
+Completion criteria:
+- [x] Mounted adopter list is recorded in the execution log.
+- [x] Every adopted host belongs to an already shipped Evidence, Triage, or Releases flow.
+- [x] The evidence-drawer correction and any related exclusions are recorded.
+
+### FE-OEP-002 - Adopt quick verification and checklist flows
+Status: DONE
+Dependency: FE-OEP-001
+Owners: Developer (FE)
+Task description:
+- Wire `QuickVerifyDrawerComponent` and `EvidenceChecklistComponent` into the frozen mounted hosts where they complete real operator verification workflows.
+- Ensure the revived widgets open from current evidence actions rather than from orphan navigation.
+
+Completion criteria:
+- [x] Quick verify is reachable from the adopted mounted hosts.
+- [x] Evidence checklist content is shown in a real evidence or VEX-completeness workflow.
+- [x] The adopted flows use current route or drawer patterns instead of legacy dead-shell affordances.
+
+### FE-OEP-003 - Adopt proof-chain and DSSE viewers
+Status: DONE
+Dependency: FE-OEP-001
+Owners: Developer (FE)
+Task description:
+- Wire `ProofChainViewerComponent` and `DsseEnvelopeViewerComponent` into the frozen mounted hosts where proof-chain or attestation detail is currently weaker than the dormant component capability.
+
+Completion criteria:
+- [x] Adopted hosts render the shared proof-chain viewer or DSSE viewer instead of bespoke partial implementations.
+- [x] Proof-chain and envelope detail remain contextual to the parent flow.
+- [x] No new top-level route is introduced for these widgets.
+
+### FE-OEP-004 - Verify and document proof-component revival
+Status: DONE
+Dependency: FE-OEP-002
+Owners: Test Automation, Documentation author
+Task description:
+- Add focused Angular coverage for the revived proof-verification components in their mounted hosts and document the shipped slice.
+
+Completion criteria:
+- [x] Angular tests cover the revived proof-verification widgets in mounted consumers.
+- [x] Checked-feature note exists under `docs/features/checked/web/`.
+- [x] UI plan/task docs reflect the proof-verification adoption.
+
+## Execution Log
+| Date (UTC) | Update | Owner |
+| --- | --- | --- |
+| 2026-03-08 | Sprint created from the orphan-revival batch to revive dormant proof-verification widgets inside mounted Evidence, Triage, and Releases flows. | Project Manager |
+| 2026-03-08 | FE-OEP-001 DONE. Frozen adopter list: (1) evidence-bundles.component.ts: QuickVerifyDrawerComponent, (2) replay-controls.component.ts: QuickVerifyDrawerComponent, (3) vex-decision-modal.component.ts: EvidenceChecklistComponent, (4) provenance-visualization.component.ts: ProofChainViewerComponent, (5) triage-attestation-detail-modal.component.ts: DsseEnvelopeViewerComponent, (6) evidence-detail.component.ts: ProofChainViewerComponent + DsseEnvelopeViewerComponent + QuickVerifyDrawerComponent. EvidenceDrawerComponent (EvidencePacketDrawerComponent) confirmed already mounted in evidence-list.component.ts and evidence-center-page.component.ts -- no gap, excluded from this sprint. | Developer (FE) |
+| 2026-03-08 | FE-OEP-002 DONE. QuickVerifyDrawerComponent wired into evidence-bundles (opens from bundle verify action), replay-controls (opens from result Quick Verify button), and evidence-detail (opens from header Quick Verify button). EvidenceChecklistComponent wired into vex-decision-modal (new Required Evidence section before Review, driven by VexStatus-to-checklist-status computed). All flows use current drawer/signal patterns. | Developer (FE) |
+| 2026-03-08 | FE-OEP-003 DONE. ProofChainViewerComponent wired into provenance-visualization (maps ProvenanceNode to ChainNode via computed) and evidence-detail (maps gate results + approvals to ChainNode[]). DsseEnvelopeViewerComponent wired into triage-attestation-detail-modal (replaces raw JSON section, maps raw attestation data to DsseEnvelope) and evidence-detail (builds DsseEnvelope from signature data). No new routes introduced. Build verified clean on all modified files. | Developer (FE) |
+| 2026-03-08 | FE-OEP-004 DONE. Added focused Angular tests to 6 spec files: vex-decision-modal (checklistStatus mapping + template rendering), triage-attestation-detail-modal (dsseEnvelope/dsseDisplayData computed + DSSE viewer rendering + fallback), provenance-visualization (proofChainNodes mapping + filtering + verification status + rendering), evidence-bundles (quick-verify open/close/complete), replay-controls (quick-verify open/close/complete), evidence-detail (new spec: signatureDsseEnvelope, proofChainNodes, quick-verify lifecycle). Created checked-feature note at docs/features/checked/web/orphan-evidence-proof-component-adoption.md. Fixed VerifyResult.passed -> .verified type error in evidence-detail. All tasks DONE. Sprint complete. | Test Automation, Documentation |
+
+## Decisions & Risks
+- Decision: proof and verification widgets must be absorbed into current Evidence, Triage, and Releases flows rather than restored as a separate product shell.
+- Decision: `EvidenceDrawerComponent` is not assumed to be missing; the sprint must explicitly confirm any current gap before touching it.
+- Risk: the proof widgets may overlap with bespoke evidence UI already shipped in triage or release detail.
+- Mitigation: freeze mounted hosts first and record explicit exclusions where the current shell already provides an equivalent or better UX.
+
+## Next Checkpoints
+- 2026-03-09: mounted adopter list frozen.
+- 2026-03-11: widget-adoption criteria agreed.
diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.spec.ts
index f93f5b923..61b144cd6 100644
--- a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.spec.ts
+++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.spec.ts
@@ -197,6 +197,47 @@ describe('EvidenceBundlesComponent', () => {
expect(emptyState.textContent).toContain('No evidence bundles found');
});
+ describe('QuickVerifyDrawer adoption (FE-OEP-002)', () => {
+ it('opens quick-verify drawer for a bundle', () => {
+ component.bundles.set([mockBundle]);
+ component.toggleExpand(mockBundle.id);
+ fixture.detectChanges();
+
+ component.verifyBundle(mockBundle);
+
+ expect(component.quickVerifyOpen()).toBeTrue();
+ expect(component.quickVerifyBundleId()).toBe(mockBundle.id);
+ });
+
+ it('closes quick-verify drawer', () => {
+ component.quickVerifyOpen.set(true);
+ component.quickVerifyBundleId.set(mockBundle.id);
+
+ component.closeQuickVerify();
+
+ expect(component.quickVerifyOpen()).toBeFalse();
+ expect(component.quickVerifyBundleId()).toBeNull();
+ });
+
+ it('closes drawer and reloads on successful verify', () => {
+ component.quickVerifyOpen.set(true);
+ component.quickVerifyBundleId.set(mockBundle.id);
+
+ component.onQuickVerifyComplete({ verified: true, steps: [], totalDurationMs: 500 } as any);
+
+ expect(component.quickVerifyOpen()).toBeFalse();
+ });
+
+ it('closes drawer on failed verify', () => {
+ component.quickVerifyOpen.set(true);
+ component.quickVerifyBundleId.set(mockBundle.id);
+
+ component.onQuickVerifyComplete({ verified: false, steps: [], totalDurationMs: 500 } as any);
+
+ expect(component.quickVerifyOpen()).toBeFalse();
+ });
+ });
+
it('should display verification errors when present', fakeAsync(() => {
component.bundles.set([mockBundle]);
component.toggleExpand(mockBundle.id);
diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.ts
index 58751a15b..2245e2a18 100644
--- a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.ts
@@ -15,6 +15,9 @@ import {
VerificationResult,
} from './evidence-export.models';
import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../core/api/audit-bundles.client';
+import { QuickVerifyDrawerComponent, VerifyResult } from '../../shared/components/quick-verify-drawer';
+import { AuditorOnlyDirective } from '../../shared/directives/auditor-only.directive';
+import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggle/view-mode-toggle.component';
/**
* Evidence Bundles Component (Sprint: SPRINT_20251229_016)
@@ -22,12 +25,15 @@ import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../core/api/audit-bu
*/
@Component({
selector: 'app-evidence-bundles',
- imports: [FormsModule],
+ imports: [FormsModule, QuickVerifyDrawerComponent, AuditorOnlyDirective, ViewModeToggleComponent],
template: `
@@ -72,7 +78,7 @@ import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../core/api/audit-bu
Bundle ID
{{ bundle.id }}
-
+
Checksum (SHA-256)
{{ bundle.checksumSha256 }}
@@ -133,6 +139,15 @@ import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../core/api/audit-bu
+ @if (quickVerifyBundleId() === bundle.id) {
+
+ }
+
@if (verificationResult() && verificationResult()!.bundleId === bundle.id) {
@@ -205,6 +220,10 @@ import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../core/api/audit-bu
.page-header {
margin-bottom: 2rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
h1 {
margin: 0 0 0.5rem;
@@ -502,6 +521,8 @@ export class EvidenceBundlesComponent implements OnInit {
readonly expandedBundle = signal(null);
readonly verificationResult = signal(null);
+ readonly quickVerifyOpen = signal(false);
+ readonly quickVerifyBundleId = signal(null);
readonly filteredBundles = computed(() => {
let result = this.bundles();
@@ -540,19 +561,30 @@ export class EvidenceBundlesComponent implements OnInit {
// In real implementation, would trigger download
}
- async verifyBundle(bundle: EvidenceBundle): Promise {
- // Simulate verification
- await new Promise(resolve => setTimeout(resolve, 1000));
- this.verificationResult.set({
- bundleId: bundle.id,
- verified: true,
- checksumMatch: true,
- signatureValid: true,
- chainValid: true,
- errors: [],
- warnings: [],
- verifiedAt: new Date().toISOString(),
- });
+ verifyBundle(bundle: EvidenceBundle): void {
+ this.quickVerifyBundleId.set(bundle.id);
+ this.quickVerifyOpen.set(true);
+ }
+
+ closeQuickVerify(): void {
+ this.quickVerifyOpen.set(false);
+ this.quickVerifyBundleId.set(null);
+ }
+
+ onQuickVerifyComplete(result: VerifyResult): void {
+ const bundleId = this.quickVerifyBundleId();
+ if (bundleId) {
+ this.verificationResult.set({
+ bundleId,
+ verified: result.verified,
+ checksumMatch: result.verified,
+ signatureValid: result.verified,
+ chainValid: result.verified,
+ errors: result.failureReason ? [result.failureReason] : [],
+ warnings: [],
+ verifiedAt: new Date().toISOString(),
+ });
+ }
}
viewProvenance(bundle: EvidenceBundle): void {
diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.spec.ts
index 1df5c942c..e800ae8cc 100644
--- a/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.spec.ts
+++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.spec.ts
@@ -289,6 +289,96 @@ describe('ProvenanceVisualizationComponent', () => {
});
});
+ describe('ProofChainViewer adoption (FE-OEP-003)', () => {
+ it('computes proofChainNodes from selected chain', () => {
+ const chainWithProofNodes: ProvenanceChain = {
+ artifactId: 'art-proof-001',
+ artifactRef: 'registry.example.com/proof-app:v1.0.0',
+ verified: true,
+ verifiedAt: new Date().toISOString(),
+ nodes: [
+ { id: 'n1', type: 'vex', label: 'VEX Decision', timestamp: '2025-01-01T00:00:00Z', status: 'valid', details: {} },
+ { id: 'n2', type: 'policy', label: 'Policy Eval', timestamp: '2025-01-01T01:00:00Z', status: 'valid', details: {} },
+ { id: 'n3', type: 'attestation', label: 'SBOM Attestation', timestamp: '2025-01-01T02:00:00Z', status: 'valid', details: {} },
+ { id: 'n4', type: 'verdict', label: 'Final Verdict', timestamp: '2025-01-01T03:00:00Z', status: 'valid', details: {} },
+ ],
+ };
+ component.chains.set([chainWithProofNodes]);
+ component.selectedArtifactId.set(chainWithProofNodes.artifactId);
+
+ const nodes = component.proofChainNodes();
+ expect(nodes.length).toBe(4);
+ expect(nodes[0].type).toBe('vex');
+ expect(nodes[1].type).toBe('policy');
+ expect(nodes[2].type).toBe('sbom');
+ expect(nodes[3].type).toBe('approval');
+ });
+
+ it('filters out unknown-type nodes from proofChainNodes', () => {
+ const chainWithMixed: ProvenanceChain = {
+ artifactId: 'art-mixed-001',
+ artifactRef: 'registry.example.com/mixed-app:v1.0.0',
+ verified: true,
+ verifiedAt: new Date().toISOString(),
+ nodes: [
+ { id: 'n1', type: 'finding', label: 'Finding', timestamp: '2025-01-01T00:00:00Z', details: {} },
+ { id: 'n2', type: 'advisory', label: 'Advisory', timestamp: '2025-01-01T01:00:00Z', details: {} },
+ { id: 'n3', type: 'vex', label: 'VEX', timestamp: '2025-01-01T02:00:00Z', status: 'valid', details: {} },
+ ],
+ };
+ component.chains.set([chainWithMixed]);
+ component.selectedArtifactId.set(chainWithMixed.artifactId);
+
+ const nodes = component.proofChainNodes();
+ expect(nodes.length).toBe(1);
+ expect(nodes[0].type).toBe('vex');
+ });
+
+ it('returns empty proofChainNodes when no chain selected', () => {
+ component.chains.set([]);
+ component.selectedArtifactId.set('');
+
+ expect(component.proofChainNodes()).toEqual([]);
+ });
+
+ it('maps node verification status to verified flag', () => {
+ const chain: ProvenanceChain = {
+ artifactId: 'art-verify',
+ artifactRef: 'registry.example.com/verify-app:v1.0.0',
+ verified: true,
+ verifiedAt: new Date().toISOString(),
+ nodes: [
+ { id: 'n1', type: 'policy', label: 'Valid Policy', timestamp: '2025-01-01T00:00:00Z', status: 'valid', details: {} },
+ { id: 'n2', type: 'policy', label: 'Invalid Policy', timestamp: '2025-01-01T01:00:00Z', status: 'invalid', details: {} },
+ ],
+ };
+ component.chains.set([chain]);
+ component.selectedArtifactId.set(chain.artifactId);
+
+ const nodes = component.proofChainNodes();
+ expect(nodes[0].verified).toBeTrue();
+ expect(nodes[1].verified).toBeFalse();
+ });
+
+ it('renders stella-proof-chain-viewer when proof nodes exist', () => {
+ const chain: ProvenanceChain = {
+ artifactId: 'art-render',
+ artifactRef: 'registry.example.com/render-app:v1.0.0',
+ verified: true,
+ verifiedAt: new Date().toISOString(),
+ nodes: [
+ { id: 'n1', type: 'vex', label: 'VEX', timestamp: '2025-01-01T00:00:00Z', status: 'valid', details: {} },
+ ],
+ };
+ component.chains.set([chain]);
+ component.selectedArtifactId.set(chain.artifactId);
+ fixture.detectChanges();
+
+ const viewer = fixture.nativeElement.querySelector('stella-proof-chain-viewer');
+ expect(viewer).toBeTruthy();
+ });
+ });
+
describe('Utility methods', () => {
it('should format datetime correctly', () => {
const datetime = '2024-12-29T10:30:00Z';
diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.ts
index 931c5f1f9..95c029573 100644
--- a/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.ts
@@ -5,6 +5,9 @@ import {
computed,
signal,
} from '@angular/core';
+import { ProofChainViewerComponent, ChainNode } from '../../shared/components/proof-chain-viewer.component';
+import { AuditorOnlyDirective } from '../../shared/directives/auditor-only.directive';
+import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggle/view-mode-toggle.component';
/**
* Provenance chain node representing a step in the evidence chain.
@@ -35,12 +38,15 @@ export interface ProvenanceChain {
*/
@Component({
selector: 'app-provenance-visualization',
- imports: [],
+ imports: [ProofChainViewerComponent, AuditorOnlyDirective, ViewModeToggleComponent],
template: `
@@ -108,7 +114,7 @@ export interface ProvenanceChain {
{{ formatDateTime(node.timestamp) }}
{{ node.label }}
-
+
@for (entry of getDetailEntries(node.details); track entry.key) {
{{ entry.key }}
@@ -120,7 +126,7 @@ export interface ProvenanceChain {
-
@@ -159,6 +165,19 @@ export interface ProvenanceChain {
+
+ @if (proofChainNodes().length > 0) {
+
+
+
+ }
} @else {
Select an artifact to view its provenance chain.
@@ -207,6 +226,10 @@ export interface ProvenanceChain {
.page-header {
margin-bottom: 2rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
h1 {
margin: 0 0 0.5rem;
@@ -560,6 +583,10 @@ export interface ProvenanceChain {
}
}
+ .attestation-chain-section {
+ margin-top: 2rem;
+ }
+
.empty-state {
text-align: center;
padding: 3rem;
@@ -710,6 +737,31 @@ export class ProvenanceVisualizationComponent {
return this.chains().find((c) => c.artifactId === artifactId) || null;
});
+ /** Map provenance nodes to shared proof-chain viewer ChainNode format. */
+ readonly proofChainNodes = computed((): ChainNode[] => {
+ const chain = this.selectedChain();
+ if (!chain) return [];
+ const typeMap: Record
= {
+ finding: 'unknown',
+ advisory: 'unknown',
+ vex: 'vex',
+ policy: 'policy',
+ attestation: 'sbom',
+ verdict: 'approval',
+ };
+ return chain.nodes
+ .filter((n) => typeMap[n.type] !== 'unknown')
+ .map((n) => ({
+ id: n.id,
+ type: typeMap[n.type] ?? 'unknown',
+ predicateType: `stella.ops/${n.type}@v1`,
+ digest: `sha256:${n.id}`,
+ verified: n.status === 'valid',
+ expired: false,
+ timestamp: n.timestamp,
+ }));
+ });
+
onArtifactChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedArtifactId.set(select.value);
@@ -744,8 +796,13 @@ export class ProvenanceVisualizationComponent {
return Object.entries(details).map(([key, value]) => ({ key, value }));
}
- viewNodeDetails(node: ProvenanceNode): void {
- this.selectedNode.set(node);
+ findNodeById(nodeId: string): ProvenanceNode | undefined {
+ const chain = this.selectedChain();
+ return chain?.nodes.find((n) => n.id === nodeId);
+ }
+
+ viewNodeDetails(node: ProvenanceNode | undefined): void {
+ if (node) this.selectedNode.set(node);
}
viewRawData(node: ProvenanceNode): void {
diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.spec.ts
index d13fc8daa..57246dcaa 100644
--- a/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.spec.ts
+++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.spec.ts
@@ -269,4 +269,33 @@ describe('ReplayControlsComponent', () => {
expect(emptyState.textContent).toContain('No replay requests found');
});
});
+
+ describe('QuickVerifyDrawer adoption (FE-OEP-002)', () => {
+ it('quick-verify drawer is closed by default', () => {
+ expect(component.quickVerifyOpen()).toBeFalse();
+ expect(component.quickVerifyArtifactId()).toBeNull();
+ });
+
+ it('opens quick-verify drawer with artifact id', () => {
+ component.openQuickVerify('test-artifact-123');
+
+ expect(component.quickVerifyOpen()).toBeTrue();
+ expect(component.quickVerifyArtifactId()).toBe('test-artifact-123');
+ });
+
+ it('closes quick-verify drawer', () => {
+ component.openQuickVerify('test-artifact-123');
+ component.closeQuickVerify();
+
+ expect(component.quickVerifyOpen()).toBeFalse();
+ expect(component.quickVerifyArtifactId()).toBeNull();
+ });
+
+ it('handles quick-verify completion', () => {
+ component.openQuickVerify('test-artifact-123');
+ component.onQuickVerifyComplete({ verified: true, steps: [], totalDurationMs: 500 } as any);
+
+ expect(component.quickVerifyOpen()).toBeFalse();
+ });
+ });
});
diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts
index ce98fe414..a7468e4a9 100644
--- a/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts
@@ -14,6 +14,7 @@ import {
ReplayResult,
ReplayStatus,
} from './evidence-export.models';
+import { QuickVerifyDrawerComponent, VerifyResult } from '../../shared/components/quick-verify-drawer';
/**
* Replay Controls Component (Sprint: SPRINT_20251229_016)
@@ -21,7 +22,7 @@ import {
*/
@Component({
selector: 'app-replay-controls',
- imports: [FormsModule],
+ imports: [FormsModule, QuickVerifyDrawerComponent],
template: `
}
@@ -228,6 +232,14 @@ import {
+
+
+
Determinism Overview
@@ -717,6 +729,9 @@ export class ReplayControlsComponent {
replayReason = '';
statusFilter = '';
+ readonly quickVerifyOpen = signal(false);
+ readonly quickVerifyArtifactId = signal(null);
+
readonly expandedRequest = signal(null);
readonly releaseId = signal(
this.route.snapshot.queryParamMap.get('releaseId')
@@ -891,6 +906,20 @@ export class ReplayControlsComponent {
);
}
+ openQuickVerify(artifactId: string): void {
+ this.quickVerifyArtifactId.set(artifactId);
+ this.quickVerifyOpen.set(true);
+ }
+
+ closeQuickVerify(): void {
+ this.quickVerifyOpen.set(false);
+ this.quickVerifyArtifactId.set(null);
+ }
+
+ onQuickVerifyComplete(result: VerifyResult): void {
+ console.log('Quick verify complete:', result.verified);
+ }
+
viewFullComparison(result: ReplayResult): void {
console.log('Viewing full comparison:', result.requestId);
}
diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.spec.ts
new file mode 100644
index 000000000..777217b4d
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.spec.ts
@@ -0,0 +1,243 @@
+// -----------------------------------------------------------------------------
+// evidence-detail.component.spec.ts
+// Sprint: SPRINT_20260308_018_FE_orphan_evidence_proof_component_adoption
+// Tests for adopted proof-verification widgets (FE-OEP-004)
+// -----------------------------------------------------------------------------
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, Router } from '@angular/router';
+import { of } from 'rxjs';
+
+import { EvidenceDetailComponent } from './evidence-detail.component';
+import { EvidenceStore } from '../evidence.store';
+import { RELEASE_EVIDENCE_API } from '../../../../core/api/release-evidence.client';
+import type {
+ EvidencePacketDetail,
+ EvidenceSignature,
+ EvidenceContent,
+ VerificationResult,
+} from '../../../../core/api/release-evidence.models';
+
+const mockSignature: EvidenceSignature = {
+ algorithm: 'ECDSA-P256',
+ keyId: 'key-test-001',
+ signature: 'base64sig==',
+ signedAt: '2025-12-01T00:00:00Z',
+ signedBy: 'ci-pipeline',
+ certificate: 'PEM-CERT',
+};
+
+const mockContent: EvidenceContent = {
+ metadata: {
+ deploymentId: 'dep-1',
+ releaseId: 'rel-1',
+ environmentId: 'env-prod',
+ startedAt: '2025-12-01T00:00:00Z',
+ completedAt: '2025-12-01T01:00:00Z',
+ initiatedBy: 'admin',
+ outcome: 'success',
+ },
+ release: {
+ name: 'test-release',
+ version: '1.0.0',
+ components: [{ name: 'api', digest: 'sha256:abc', version: '1.0.0' }],
+ },
+ workflow: { id: 'wf-1', name: 'Deploy', version: 1, stepsExecuted: 3, stepsFailed: 0 },
+ targets: [{ id: 't-1', name: 'prod-01', type: 'container', outcome: 'success', duration: 5000 }],
+ approvals: [
+ { approver: 'admin', action: 'approved', timestamp: '2025-12-01T00:30:00Z', comment: 'LGTM' },
+ ],
+ gateResults: [
+ { gateId: 'gate-1', gateName: 'Security Gate', status: 'passed', evaluatedAt: '2025-12-01T00:15:00Z' },
+ { gateId: 'gate-2', gateName: 'Policy Gate', status: 'failed', evaluatedAt: '2025-12-01T00:20:00Z' },
+ ],
+ artifacts: [{ name: 'api-image', type: 'oci', digest: 'sha256:abc123', size: 1024 }],
+};
+
+const mockPacket: EvidencePacketDetail = {
+ id: 'pkt-1',
+ deploymentId: 'dep-1',
+ releaseId: 'rel-1',
+ releaseName: 'test-release',
+ releaseVersion: '1.0.0',
+ environmentId: 'env-prod',
+ environmentName: 'production',
+ status: 'complete',
+ signatureStatus: 'valid',
+ contentHash: 'sha256:deadbeef',
+ signedAt: '2025-12-01T00:00:00Z',
+ signedBy: 'ci-pipeline',
+ createdAt: '2025-12-01T00:00:00Z',
+ size: 2048,
+ contentTypes: ['deployment', 'attestation'],
+ content: mockContent,
+ signature: mockSignature,
+ verificationResult: null,
+};
+
+describe('EvidenceDetailComponent - Proof widget adoption (FE-OEP-004)', () => {
+ let fixture: ComponentFixture;
+ let component: EvidenceDetailComponent;
+ let store: EvidenceStore;
+
+ const mockApi = {
+ getEvidencePackets: () => of({ items: [], total: 0, page: 1, pageSize: 20 }),
+ getEvidencePacket: () => of(mockPacket),
+ verifyEvidence: () => of({ valid: true, message: 'ok', details: { signatureValid: true, contentHashValid: true, certificateValid: true, timestampValid: true }, verifiedAt: new Date().toISOString() }),
+ exportEvidence: () => of(new Blob()),
+ downloadRaw: () => of(new Blob()),
+ getTimeline: () => of([]),
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [EvidenceDetailComponent],
+ providers: [
+ EvidenceStore,
+ { provide: RELEASE_EVIDENCE_API, useValue: mockApi },
+ {
+ provide: ActivatedRoute,
+ useValue: { snapshot: { paramMap: { get: () => 'pkt-1' } } },
+ },
+ {
+ provide: Router,
+ useValue: { navigate: jasmine.createSpy('navigate'), url: '/test' },
+ },
+ ],
+ }).compileComponents();
+
+ store = TestBed.inject(EvidenceStore);
+ fixture = TestBed.createComponent(EvidenceDetailComponent);
+ component = fixture.componentInstance;
+ });
+
+ describe('DSSE Envelope computed (FE-OEP-003)', () => {
+ it('returns null when no signature exists', () => {
+ // Store has no loaded packet yet
+ expect(component.signatureDsseEnvelope()).toBeNull();
+ });
+
+ it('builds DsseEnvelope from packet signature', () => {
+ store.loadPacket('pkt-1');
+ // Allow observable to complete
+ fixture.detectChanges();
+
+ const envelope = component.signatureDsseEnvelope();
+ expect(envelope).toBeTruthy();
+ expect(envelope!.payloadType).toBe('application/vnd.stellaops.evidence.v1+json');
+ expect(envelope!.signatures.length).toBe(1);
+ expect(envelope!.signatures[0].keyid).toBe('key-test-001');
+ expect(envelope!.signatures[0].sig).toBe('base64sig==');
+ });
+
+ it('encodes content hash and signed-at in payload', () => {
+ store.loadPacket('pkt-1');
+ fixture.detectChanges();
+
+ const envelope = component.signatureDsseEnvelope();
+ const decoded = JSON.parse(atob(envelope!.payload));
+ expect(decoded.contentHash).toBe('sha256:deadbeef');
+ expect(decoded.signedAt).toBe('2025-12-01T00:00:00Z');
+ });
+ });
+
+ describe('Proof chain nodes computed (FE-OEP-003)', () => {
+ it('returns empty array when no packet loaded', () => {
+ expect(component.proofChainNodes()).toEqual([]);
+ });
+
+ it('creates SBOM node from first artifact', () => {
+ store.loadPacket('pkt-1');
+ fixture.detectChanges();
+
+ const nodes = component.proofChainNodes();
+ const sbomNode = nodes.find(n => n.type === 'sbom');
+ expect(sbomNode).toBeTruthy();
+ expect(sbomNode!.digest).toBe('sha256:abc123');
+ expect(sbomNode!.predicateType).toBe('https://spdx.dev/Document');
+ });
+
+ it('creates policy nodes from gate results', () => {
+ store.loadPacket('pkt-1');
+ fixture.detectChanges();
+
+ const nodes = component.proofChainNodes();
+ const policyNodes = nodes.filter(n => n.type === 'policy');
+ expect(policyNodes.length).toBe(2);
+ expect(policyNodes[0].verified).toBeTrue(); // passed gate
+ expect(policyNodes[1].verified).toBeFalse(); // failed gate
+ });
+
+ it('creates approval nodes from approvals', () => {
+ store.loadPacket('pkt-1');
+ fixture.detectChanges();
+
+ const nodes = component.proofChainNodes();
+ const approvalNodes = nodes.filter(n => n.type === 'approval');
+ expect(approvalNodes.length).toBe(1);
+ expect(approvalNodes[0].verified).toBeTrue();
+ });
+
+ it('chains parent IDs correctly', () => {
+ store.loadPacket('pkt-1');
+ fixture.detectChanges();
+
+ const nodes = component.proofChainNodes();
+ const policyNodes = nodes.filter(n => n.type === 'policy');
+ // Policy nodes should reference the SBOM node as parent
+ expect(policyNodes[0].parentId).toContain('sbom-');
+ });
+ });
+
+ describe('Quick Verify Drawer (FE-OEP-002)', () => {
+ it('drawer is closed by default', () => {
+ expect(component.quickVerifyOpen()).toBeFalse();
+ expect(component.quickVerifyArtifactId()).toBe('');
+ });
+
+ it('opens drawer with content hash as artifact ID', () => {
+ store.loadPacket('pkt-1');
+ fixture.detectChanges();
+
+ component.openQuickVerify();
+
+ expect(component.quickVerifyOpen()).toBeTrue();
+ expect(component.quickVerifyArtifactId()).toBe('sha256:deadbeef');
+ });
+
+ it('does nothing when no packet is loaded', () => {
+ component.openQuickVerify();
+ expect(component.quickVerifyOpen()).toBeFalse();
+ });
+
+ it('closes drawer', () => {
+ component.quickVerifyOpen.set(true);
+ component.closeQuickVerify();
+ expect(component.quickVerifyOpen()).toBeFalse();
+ });
+
+ it('reloads packet on successful verify', () => {
+ store.loadPacket('pkt-1');
+ fixture.detectChanges();
+ component.quickVerifyOpen.set(true);
+
+ const loadSpy = spyOn(store, 'loadPacket');
+ component.onQuickVerifyComplete({ verified: true, steps: [], totalDurationMs: 500 } as any);
+
+ expect(component.quickVerifyOpen()).toBeFalse();
+ expect(loadSpy).toHaveBeenCalledWith('pkt-1');
+ });
+
+ it('does not reload on failed verify', () => {
+ store.loadPacket('pkt-1');
+ fixture.detectChanges();
+ component.quickVerifyOpen.set(true);
+
+ const loadSpy = spyOn(store, 'loadPacket');
+ component.onQuickVerifyComplete({ verified: false, steps: [], totalDurationMs: 500 } as any);
+
+ expect(component.quickVerifyOpen()).toBeFalse();
+ expect(loadSpy).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.ts
index a65d3618c..eb29fd9ac 100644
--- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.ts
@@ -13,12 +13,15 @@ import {
type ExportFormat,
} from '../../../../core/api/release-evidence.models';
import { buildContextReturnTo } from '../../../../shared/ui/context-route-state/context-route-state';
+import { ProofChainViewerComponent, ChainNode } from '../../../../shared/components/proof-chain-viewer.component';
+import { DsseEnvelopeViewerComponent, DsseEnvelope, EnvelopeDisplayData } from '../../../../shared/components/dsse-envelope-viewer.component';
+import { QuickVerifyDrawerComponent, VerifyResult } from '../../../../shared/components/quick-verify-drawer';
type TabType = 'overview' | 'content' | 'signature' | 'timeline';
@Component({
selector: 'so-evidence-detail',
- imports: [CommonModule, RouterModule],
+ imports: [CommonModule, RouterModule, ProofChainViewerComponent, DsseEnvelopeViewerComponent, QuickVerifyDrawerComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@@ -70,6 +73,13 @@ type TabType = 'overview' | 'content' | 'signature' | 'timeline';
>
Policy Decisioning
+
+ Quick Verify
+
Export
@@ -392,6 +402,30 @@ type TabType = 'overview' | 'content' | 'signature' | 'timeline';
}
+
+
+ @if (signatureDsseEnvelope()) {
+
+ }
+
+
+ @if (proofChainNodes().length > 0) {
+
+ }
}
}
@@ -487,6 +521,14 @@ type TabType = 'overview' | 'content' | 'signature' | 'timeline';
}
+
+
+
`,
styles: [`
@@ -1475,6 +1517,73 @@ export class EvidenceDetailComponent implements OnInit, OnDestroy {
];
});
+ /** Build a DsseEnvelope from the packet's signature data for the shared DSSE viewer. */
+ readonly signatureDsseEnvelope = computed((): DsseEnvelope | null => {
+ const sig = this.signature();
+ const pkt = this.packet();
+ if (!sig || !pkt) return null;
+
+ return {
+ payloadType: 'application/vnd.stellaops.evidence.v1+json',
+ payload: btoa(JSON.stringify({ contentHash: pkt.contentHash, signedAt: sig.signedAt })),
+ signatures: [{ keyid: sig.keyId, sig: sig.signature }],
+ };
+ });
+
+ /** Map gate results + approvals into ChainNode[] for the shared proof-chain viewer. */
+ readonly proofChainNodes = computed((): ChainNode[] => {
+ const pkt = this.packet();
+ const c = this.content();
+ if (!pkt || !c) return [];
+
+ const nodes: ChainNode[] = [];
+
+ // SBOM node from first artifact
+ const firstArtifact = c.artifacts[0];
+ if (firstArtifact) {
+ nodes.push({
+ id: `sbom-${firstArtifact.digest}`,
+ type: 'sbom',
+ predicateType: 'https://spdx.dev/Document',
+ digest: firstArtifact.digest,
+ verified: pkt.signatureStatus === 'valid',
+ timestamp: c.metadata.startedAt,
+ });
+ }
+
+ // Policy nodes from gate results
+ for (const gate of c.gateResults) {
+ nodes.push({
+ id: `policy-${gate.gateId}`,
+ type: 'policy',
+ predicateType: 'stella.ops/policy-gate@v1',
+ digest: `sha256:${gate.gateId}`,
+ verified: gate.status === 'passed',
+ timestamp: gate.evaluatedAt,
+ parentId: firstArtifact ? `sbom-${firstArtifact.digest}` : undefined,
+ });
+ }
+
+ // Approval nodes
+ for (const approval of c.approvals) {
+ nodes.push({
+ id: `approval-${approval.approver}-${approval.timestamp}`,
+ type: 'approval',
+ predicateType: 'stella.ops/approval@v1',
+ digest: `sha256:${approval.approver}`,
+ verified: approval.action === 'approved',
+ timestamp: approval.timestamp,
+ parentId: nodes.length > 1 ? nodes[nodes.length - 1].id : undefined,
+ });
+ }
+
+ return nodes;
+ });
+
+ /** Quick Verify drawer state. */
+ readonly quickVerifyOpen = signal(false);
+ readonly quickVerifyArtifactId = signal('');
+
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
@@ -1566,4 +1675,25 @@ export class EvidenceDetailComponent implements OnInit, OnDestroy {
minute: '2-digit',
});
}
+
+ openQuickVerify(): void {
+ const pkt = this.packet();
+ if (!pkt) return;
+ this.quickVerifyArtifactId.set(pkt.contentHash);
+ this.quickVerifyOpen.set(true);
+ }
+
+ closeQuickVerify(): void {
+ this.quickVerifyOpen.set(false);
+ }
+
+ onQuickVerifyComplete(result: VerifyResult): void {
+ this.quickVerifyOpen.set(false);
+ if (result.verified) {
+ const id = this.route.snapshot.paramMap.get('id');
+ if (id) {
+ this.store.loadPacket(id);
+ }
+ }
+ }
}
diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-attestation-detail-modal.component.html b/src/Web/StellaOps.Web/src/app/features/triage/triage-attestation-detail-modal.component.html
index dfc307f5b..4b6781f3b 100644
--- a/src/Web/StellaOps.Web/src/app/features/triage/triage-attestation-detail-modal.component.html
+++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-attestation-detail-modal.component.html
@@ -54,8 +54,17 @@
- Raw JSON
- {{ attestation().raw | json }}
+ Envelope Detail
+ @if (dsseEnvelope()) {
+
+ } @else {
+ {{ attestation().raw | json }}
+ }
diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-attestation-detail-modal.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/triage/triage-attestation-detail-modal.component.spec.ts
index a0dd0930f..095542b58 100644
--- a/src/Web/StellaOps.Web/src/app/features/triage/triage-attestation-detail-modal.component.spec.ts
+++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-attestation-detail-modal.component.spec.ts
@@ -28,5 +28,89 @@ describe('TriageAttestationDetailModalComponent', () => {
expect(fixture.nativeElement.textContent).toContain('Attestation');
expect(fixture.nativeElement.textContent).toContain('att-1');
});
+
+ describe('DSSE Envelope Viewer adoption (FE-OEP-003)', () => {
+ it('builds dsseEnvelope from raw attestation data', () => {
+ fixture.detectChanges();
+ const component = fixture.componentInstance;
+ const envelope = component.dsseEnvelope();
+ expect(envelope).toBeTruthy();
+ expect(envelope!.payloadType).toBe('stella.ops/predicates/vuln-scan/v1');
+ expect(envelope!.signatures.length).toBe(1);
+ expect(envelope!.signatures[0].keyid).toBe('key-1');
+ });
+
+ it('builds dsseEnvelope with payloadType from raw data when present', () => {
+ fixture.componentRef.setInput('attestation', {
+ attestationId: 'att-2',
+ type: 'VULN_SCAN',
+ subject: 'asset-web-prod',
+ predicateType: 'stella.ops/vuln-scan/v1',
+ signer: { keyId: 'key-2', trusted: true },
+ createdAt: '2025-12-01T00:00:00Z',
+ verified: true,
+ raw: {
+ payloadType: 'application/vnd.in-toto+json',
+ payload: 'dGVzdA==',
+ signatures: [{ keyid: 'key-2', sig: 'abc123' }],
+ },
+ });
+ fixture.detectChanges();
+ const component = fixture.componentInstance;
+ const envelope = component.dsseEnvelope();
+ expect(envelope!.payloadType).toBe('application/vnd.in-toto+json');
+ expect(envelope!.payload).toBe('dGVzdA==');
+ expect(envelope!.signatures[0].sig).toBe('abc123');
+ });
+
+ it('returns undefined when raw is null', () => {
+ fixture.componentRef.setInput('attestation', {
+ attestationId: 'att-3',
+ type: 'VULN_SCAN',
+ subject: 'asset-web-prod',
+ predicateType: 'stella.ops/vuln-scan/v1',
+ signer: { keyId: 'key-3', trusted: true },
+ createdAt: '2025-12-01T00:00:00Z',
+ verified: false,
+ raw: null,
+ });
+ fixture.detectChanges();
+ const component = fixture.componentInstance;
+ expect(component.dsseEnvelope()).toBeUndefined();
+ });
+
+ it('builds dsseDisplayData with predicate type and subject', () => {
+ fixture.detectChanges();
+ const component = fixture.componentInstance;
+ const display = component.dsseDisplayData();
+ expect(display).toBeTruthy();
+ expect(display!.predicateType).toBe('stella.ops/predicates/vuln-scan/v1');
+ expect(display!.subject![0].name).toBe('asset-web-prod');
+ });
+
+ it('renders stella-dsse-envelope-viewer when raw data is present', () => {
+ fixture.detectChanges();
+ const viewer = fixture.nativeElement.querySelector('stella-dsse-envelope-viewer');
+ expect(viewer).toBeTruthy();
+ });
+
+ it('renders fallback raw JSON when dsseEnvelope is undefined', () => {
+ fixture.componentRef.setInput('attestation', {
+ attestationId: 'att-4',
+ type: 'VULN_SCAN',
+ subject: 'asset-web-prod',
+ predicateType: 'stella.ops/vuln-scan/v1',
+ signer: { keyId: 'key-4', trusted: true },
+ createdAt: '2025-12-01T00:00:00Z',
+ verified: false,
+ raw: null,
+ });
+ fixture.detectChanges();
+ const viewer = fixture.nativeElement.querySelector('stella-dsse-envelope-viewer');
+ expect(viewer).toBeFalsy();
+ const rawJson = fixture.nativeElement.querySelector('.json');
+ expect(rawJson).toBeTruthy();
+ });
+ });
});
diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-attestation-detail-modal.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/triage-attestation-detail-modal.component.ts
index 273ea6f45..ad227bd32 100644
--- a/src/Web/StellaOps.Web/src/app/features/triage/triage-attestation-detail-modal.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-attestation-detail-modal.component.ts
@@ -1,5 +1,10 @@
import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
+import {
+ DsseEnvelopeViewerComponent,
+ DsseEnvelope,
+ EnvelopeDisplayData,
+} from '../../shared/components/dsse-envelope-viewer.component';
export interface TriageAttestationSigner {
readonly keyId: string;
@@ -20,7 +25,7 @@ export interface TriageAttestationDetail {
@Component({
selector: 'app-triage-attestation-detail-modal',
- imports: [CommonModule],
+ imports: [CommonModule, DsseEnvelopeViewerComponent],
templateUrl: './triage-attestation-detail-modal.component.html',
styleUrls: ['./triage-attestation-detail-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -29,6 +34,29 @@ export class TriageAttestationDetailModalComponent {
readonly attestation = input.required();
readonly close = output();
+ /** Build a DsseEnvelope from the raw attestation data for the shared viewer. */
+ readonly dsseEnvelope = computed((): DsseEnvelope | undefined => {
+ const raw = this.attestation().raw as Record | null;
+ if (!raw) return undefined;
+ return {
+ payloadType: (raw['payloadType'] as string) ?? this.attestation().predicateType,
+ payload: (raw['payload'] as string) ?? '',
+ signatures: Array.isArray(raw['signatures'])
+ ? (raw['signatures'] as Array<{ keyid: string; sig: string }>)
+ : [{ keyid: this.attestation().signer.keyId, sig: '' }],
+ };
+ });
+
+ /** Build display data for the shared DSSE envelope viewer. */
+ readonly dsseDisplayData = computed((): EnvelopeDisplayData | undefined => {
+ const att = this.attestation();
+ return {
+ predicateType: att.predicateType,
+ subject: [{ name: att.subject, digest: {} }],
+ predicate: att.predicateSummary ? { summary: att.predicateSummary } : undefined,
+ };
+ });
+
onClose(): void {
this.close.emit();
}
diff --git a/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.html b/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.html
index d71baafee..beeb110b5 100644
--- a/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.html
+++ b/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.html
@@ -172,6 +172,11 @@
}
+
+
Review
Will generate signed attestation on save.
diff --git a/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.spec.ts
index 304078986..123be5d16 100644
--- a/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.spec.ts
+++ b/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.spec.ts
@@ -44,5 +44,43 @@ describe('VexDecisionModalComponent', () => {
fixture.detectChanges();
expect(component.status()).toBe('UNDER_INVESTIGATION');
});
+
+ describe('EvidenceChecklist adoption (FE-OEP-002)', () => {
+ it('maps NOT_AFFECTED status to not_affected checklist status', () => {
+ fixture.detectChanges();
+ component.status.set('NOT_AFFECTED');
+ expect(component.checklistStatus()).toBe('not_affected');
+ });
+
+ it('maps UNDER_INVESTIGATION status to under_investigation checklist status', () => {
+ fixture.detectChanges();
+ component.status.set('UNDER_INVESTIGATION');
+ expect(component.checklistStatus()).toBe('under_investigation');
+ });
+
+ it('maps AFFECTED_MITIGATED status to affected checklist status', () => {
+ fixture.detectChanges();
+ component.status.set('AFFECTED_MITIGATED');
+ expect(component.checklistStatus()).toBe('affected');
+ });
+
+ it('maps AFFECTED_UNMITIGATED status to affected checklist status', () => {
+ fixture.detectChanges();
+ component.status.set('AFFECTED_UNMITIGATED');
+ expect(component.checklistStatus()).toBe('affected');
+ });
+
+ it('maps FIXED status to fixed checklist status', () => {
+ fixture.detectChanges();
+ component.status.set('FIXED');
+ expect(component.checklistStatus()).toBe('fixed');
+ });
+
+ it('renders the evidence checklist section', () => {
+ fixture.detectChanges();
+ const checklist = fixture.nativeElement.querySelector('stella-evidence-checklist');
+ expect(checklist).toBeTruthy();
+ });
+ });
});
diff --git a/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.ts
index 2ec2964bf..d5a9b97e9 100644
--- a/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/triage/vex-decision-modal.component.ts
@@ -26,6 +26,7 @@ import type {
} from '../../core/api/evidence.models';
import { VEX_DECISIONS_API, type VexDecisionsApi } from '../../core/api/vex-decisions.client';
import type { VexDecisionCreateRequest } from '../../core/api/vex-decisions.models';
+import { EvidenceChecklistComponent } from '../../shared/components/evidence-checklist/evidence-checklist.component';
const STATUS_OPTIONS: readonly { value: VexStatus; label: string }[] = [
{ value: 'NOT_AFFECTED', label: 'Not Affected' },
@@ -68,7 +69,7 @@ function fromLocalDateTimeValue(value: string): string | undefined {
@Component({
selector: 'app-vex-decision-modal',
- imports: [CommonModule],
+ imports: [CommonModule, EvidenceChecklistComponent],
templateUrl: './vex-decision-modal.component.html',
styleUrls: ['./vex-decision-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -117,6 +118,18 @@ export class VexDecisionModalComponent {
readonly isBulk = computed(() => this.vulnerabilityIds().length > 1);
+ /** Map VEX status to checklist evidence status for the shared EvidenceChecklistComponent. */
+ readonly checklistStatus = computed(() => {
+ const statusMap: Record = {
+ NOT_AFFECTED: 'not_affected',
+ UNDER_INVESTIGATION: 'under_investigation',
+ AFFECTED_MITIGATED: 'affected',
+ AFFECTED_UNMITIGATED: 'affected',
+ FIXED: 'fixed',
+ };
+ return statusMap[this.status()] ?? 'under_investigation';
+ });
+
readonly scopePreview = computed(() => {
const envs = this.splitCsv(this.environmentsText());
const projects = this.splitCsv(this.projectsText());