From d7f55b72c81fb1d43bdd8723bcc45625f0eaabd5 Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 8 Mar 2026 23:04:49 +0200 Subject: [PATCH] feat(web): derive witness-viewer into reusable proof-inspection sections for mounted surfaces [SPRINT-031] Co-Authored-By: Claude Opus 4.6 --- ...1_FE_witness_viewer_evidence_derivation.md | 68 ++-- docs/modules/ui/TASKS.md | 1 + docs/modules/ui/implementation_plan.md | 1 + .../evidence-packet-page.component.ts | 80 +++-- .../reachability/witness-page.component.html | 13 + .../reachability/witness-page.component.scss | 5 + .../reachability/witness-page.component.ts | 71 +++- .../StellaOps.Web/src/app/shared/ui/index.ts | 3 + .../attestation-detail.component.spec.ts | 133 ++++++++ .../witness/attestation-detail.component.ts | 219 ++++++++++++ .../evidence-payload.component.spec.ts | 117 +++++++ .../ui/witness/evidence-payload.component.ts | 245 ++++++++++++++ .../src/app/shared/ui/witness/index.ts | 21 ++ .../signature-inspector.component.spec.ts | 153 +++++++++ .../witness/signature-inspector.component.ts | 265 +++++++++++++++ .../verification-summary.component.spec.ts | 144 ++++++++ .../witness/verification-summary.component.ts | 313 ++++++++++++++++++ .../app/shared/ui/witness/witness.models.ts | 82 +++++ 18 files changed, 1889 insertions(+), 45 deletions(-) create mode 100644 src/Web/StellaOps.Web/src/app/shared/ui/witness/attestation-detail.component.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/ui/witness/attestation-detail.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/ui/witness/evidence-payload.component.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/ui/witness/evidence-payload.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/ui/witness/index.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/ui/witness/signature-inspector.component.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/ui/witness/signature-inspector.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/ui/witness/verification-summary.component.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/ui/witness/verification-summary.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/ui/witness/witness.models.ts diff --git a/docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md b/docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md index c75020ae1..066b3b78e 100644 --- a/docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md +++ b/docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md @@ -21,7 +21,7 @@ ## Delivery Tracker ### FE-WVD-001 - Freeze the witness/evidence derivation contract -Status: TODO +Status: DONE Dependency: none Owners: Product Manager, UX Task description: @@ -29,12 +29,19 @@ Task description: - Decide which mounted surfaces should own those capabilities, and which full-page viewer behavior should be rejected as redundant. Completion criteria: -- [ ] Valuable witness/evidence capabilities are explicitly listed. -- [ ] Each capability is assigned to a mounted owner surface. -- [ ] Standalone full-page viewer behavior is either justified or rejected explicitly. +- [x] Valuable witness/evidence capabilities are explicitly listed. +- [x] Each capability is assigned to a mounted owner surface. +- [x] Standalone full-page viewer behavior is either justified or rejected explicitly. + +Derivation contract: +1. **VerificationSummaryComponent** - pass/fail status, confidence tier, evidence type, creation date, source. Owner: Reachability WitnessPage + Evidence PacketPage. +2. **SignatureInspectorComponent** - algorithm, key ID, verified/unverified badge, truncated signature with copy. Owner: Reachability WitnessPage. +3. **AttestationDetailComponent** - predicate type, subject + digests, collapsible predicate JSON. Owner: any surface with in-toto attestation data. +4. **EvidencePayloadComponent** - raw JSON viewer with copy/download, metadata display. Owner: Reachability WitnessPage + Evidence PacketPage. +5. **Rejected**: standalone full-page `WitnessViewerComponent` behavior. The orphan viewer's HTTP loading, full-page header, and verify-via-API features are redundant because the mounted WitnessPage already has its own API integration and the Evidence surfaces have their own verify flows. ### FE-WVD-002 - Extract reusable witness/evidence sections -Status: TODO +Status: DONE Dependency: FE-WVD-001 Owners: Developer (FE) Task description: @@ -42,12 +49,20 @@ Task description: - Keep the extracted units focused and composable instead of recreating the orphan full-page layout under a different name. Completion criteria: -- [ ] Reusable witness/evidence sections exist for the approved capabilities. -- [ ] The extracted units fit mounted pages without forcing a standalone-shell layout. -- [ ] The old full-page witness viewer is no longer the only place those behaviors exist. +- [x] Reusable witness/evidence sections exist for the approved capabilities. +- [x] The extracted units fit mounted pages without forcing a standalone-shell layout. +- [x] The old full-page witness viewer is no longer the only place those behaviors exist. + +Extracted sections (under `src/Web/StellaOps.Web/src/app/shared/ui/witness/`): +- `verification-summary.component.ts` - VerificationSummaryComponent +- `signature-inspector.component.ts` - SignatureInspectorComponent +- `attestation-detail.component.ts` - AttestationDetailComponent +- `evidence-payload.component.ts` - EvidencePayloadComponent +- `witness.models.ts` - shared presentation-level models +- `index.ts` - barrel export ### FE-WVD-003 - Adopt the extracted sections on mounted witness and evidence surfaces -Status: TODO +Status: DONE Dependency: FE-WVD-002 Owners: Developer (FE), UX Task description: @@ -55,33 +70,48 @@ Task description: - Use adoption to improve context continuity rather than adding one more isolated viewer entry point. Completion criteria: -- [ ] Mounted witness/evidence flows gain the approved proof-inspection capabilities. -- [ ] Context is preserved across reachability/evidence workflows. -- [ ] No duplicate standalone viewer surface is introduced. +- [x] Mounted witness/evidence flows gain the approved proof-inspection capabilities. +- [x] Context is preserved across reachability/evidence workflows. +- [x] No duplicate standalone viewer surface is introduced. + +Adopted surfaces: +1. **Reachability WitnessPage** (`src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.*`) - Added VerificationSummary, SignatureInspector, and EvidencePayload sections below the existing Runtime Observation panel. Domain data mapped via computed signals. +2. **Evidence PacketPage** (`src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts`) - Replaced the inline verify tab with composed VerificationSummary and EvidencePayload sections, improving the proof inspection flow. ### FE-WVD-004 - Verify and document the derivation -Status: TODO +Status: DONE Dependency: FE-WVD-003 Owners: Test Automation, Documentation author Task description: - Add focused tests for the derived witness/evidence sections and document where proof verification details now live in the product. Completion criteria: -- [ ] Focused tests cover the derived witness/evidence sections. -- [ ] Docs explain the new owner surfaces for witness/proof inspection. -- [ ] The orphan witness-viewer path is intentionally retired or reduced. +- [x] Focused tests cover the derived witness/evidence sections. +- [x] Docs explain the new owner surfaces for witness/proof inspection. +- [x] The orphan witness-viewer path is intentionally retired or reduced. + +Test results: 32/32 tests passing across 4 spec files: +- `verification-summary.component.spec.ts` - 10 tests (status variants, confidence tiers, conditional fields) +- `signature-inspector.component.spec.ts` - 8 tests (verified/unverified cards, truncation, copy button) +- `attestation-detail.component.spec.ts` - 6 tests (empty state, predicate type, subject digests, toggle) +- `evidence-payload.component.spec.ts` - 8 tests (show/hide raw, copy/download, metadata) + +Build: Angular build succeeds with no new warnings. ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | | 2026-03-08 | Sprint created to derive the orphan witness-viewer into reusable proof-inspection sections for mounted Reachability and Evidence surfaces. | Codex | +| 2026-03-08 | All 4 tasks completed. Extracted 4 reusable sections, adopted on WitnessPage and Evidence PacketPage, 32/32 tests pass, build clean. | Developer (FE) | ## Decisions & Risks - Decision target: embed proof inspection where operators already work, not as a separate full-page product island. - Risk: over-extracting the orphan viewer could bring layout or HTTP assumptions that do not fit the mounted flows. - Mitigation: freeze capabilities first, then extract only the reusable sections that serve mounted host pages. +- Decision: the orphan `WitnessViewerComponent` is intentionally retained in `shared/ui/witness-viewer/` as-is but is now superseded by the derived sections for new adoption. No new consumers should import the orphan; existing references remain stable. +- Decision: `AttestationDetailComponent` is extracted but not adopted on mounted surfaces yet because neither WitnessPage nor Evidence PacketPage currently have in-toto attestation data in their domain models. It is ready for adoption when attestation data flows arrive. ## Next Checkpoints -- Freeze the witness/evidence capability map. -- Extract reusable proof-inspection sections. -- Adopt them into mounted Reachability and Evidence surfaces. +- Freeze the witness/evidence capability map. DONE +- Extract reusable proof-inspection sections. DONE +- Adopt them into mounted Reachability and Evidence surfaces. DONE diff --git a/docs/modules/ui/TASKS.md b/docs/modules/ui/TASKS.md index 9786578f2..cfd1e01ee 100644 --- a/docs/modules/ui/TASKS.md +++ b/docs/modules/ui/TASKS.md @@ -6,6 +6,7 @@ - [DONE] `docs-archived/implplan/SPRINT_20260308_024_FE_orphan_revival_regression_remediation.md` - Fixed reviewed orphan-revival regressions: build blockers cleared, canonical evidence-thread navigation restored, audit/trust filter capabilities restored, and fabricated finding evidence removed from mounted hosts. - [DOING] `docs/implplan/SPRINT_20260308_025_FE_safe_cleanup_and_generated_artifacts_prune.md` - Approved UI cleanup to prune committed generated/debug artifacts plus confirmed orphan route and legacy release-control leaves. - [DONE] `docs/implplan/SPRINT_20260308_026_FE_settings_information_architecture_rationalization.md` - Settings IA rationalized: personal-preferences shell with admin/ops rehoming via controlled redirects. +- [DONE] `docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md` - Derived orphan WitnessViewerComponent into 4 reusable proof-inspection sections (VerificationSummary, SignatureInspector, AttestationDetail, EvidencePayload) adopted on Reachability WitnessPage and Evidence PacketPage. ## Queued Sprint Links - `docs/modules/ui/orphan-revival-batch/README.md` - review index for the orphan shared-component and disconnected-route revival batch. diff --git a/docs/modules/ui/implementation_plan.md b/docs/modules/ui/implementation_plan.md index 8cef3b615..24bd4b59c 100644 --- a/docs/modules/ui/implementation_plan.md +++ b/docs/modules/ui/implementation_plan.md @@ -8,6 +8,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence. - Update this file when new scoped work is approved. - Sprint `025` is active for safe cleanup of approved dead leaves and committed generated/debug artifacts in the Web workspace. - Sprint `026` shipped Settings IA rationalization: the Settings shell now owns only personal preferences (appearance, language, layout, AI assistant). All admin, tenant, and operations configuration leaves redirect to their canonical owners (Administration, Setup, Ops). See `docs/features/checked/web/settings-ia-rationalization-ui.md`. +- Sprint `031` (Witness Viewer Evidence Derivation) is DONE. Derived the orphan `WitnessViewerComponent` into 4 reusable proof-inspection sections (VerificationSummary, SignatureInspector, AttestationDetail, EvidencePayload) adopted on Reachability WitnessPage and Evidence PacketPage. See `docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md`. ## Near-term deliverables - No active UI deliverables are currently staged in `docs/implplan`. diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts index 6e019a9a5..922811a7a 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts @@ -5,14 +5,23 @@ * Detailed view of a single evidence packet with contents and verification. */ -import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@angular/core'; +import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, RouterLink } from '@angular/router'; +import { + VerificationSummaryComponent, + EvidencePayloadComponent, +} from '../../shared/ui/witness/index'; +import type { + VerificationSummaryData, + EvidencePayloadData, +} from '../../shared/ui/witness/index'; + @Component({ selector: 'app-evidence-packet-page', standalone: true, - imports: [CommonModule, RouterLink], + imports: [CommonModule, RouterLink, VerificationSummaryComponent, EvidencePayloadComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -125,30 +134,16 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
} @case ('verify') { -
-

Verification

-
- @if (packet().verified) { -
- -
- Signature Valid -

Verified against trusted key: ops-signing-key-2026

-
-
- } @else { -
- ? -
- Not Yet Verified -

Click verify to check signature

-
-
- } +
+ + +
+
- + +
} @case ('proof-chain') { @@ -340,6 +335,9 @@ import { ActivatedRoute, RouterLink } from '@angular/router'; .btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; } .btn--primary { background: var(--color-brand-primary); border: none; color: var(--color-text-heading); } .btn--secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); color: var(--color-text-primary); } + + .verify-panel { display: grid; gap: 1rem; } + .verify-action-row { display: flex; gap: 0.75rem; } `] }) export class EvidencePacketPageComponent implements OnInit { @@ -381,6 +379,38 @@ export class EvidencePacketPageComponent implements OnInit { { id: '5', type: 'Promotion', icon: '', hash: 'sha256:m3n4o5...', time: '2h ago' }, ]; + // --------------------------------------------------------------------------- + // Derived proof-inspection section data (SPRINT-031 FE-WVD-003) + // --------------------------------------------------------------------------- + + readonly verificationSummary = computed((): VerificationSummaryData => { + const p = this.packet(); + const pType: string = p.type; + return { + id: p.id, + typeLabel: pType.charAt(0).toUpperCase() + pType.slice(1), + typeBadge: pType === 'attestation' ? 'attestation' : pType === 'exception' ? 'bundle' : 'receipt', + status: p.verified ? 'verified' : p.signed ? 'unverified' : 'pending', + createdAt: p.createdAt, + source: p.environment ?? undefined, + }; + }); + + readonly evidencePayload = computed((): EvidencePayloadData => { + const p = this.packet(); + return { + evidenceId: p.id, + rawContent: JSON.stringify(p, null, 2), + metadata: { + bundleDigest: p.bundleDigest, + releaseVersion: p.releaseVersion, + environment: p.environment, + signed: p.signed, + verified: p.verified, + }, + }; + }); + ngOnInit(): void { this.route.params.subscribe(params => { this.packetId.set(params['packetId'] || ''); diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.html b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.html index 7b0000f60..a261f6be0 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.html +++ b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.html @@ -269,6 +269,19 @@ + +
+ @if (verificationSummary(); as summary) { + + } + + + + @if (evidencePayload(); as payload) { + + } +
+ { + const w = this.witness(); + if (!w) return null; + return { + id: w.witnessId, + typeLabel: 'Witness', + typeBadge: 'witness', + status: w.signature?.verified ? 'verified' + : w.signature ? 'unverified' + : 'pending', + confidenceTier: w.confidenceTier, + confidenceScore: w.confidenceScore, + createdAt: w.observedAt, + source: w.evidence.analysisMethod, + }; + }); + + readonly signatureDataList = computed((): readonly SignatureData[] => { + const w = this.witness(); + if (!w?.signature) return []; + return [{ + id: w.signature.keyId, + algorithm: w.signature.algorithm, + keyId: w.signature.keyId, + value: w.signature.signature, + timestamp: w.signature.verifiedAt, + verified: w.signature.verified ?? false, + }]; + }); + + readonly evidencePayload = computed((): EvidencePayloadData | null => { + const w = this.witness(); + if (!w) return null; + return { + evidenceId: w.witnessId, + rawContent: JSON.stringify(w, null, 2), + metadata: { + scanId: w.scanId, + vulnId: w.vulnId, + confidenceTier: w.confidenceTier, + isReachable: w.isReachable, + pathHash: w.pathHash ?? 'n/a', + analysisMethod: w.evidence.analysisMethod, + }, + }; + }); + constructor() { combineLatest([this.route.paramMap, this.route.queryParamMap]) .pipe(takeUntilDestroyed(this.destroyRef)) diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/index.ts b/src/Web/StellaOps.Web/src/app/shared/ui/index.ts index 0d096bb86..e208f261d 100644 --- a/src/Web/StellaOps.Web/src/app/shared/ui/index.ts +++ b/src/Web/StellaOps.Web/src/app/shared/ui/index.ts @@ -21,6 +21,9 @@ export * from './status-badge/status-badge.component'; export { MetricCardComponent, DeltaDirection, MetricSeverity } from './metric-card/metric-card.component'; export * from './timeline-list/timeline-list.component'; +// Witness/evidence proof-inspection sections +export * from './witness/index'; + // Utility export * from './empty-state/empty-state.component'; export * from './inline-code/inline-code.component'; diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/attestation-detail.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/attestation-detail.component.spec.ts new file mode 100644 index 000000000..6ba4c66e4 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/attestation-detail.component.spec.ts @@ -0,0 +1,133 @@ +/** + * Attestation Detail Component Tests + * Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-004) + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; + +import { AttestationDetailComponent } from './attestation-detail.component'; +import type { AttestationData } from './witness.models'; + +@Component({ + standalone: true, + imports: [AttestationDetailComponent], + template: ``, +}) +class TestHostComponent { + data = signal(null); +} + +describe('AttestationDetailComponent', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render the attestation detail section', () => { + const section = fixture.nativeElement.querySelector('[data-testid="attestation-detail"]'); + expect(section).toBeTruthy(); + }); + + it('should show empty state when no data', () => { + const empty = fixture.nativeElement.querySelector('[data-testid="attestation-empty"]'); + expect(empty).toBeTruthy(); + expect(empty.textContent).toContain('No attestation data available'); + }); + + it('should display predicate type', () => { + host.data.set({ + predicateType: 'https://in-toto.io/attestation/vulns/v0.1', + subjectName: 'registry.example.com/app:v1.2', + subjectDigests: [ + { algorithm: 'sha256', hash: 'abc123def456' }, + ], + predicate: { scanner: 'grype', version: '0.72' }, + }); + fixture.detectChanges(); + + const predicateType = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-type"]'); + expect(predicateType).toBeTruthy(); + expect(predicateType.textContent).toContain('https://in-toto.io/attestation/vulns/v0.1'); + }); + + it('should display subject name and digests', () => { + host.data.set({ + predicateType: 'https://in-toto.io/attestation/sbom/v0.1', + subjectName: 'registry.example.com/app:v1.2', + subjectDigests: [ + { algorithm: 'sha256', hash: 'abc123' }, + { algorithm: 'sha512', hash: 'def456' }, + ], + predicate: {}, + }); + fixture.detectChanges(); + + const subject = fixture.nativeElement.querySelector('[data-testid="attestation-subject"]'); + expect(subject).toBeTruthy(); + expect(subject.textContent).toContain('registry.example.com/app:v1.2'); + + const digestRows = fixture.nativeElement.querySelectorAll('.digest-row'); + expect(digestRows.length).toBe(2); + expect(digestRows[0].textContent).toContain('sha256'); + expect(digestRows[0].textContent).toContain('abc123'); + expect(digestRows[1].textContent).toContain('sha512'); + }); + + it('should toggle predicate JSON on click', () => { + host.data.set({ + predicateType: 'https://in-toto.io/attestation/sbom/v0.1', + subjectName: 'app:v1', + subjectDigests: [], + predicate: { scanner: 'grype', version: '0.72' }, + }); + fixture.detectChanges(); + + // Initially hidden + let predicateJson = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-json"]'); + expect(predicateJson).toBeNull(); + + // Click toggle + const toggle = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-toggle"]'); + expect(toggle).toBeTruthy(); + toggle.click(); + fixture.detectChanges(); + + // Now visible + predicateJson = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-json"]'); + expect(predicateJson).toBeTruthy(); + expect(predicateJson.textContent).toContain('grype'); + expect(predicateJson.textContent).toContain('0.72'); + }); + + it('should hide predicate JSON when toggled off', () => { + host.data.set({ + predicateType: 'type', + subjectName: 'subj', + subjectDigests: [], + predicate: { key: 'value' }, + }); + fixture.detectChanges(); + + const toggle = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-toggle"]'); + toggle.click(); + fixture.detectChanges(); + + // Verify visible + expect(fixture.nativeElement.querySelector('[data-testid="attestation-predicate-json"]')).toBeTruthy(); + + // Toggle off + toggle.click(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('[data-testid="attestation-predicate-json"]')).toBeNull(); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/attestation-detail.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/attestation-detail.component.ts new file mode 100644 index 000000000..faad40a0e --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/attestation-detail.component.ts @@ -0,0 +1,219 @@ +/** + * Attestation Detail Component + * Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-002) + * + * Composable section displaying attestation statement type, subject digests, + * and predicate payload. Embeddable in Reachability witness and Evidence + * proof surfaces. + */ + +import { + Component, + ChangeDetectionStrategy, + input, + signal, +} from '@angular/core'; + +import type { AttestationData } from './witness.models'; + +@Component({ + selector: 'app-attestation-detail', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+

Attestation

+ + @if (!data()) { +
+ No attestation data available. +
+ } @else { +
+
Predicate Type
+
{{ data()!.predicateType }}
+ +
Subject
+
+
+ {{ data()!.subjectName }} +
+ @for (digest of data()!.subjectDigests; track digest.algorithm) { +
+ {{ digest.algorithm }}: + {{ digest.hash }} +
+ } +
+
+
+
+ + +
+ + + @if (showPredicate()) { +
{{ predicateJson() }}
+ } +
+ } +
+ `, + styles: [` + .attestation-detail { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + padding: 1rem; + } + + .section-title { + margin: 0 0 0.75rem 0; + font-size: 0.875rem; + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-secondary); + } + + .empty-state { + padding: 1rem; + color: var(--color-text-secondary); + font-size: 0.875rem; + } + + .attestation-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.5rem 1rem; + margin: 0 0 0.75rem 0; + } + + .attestation-grid dt { + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + font-size: 0.875rem; + } + + .attestation-grid dd { + margin: 0; + } + + code { + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.8125rem; + background: var(--color-severity-none-bg); + padding: 0.1rem 0.35rem; + border-radius: var(--radius-sm); + } + + .subject-info { + display: flex; + flex-direction: column; + gap: 0.35rem; + } + + .subject-name { + font-weight: var(--font-weight-medium); + font-size: 0.875rem; + } + + .subject-digests { + display: flex; + flex-direction: column; + gap: 0.2rem; + } + + .digest-row { + display: flex; + gap: 0.5rem; + align-items: baseline; + } + + .digest-algorithm { + font-size: 0.72rem; + color: var(--color-text-muted); + min-width: 55px; + } + + .digest-value { + font-size: 0.72rem; + word-break: break-all; + } + + .predicate-section { + border-top: 1px solid var(--color-border-primary); + padding-top: 0.65rem; + } + + .predicate-toggle { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: none; + border: none; + color: var(--color-brand-primary); + cursor: pointer; + font-size: 0.82rem; + font-weight: var(--font-weight-medium); + padding: 0; + } + + .predicate-toggle svg { + transition: transform 0.15s ease; + } + + .predicate-toggle svg.rotated { + transform: rotate(90deg); + } + + .predicate-json { + background: var(--color-text-heading); + color: var(--color-severity-none-bg); + padding: 0.85rem; + border-radius: var(--radius-md); + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.78rem; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + max-height: 300px; + overflow-y: auto; + margin: 0.5rem 0 0; + } + `], +}) +export class AttestationDetailComponent { + /** Attestation data to display (null when not available). */ + readonly data = input(null); + + /** Whether the predicate JSON is expanded. */ + readonly showPredicate = signal(false); + + /** Formatted predicate JSON. */ + readonly predicateJson = () => { + const d = this.data(); + if (!d) return ''; + try { + return JSON.stringify(d.predicate, null, 2); + } catch { + return String(d.predicate); + } + }; +} diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/evidence-payload.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/evidence-payload.component.spec.ts new file mode 100644 index 000000000..36dfc1b4a --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/evidence-payload.component.spec.ts @@ -0,0 +1,117 @@ +/** + * Evidence Payload Component Tests + * Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-004) + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; + +import { EvidencePayloadComponent } from './evidence-payload.component'; +import type { EvidencePayloadData } from './witness.models'; + +@Component({ + standalone: true, + imports: [EvidencePayloadComponent], + template: ``, +}) +class TestHostComponent { + data = signal({ + evidenceId: 'ev-001', + rawContent: '{"type":"attestation","verified":true}', + metadata: { + source: 'scanner', + version: '0.72', + }, + }); +} + +describe('EvidencePayloadComponent', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render the evidence payload section', () => { + const section = fixture.nativeElement.querySelector('[data-testid="evidence-payload"]'); + expect(section).toBeTruthy(); + }); + + it('should show the "Show Raw Content" button initially', () => { + const showBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-show"]'); + expect(showBtn).toBeTruthy(); + expect(showBtn.textContent).toContain('Show Raw Content'); + }); + + it('should show raw content when toggled', () => { + const showBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-show"]'); + showBtn.click(); + fixture.detectChanges(); + + const raw = fixture.nativeElement.querySelector('[data-testid="evidence-payload-raw"]'); + expect(raw).toBeTruthy(); + expect(raw.textContent).toContain('attestation'); + expect(raw.textContent).toContain('true'); + }); + + it('should have copy and download action buttons', () => { + const copyBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-copy"]'); + const downloadBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-download"]'); + expect(copyBtn).toBeTruthy(); + expect(downloadBtn).toBeTruthy(); + expect(copyBtn.textContent).toContain('Copy'); + expect(downloadBtn.textContent).toContain('Download'); + }); + + it('should display metadata section when metadata is provided', () => { + const metadata = fixture.nativeElement.querySelector('[data-testid="evidence-payload-metadata"]'); + expect(metadata).toBeTruthy(); + expect(metadata.textContent).toContain('scanner'); + expect(metadata.textContent).toContain('0.72'); + }); + + it('should not display metadata when empty', () => { + host.data.set({ + evidenceId: 'ev-002', + rawContent: '{}', + metadata: {}, + }); + fixture.detectChanges(); + + const metadata = fixture.nativeElement.querySelector('[data-testid="evidence-payload-metadata"]'); + expect(metadata).toBeNull(); + }); + + it('should not display metadata when undefined', () => { + host.data.set({ + evidenceId: 'ev-003', + rawContent: '{}', + }); + fixture.detectChanges(); + + const metadata = fixture.nativeElement.querySelector('[data-testid="evidence-payload-metadata"]'); + expect(metadata).toBeNull(); + }); + + it('should hide raw content when toggled off', () => { + // Show + const showBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-show"]'); + showBtn.click(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[data-testid="evidence-payload-raw"]')).toBeTruthy(); + + // Hide via the "Hide raw content" link + const hideBtn = fixture.nativeElement.querySelector('.btn-link'); + hideBtn.click(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('[data-testid="evidence-payload-raw"]')).toBeNull(); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/evidence-payload.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/evidence-payload.component.ts new file mode 100644 index 000000000..240241f25 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/evidence-payload.component.ts @@ -0,0 +1,245 @@ +/** + * Evidence Payload Component + * Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-002) + * + * Composable section for viewing, copying, and downloading raw evidence + * JSON payloads and metadata. Embeddable in Reachability witness and + * Evidence proof views. + */ + +import { + Component, + ChangeDetectionStrategy, + input, + signal, + computed, +} from '@angular/core'; + +import type { EvidencePayloadData } from './witness.models'; + +@Component({ + selector: 'app-evidence-payload', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+

Raw Evidence

+
+ + +
+
+ + + @if (showRaw()) { +
+
{{ data().rawContent }}
+ +
+ } @else { + + } + + + @if (hasMetadata()) { + + } +
+ `, + styles: [` + .evidence-payload { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + padding: 1rem; + } + + .section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + } + + .section-title { + margin: 0; + font-size: 0.875rem; + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-secondary); + } + + .section-actions { + display: flex; + gap: 0.4rem; + } + + .btn-action { + padding: 0.35rem 0.65rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + color: var(--color-text-primary); + font-size: 0.78rem; + font-weight: var(--font-weight-medium); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.25rem; + } + + .btn-action:hover { + background: var(--color-severity-none-bg); + } + + .show-raw-btn { + width: 100%; + padding: 0.55rem 0.85rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + color: var(--color-text-secondary); + font-size: 0.82rem; + cursor: pointer; + } + + .show-raw-btn:hover { + background: var(--color-severity-none-bg); + } + + .raw-wrapper { + display: grid; + gap: 0.35rem; + } + + .raw-content, + .metadata-json { + background: var(--color-text-heading); + color: var(--color-severity-none-bg); + padding: 0.85rem; + border-radius: var(--radius-md); + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.78rem; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + max-height: 350px; + overflow-y: auto; + margin: 0; + } + + .btn-link { + background: none; + border: none; + color: var(--color-brand-primary); + cursor: pointer; + padding: 0; + font-size: 0.75rem; + text-decoration: underline; + justify-self: start; + } + + .metadata-section { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--color-border-primary); + } + + .metadata-title { + margin: 0 0 0.5rem 0; + font-size: 0.78rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + } + `], +}) +export class EvidencePayloadComponent { + /** Payload data to display. */ + readonly data = input.required(); + + /** Whether the raw content is expanded. */ + readonly showRaw = signal(false); + + /** Whether copy feedback is active. */ + readonly copied = signal(false); + + /** Whether metadata is non-empty. */ + readonly hasMetadata = computed(() => { + const meta = this.data().metadata; + return meta != null && Object.keys(meta).length > 0; + }); + + /** Formatted metadata JSON. */ + readonly metadataJson = computed(() => { + const meta = this.data().metadata; + if (!meta) return ''; + try { + return JSON.stringify(meta, null, 2); + } catch { + return String(meta); + } + }); + + /** Copy raw content to clipboard. */ + copyPayload(): void { + const content = this.data().rawContent; + navigator.clipboard.writeText(content).then(() => { + this.copied.set(true); + setTimeout(() => this.copied.set(false), 2000); + }); + } + + /** Download raw content as JSON file. */ + downloadPayload(): void { + const content = this.data().rawContent; + const id = this.data().evidenceId; + const blob = new Blob([content], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `evidence-${id}.json`; + anchor.click(); + URL.revokeObjectURL(url); + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/index.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/index.ts new file mode 100644 index 000000000..bc15510a6 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/index.ts @@ -0,0 +1,21 @@ +/** + * Shared Witness/Evidence Proof-Inspection Sections + * Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation + * + * Reusable composable sections derived from the orphan WitnessViewerComponent. + * Designed for embedding in mounted Reachability and Evidence surfaces. + */ + +export { VerificationSummaryComponent } from './verification-summary.component'; +export { SignatureInspectorComponent } from './signature-inspector.component'; +export { AttestationDetailComponent } from './attestation-detail.component'; +export { EvidencePayloadComponent } from './evidence-payload.component'; + +export type { + VerificationSummaryData, + SignatureData, + AttestationData, + EvidencePayloadData, + VerificationStatus, + ConfidenceTier, +} from './witness.models'; diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/signature-inspector.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/signature-inspector.component.spec.ts new file mode 100644 index 000000000..146ae9469 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/signature-inspector.component.spec.ts @@ -0,0 +1,153 @@ +/** + * Signature Inspector Component Tests + * Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-004) + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; + +import { SignatureInspectorComponent } from './signature-inspector.component'; +import type { SignatureData } from './witness.models'; + +@Component({ + standalone: true, + imports: [SignatureInspectorComponent], + template: ``, +}) +class TestHostComponent { + signatures = signal([]); +} + +describe('SignatureInspectorComponent', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render the signature inspector section', () => { + const section = fixture.nativeElement.querySelector('[data-testid="signature-inspector"]'); + expect(section).toBeTruthy(); + }); + + it('should show empty state when no signatures', () => { + const empty = fixture.nativeElement.querySelector('[data-testid="signature-empty"]'); + expect(empty).toBeTruthy(); + expect(empty.textContent).toContain('No signatures available'); + }); + + it('should render a verified signature card', () => { + host.signatures.set([{ + id: 'sig-001', + algorithm: 'ECDSA-P256', + keyId: 'key-abc-123', + value: 'MEUCIQD+base64signaturevaluehere==', + timestamp: '2026-03-08T10:30:00Z', + verified: true, + issuer: 'Stella Ops CA', + }]); + fixture.detectChanges(); + + const card = fixture.nativeElement.querySelector('[data-testid="signature-card-sig-001"]'); + expect(card).toBeTruthy(); + expect(card.classList.contains('signature-card--verified')).toBe(true); + expect(card.textContent).toContain('Verified'); + expect(card.textContent).toContain('ECDSA-P256'); + expect(card.textContent).toContain('key-abc-123'); + expect(card.textContent).toContain('Stella Ops CA'); + }); + + it('should render an unverified signature card', () => { + host.signatures.set([{ + id: 'sig-002', + algorithm: 'Ed25519', + keyId: 'key-def-456', + value: 'shortval', + verified: false, + }]); + fixture.detectChanges(); + + const card = fixture.nativeElement.querySelector('[data-testid="signature-card-sig-002"]'); + expect(card).toBeTruthy(); + expect(card.classList.contains('signature-card--verified')).toBe(false); + expect(card.textContent).toContain('Unverified'); + expect(card.textContent).toContain('Ed25519'); + }); + + it('should render multiple signature cards', () => { + host.signatures.set([ + { + id: 'sig-a', + algorithm: 'ECDSA-P256', + keyId: 'key-1', + value: 'sig-value-a', + verified: true, + }, + { + id: 'sig-b', + algorithm: 'RSA-PSS', + keyId: 'key-2', + value: 'sig-value-b', + verified: false, + }, + ]); + fixture.detectChanges(); + + const cards = fixture.nativeElement.querySelectorAll('.signature-card'); + expect(cards.length).toBe(2); + }); + + it('should truncate long signature values', () => { + const longSig = 'A'.repeat(100); + host.signatures.set([{ + id: 'sig-long', + algorithm: 'ECDSA-P256', + keyId: 'key-long', + value: longSig, + verified: true, + }]); + fixture.detectChanges(); + + const sigValue = fixture.nativeElement.querySelector('.signature-value'); + expect(sigValue).toBeTruthy(); + // Truncated: 16 chars + '...' + 16 chars = 35 chars, not 100 + expect(sigValue.textContent!.length).toBeLessThan(100); + expect(sigValue.textContent).toContain('...'); + }); + + it('should show copy button for long signatures', () => { + host.signatures.set([{ + id: 'sig-copy', + algorithm: 'ECDSA-P256', + keyId: 'key-copy', + value: 'A'.repeat(100), + verified: true, + }]); + fixture.detectChanges(); + + const copyBtn = fixture.nativeElement.querySelector('[data-testid="copy-sig-sig-copy"]'); + expect(copyBtn).toBeTruthy(); + expect(copyBtn.textContent).toContain('Copy full'); + }); + + it('should not show copy button for short signatures', () => { + host.signatures.set([{ + id: 'sig-short', + algorithm: 'ECDSA-P256', + keyId: 'key-short', + value: 'short', + verified: true, + }]); + fixture.detectChanges(); + + const copyBtn = fixture.nativeElement.querySelector('[data-testid="copy-sig-sig-short"]'); + expect(copyBtn).toBeNull(); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/signature-inspector.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/signature-inspector.component.ts new file mode 100644 index 000000000..7f9bf8710 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/signature-inspector.component.ts @@ -0,0 +1,265 @@ +/** + * Signature Inspector Component + * Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-002) + * + * Composable section showing signature details: algorithm, key ID, verification + * result, and truncated/expandable signature value. Embeddable in mounted + * Reachability witness detail and Evidence packet views. + */ + +import { + Component, + ChangeDetectionStrategy, + input, + signal, +} from '@angular/core'; + +import type { SignatureData } from './witness.models'; + +@Component({ + selector: 'app-signature-inspector', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+

+ Signatures + @if (signatures().length) { + ({{ signatures().length }}) + } +

+ + @if (signatures().length === 0) { +
+ No signatures available for this evidence. +
+ } @else { +
+ @for (sig of signatures(); track sig.id) { +
+
+ + @if (sig.verified) { + + + + Verified + } @else { + + + + Unverified + } + + {{ sig.algorithm }} +
+ +
+
Key ID
+
{{ sig.keyId }}
+ + @if (sig.issuer) { +
Issuer
+
{{ sig.issuer }}
+ } + + @if (sig.timestamp) { +
Timestamp
+
{{ formatDate(sig.timestamp) }}
+ } + +
Signature
+
+ {{ truncateSignature(sig.value) }} + @if (sig.value.length > 40) { + + } +
+
+
+ } +
+ } +
+ `, + styles: [` + .signature-inspector { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + padding: 1rem; + } + + .section-title { + margin: 0 0 0.75rem 0; + font-size: 0.875rem; + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-secondary); + } + + .section-count { + font-weight: var(--font-weight-regular, 400); + color: var(--color-text-muted); + } + + .empty-state { + padding: 1rem; + color: var(--color-text-secondary); + font-size: 0.875rem; + } + + .signatures-list { + display: grid; + gap: 0.75rem; + } + + .signature-card { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + padding: 0.85rem; + background: var(--color-surface-primary); + } + + .signature-card--verified { + border-color: var(--color-severity-low-border); + background: var(--color-severity-low-bg); + } + + .signature-card__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.6rem; + } + + .signature-card__status { + display: flex; + align-items: center; + gap: 0.25rem; + font-weight: var(--font-weight-medium); + font-size: 0.875rem; + } + + .verified-icon { + color: var(--color-status-success-text); + } + + .unverified-icon { + color: var(--color-text-muted); + } + + .signature-card__algorithm { + background: var(--color-border-primary); + padding: 0.125rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: var(--font-weight-medium); + } + + .signature-card__details { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.35rem 0.85rem; + margin: 0; + font-size: 0.875rem; + } + + .signature-card__details dt { + color: var(--color-text-muted); + } + + .signature-card__details dd { + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; + } + + code { + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.8125rem; + } + + .signature-value { + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .signature-value-cell { + flex-wrap: wrap; + } + + .btn-link { + background: none; + border: none; + color: var(--color-brand-primary); + cursor: pointer; + padding: 0; + font-size: 0.75rem; + text-decoration: underline; + } + + .btn-small { + font-size: 0.72rem; + } + `], +}) +export class SignatureInspectorComponent { + /** List of signatures to display. */ + readonly signatures = input.required(); + + /** Track which signature was just copied. */ + readonly copiedId = signal(null); + + truncateSignature(value: string): string { + if (value.length <= 40) return value; + return `${value.slice(0, 16)}...${value.slice(-16)}`; + } + + copySignature(value: string): void { + navigator.clipboard.writeText(value).then(() => { + // Find the matching signature for feedback + const sig = this.signatures().find(s => s.value === value); + if (sig) { + this.copiedId.set(sig.id); + setTimeout(() => this.copiedId.set(null), 2000); + } + }); + } + + formatDate(isoDate: string): string { + try { + return new Intl.DateTimeFormat('en-US', { + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + month: 'short', + timeZone: 'UTC', + year: 'numeric', + }).format(new Date(isoDate)); + } catch { + return isoDate; + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/verification-summary.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/verification-summary.component.spec.ts new file mode 100644 index 000000000..8385b920e --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/verification-summary.component.spec.ts @@ -0,0 +1,144 @@ +/** + * Verification Summary Component Tests + * Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-004) + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; + +import { VerificationSummaryComponent } from './verification-summary.component'; +import type { VerificationSummaryData } from './witness.models'; + +@Component({ + standalone: true, + imports: [VerificationSummaryComponent], + template: ``, +}) +class TestHostComponent { + data = signal({ + id: 'witness-001', + typeLabel: 'Witness', + typeBadge: 'witness', + status: 'verified', + confidenceTier: 'confirmed', + confidenceScore: 0.95, + createdAt: '2026-03-08T10:00:00Z', + source: 'static', + }); +} + +describe('VerificationSummaryComponent', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render the verification summary section', () => { + const section = fixture.nativeElement.querySelector('[data-testid="verification-summary"]'); + expect(section).toBeTruthy(); + }); + + it('should display verified status badge', () => { + const badge = fixture.nativeElement.querySelector('[data-testid="verification-status-verified"]'); + expect(badge).toBeTruthy(); + expect(badge.textContent).toContain('Verified'); + }); + + it('should display the evidence ID', () => { + const mono = fixture.nativeElement.querySelector('.summary-mono'); + expect(mono).toBeTruthy(); + expect(mono.textContent).toContain('witness-001'); + }); + + it('should display confidence tier badge', () => { + const badge = fixture.nativeElement.querySelector('.confidence-badge--confirmed'); + expect(badge).toBeTruthy(); + expect(badge.textContent).toContain('Confirmed Reachable'); + expect(badge.textContent).toContain('95%'); + }); + + it('should display type badge', () => { + const badge = fixture.nativeElement.querySelector('.type-badge--witness'); + expect(badge).toBeTruthy(); + expect(badge.textContent).toContain('Witness'); + }); + + it('should display failed status correctly', () => { + host.data.set({ + id: 'test-002', + typeLabel: 'Attestation', + typeBadge: 'attestation', + status: 'failed', + }); + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector('[data-testid="verification-status-failed"]'); + expect(badge).toBeTruthy(); + expect(badge.textContent).toContain('Failed'); + }); + + it('should display pending status correctly', () => { + host.data.set({ + id: 'test-003', + typeLabel: 'Bundle', + typeBadge: 'bundle', + status: 'pending', + }); + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector('[data-testid="verification-status-pending"]'); + expect(badge).toBeTruthy(); + expect(badge.textContent).toContain('Pending'); + }); + + it('should display unverified status correctly', () => { + host.data.set({ + id: 'test-004', + typeLabel: 'Signature', + typeBadge: 'signature', + status: 'unverified', + }); + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector('[data-testid="verification-status-unverified"]'); + expect(badge).toBeTruthy(); + expect(badge.textContent).toContain('Unverified'); + }); + + it('should not display confidence section when tier is undefined', () => { + host.data.set({ + id: 'test-005', + typeLabel: 'Receipt', + typeBadge: 'receipt', + status: 'verified', + }); + fixture.detectChanges(); + + const confidenceBadge = fixture.nativeElement.querySelector('.confidence-badge'); + expect(confidenceBadge).toBeNull(); + }); + + it('should not display source when not provided', () => { + host.data.set({ + id: 'test-006', + typeLabel: 'Witness', + typeBadge: 'witness', + status: 'verified', + }); + fixture.detectChanges(); + + const items = fixture.nativeElement.querySelectorAll('.summary-item'); + const labels = Array.from(items).map((el: any) => + el.querySelector('.summary-label')?.textContent?.trim() + ); + expect(labels).not.toContain('Source'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/verification-summary.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/verification-summary.component.ts new file mode 100644 index 000000000..e5cb2732c --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/verification-summary.component.ts @@ -0,0 +1,313 @@ +/** + * Verification Summary Component + * Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-002) + * + * Composable section showing pass/fail verification status, confidence tier badge, + * evidence type, and creation metadata. Designed for embedding in mounted + * Reachability and Evidence surfaces -- not as a standalone page. + */ + +import { + Component, + ChangeDetectionStrategy, + input, + computed, +} from '@angular/core'; + +import type { + VerificationSummaryData, + VerificationStatus, + ConfidenceTier, +} from './witness.models'; + +@Component({ + selector: 'app-verification-summary', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+

Verification Summary

+
+ +
+ Status + + @if (data().status === 'verified') { + + } @else if (data().status === 'failed') { + + } @else if (data().status === 'pending') { + + } @else { + + } + {{ statusLabel() }} + +
+ + +
+ Type + + {{ data().typeLabel }} + +
+ + + @if (data().confidenceTier) { +
+ Confidence + + {{ confidenceLabel() }} + @if (data().confidenceScore != null) { + ({{ confidencePercent() }}) + } + +
+ } + + + @if (data().createdAt) { +
+ Created + {{ formatDate(data().createdAt!) }} +
+ } + + + @if (data().source) { +
+ Source + {{ data().source }} +
+ } + + +
+ Evidence ID + {{ data().id }} +
+
+
+ `, + styles: [` + .verification-summary { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + padding: 1rem; + } + + .section-title { + margin: 0 0 0.75rem 0; + font-size: 0.875rem; + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-secondary); + } + + .summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; + } + + .summary-item { + display: grid; + gap: 0.2rem; + } + + .summary-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-secondary); + } + + .summary-value { + font-size: 0.875rem; + } + + .summary-mono { + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.8125rem; + word-break: break-all; + } + + .status-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.2rem 0.5rem; + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: var(--font-weight-medium); + width: fit-content; + } + + .status-badge--verified { + background: var(--color-severity-low-bg); + color: var(--color-status-success-text); + } + + .status-badge--unverified { + background: var(--color-severity-none-bg); + color: var(--color-text-secondary); + } + + .status-badge--failed { + background: var(--color-severity-critical-bg); + color: var(--color-status-error-text); + } + + .status-badge--pending { + background: var(--color-severity-medium-bg); + color: var(--color-status-warning-text); + } + + .type-badge { + display: inline-block; + padding: 0.2rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: var(--font-weight-medium); + text-transform: uppercase; + width: fit-content; + } + + .type-badge--attestation { + background: var(--color-severity-info-bg); + color: var(--color-status-info-text); + } + + .type-badge--signature { + background: var(--color-status-excepted-bg); + color: var(--color-status-excepted); + } + + .type-badge--receipt { + background: var(--color-severity-low-bg); + color: var(--color-status-success-text); + } + + .type-badge--bundle { + background: var(--color-severity-high-bg); + color: var(--color-severity-high); + } + + .type-badge--witness { + background: var(--color-severity-info-bg); + color: var(--color-status-info-text); + } + + .confidence-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.2rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: var(--font-weight-medium); + width: fit-content; + } + + .confidence-badge--confirmed { + background: var(--color-severity-critical-bg); + color: var(--color-status-error-text); + } + + .confidence-badge--likely { + background: var(--color-severity-high-bg); + color: var(--color-severity-high); + } + + .confidence-badge--present { + background: var(--color-severity-none-bg); + color: var(--color-text-secondary); + } + + .confidence-badge--unreachable { + background: var(--color-severity-low-bg); + color: var(--color-status-success-text); + } + + .confidence-badge--unknown { + background: var(--color-severity-info-bg); + color: var(--color-status-info-text); + } + + .confidence-score { + opacity: 0.8; + font-size: 0.7rem; + } + `], +}) +export class VerificationSummaryComponent { + /** Verification summary data to display. */ + readonly data = input.required(); + + readonly statusLabel = computed(() => { + const labels: Record = { + verified: 'Verified', + unverified: 'Unverified', + failed: 'Failed', + pending: 'Pending', + }; + return labels[this.data().status] ?? this.data().status; + }); + + readonly confidenceLabel = computed(() => { + const labels: Record = { + confirmed: 'Confirmed Reachable', + likely: 'Likely Reachable', + present: 'Present', + unreachable: 'Unreachable', + unknown: 'Unknown', + }; + return labels[this.data().confidenceTier!] ?? this.data().confidenceTier; + }); + + readonly confidencePercent = computed(() => { + const score = this.data().confidenceScore; + if (score == null) return ''; + return `${Math.round(score * 100)}%`; + }); + + formatDate(isoDate: string): string { + try { + return new Intl.DateTimeFormat('en-US', { + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + month: 'short', + timeZone: 'UTC', + year: 'numeric', + }).format(new Date(isoDate)); + } catch { + return isoDate; + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/witness/witness.models.ts b/src/Web/StellaOps.Web/src/app/shared/ui/witness/witness.models.ts new file mode 100644 index 000000000..e62501e5e --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/witness/witness.models.ts @@ -0,0 +1,82 @@ +/** + * Shared witness/evidence proof-inspection models. + * Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation + * + * Domain types used by the derived proof-inspection sections. + * These are intentionally presentation-level models that both the + * Reachability and Evidence features can map their domain data into. + */ + +/** Verification status for an evidence artifact. */ +export type VerificationStatus = 'verified' | 'unverified' | 'failed' | 'pending'; + +/** Confidence tier for reachability assessment (mirrors witness.models). */ +export type ConfidenceTier = 'confirmed' | 'likely' | 'present' | 'unreachable' | 'unknown'; + +/** + * Input data for the verification summary section. + */ +export interface VerificationSummaryData { + /** Unique identifier for the evidence or witness. */ + readonly id: string; + /** Human-readable label for the evidence type. */ + readonly typeLabel: string; + /** CSS class suffix for the type badge (e.g., 'attestation', 'signature'). */ + readonly typeBadge: string; + /** Current verification status. */ + readonly status: VerificationStatus; + /** Confidence tier (if available). */ + readonly confidenceTier?: ConfidenceTier; + /** Confidence score 0.0-1.0 (if available). */ + readonly confidenceScore?: number; + /** When the evidence was created or observed. */ + readonly createdAt?: string; + /** Evidence source identifier. */ + readonly source?: string; +} + +/** + * Input data for the signature inspector section. + */ +export interface SignatureData { + /** Signature identifier. */ + readonly id: string; + /** Cryptographic algorithm (e.g., ECDSA-P256, Ed25519). */ + readonly algorithm: string; + /** Key identifier. */ + readonly keyId: string; + /** Truncated or full signature value. */ + readonly value: string; + /** Timestamp of signature. */ + readonly timestamp?: string; + /** Whether the signature has been verified. */ + readonly verified: boolean; + /** Issuer of the signing key (optional). */ + readonly issuer?: string; +} + +/** + * Input data for the attestation detail section. + */ +export interface AttestationData { + /** In-toto predicate type URI. */ + readonly predicateType: string; + /** Subject name. */ + readonly subjectName: string; + /** Subject digests (algorithm -> hash). */ + readonly subjectDigests: ReadonlyArray<{ readonly algorithm: string; readonly hash: string }>; + /** Predicate payload (arbitrary JSON). */ + readonly predicate: Record; +} + +/** + * Input data for the evidence payload section. + */ +export interface EvidencePayloadData { + /** Evidence identifier for naming downloads. */ + readonly evidenceId: string; + /** Raw content to display (JSON string or raw text). */ + readonly rawContent: string; + /** Metadata key-value pairs to display. */ + readonly metadata?: Record; +}