feat(web): derive witness-viewer into reusable proof-inspection sections for mounted surfaces [SPRINT-031]
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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: `
|
||||
<div class="evidence-packet">
|
||||
@@ -125,30 +134,16 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
</div>
|
||||
}
|
||||
@case ('verify') {
|
||||
<div class="panel">
|
||||
<h3>Verification</h3>
|
||||
<div class="verification-status">
|
||||
@if (packet().verified) {
|
||||
<div class="verification-result verification-result--success">
|
||||
<span class="verification-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||||
<div>
|
||||
<strong>Signature Valid</strong>
|
||||
<p>Verified against trusted key: ops-signing-key-2026</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="verification-result verification-result--pending">
|
||||
<span class="verification-icon">?</span>
|
||||
<div>
|
||||
<strong>Not Yet Verified</strong>
|
||||
<p>Click verify to check signature</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="panel verify-panel" data-testid="verify-tab">
|
||||
<app-verification-summary [data]="verificationSummary()" />
|
||||
|
||||
<div class="verify-action-row">
|
||||
<button type="button" class="btn btn--primary" (click)="runVerification()">
|
||||
Run Verification
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn--primary" (click)="runVerification()">
|
||||
Run Verification
|
||||
</button>
|
||||
|
||||
<app-evidence-payload [data]="evidencePayload()" />
|
||||
</div>
|
||||
}
|
||||
@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: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>', 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'] || '');
|
||||
|
||||
@@ -269,6 +269,19 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Derived proof-inspection sections (SPRINT-031 FE-WVD-003) -->
|
||||
<section class="proof-inspection-grid" data-testid="proof-inspection-sections">
|
||||
@if (verificationSummary(); as summary) {
|
||||
<app-verification-summary [data]="summary" />
|
||||
}
|
||||
|
||||
<app-signature-inspector [signatures]="signatureDataList()" />
|
||||
|
||||
@if (evidencePayload(); as payload) {
|
||||
<app-evidence-payload [data]="payload" />
|
||||
}
|
||||
</section>
|
||||
|
||||
<app-poe-drawer
|
||||
[open]="showPoe() && !!proofArtifact()"
|
||||
[poeArtifact]="proofArtifact()"
|
||||
|
||||
@@ -292,6 +292,11 @@ h2 {
|
||||
}
|
||||
}
|
||||
|
||||
.proof-inspection-grid {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.path-row {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -27,6 +27,17 @@ import {
|
||||
findWitnessFixture,
|
||||
} from './reachability-fixtures';
|
||||
|
||||
import {
|
||||
VerificationSummaryComponent,
|
||||
SignatureInspectorComponent,
|
||||
EvidencePayloadComponent,
|
||||
} from '../../shared/ui/witness/index';
|
||||
import type {
|
||||
VerificationSummaryData,
|
||||
SignatureData,
|
||||
EvidencePayloadData,
|
||||
} from '../../shared/ui/witness/index';
|
||||
|
||||
interface WitnessPathRow {
|
||||
readonly id: string;
|
||||
readonly symbol: string;
|
||||
@@ -40,7 +51,13 @@ type MessageType = 'success' | 'error';
|
||||
@Component({
|
||||
selector: 'app-witness-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, PoEDrawerComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
PoEDrawerComponent,
|
||||
VerificationSummaryComponent,
|
||||
SignatureInspectorComponent,
|
||||
EvidencePayloadComponent,
|
||||
],
|
||||
templateUrl: './witness-page.component.html',
|
||||
styleUrls: ['./witness-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -156,6 +173,58 @@ export class WitnessPageComponent {
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived proof-inspection section data (SPRINT-031 FE-WVD-003)
|
||||
// Maps Reachability domain data to the shared witness section inputs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
readonly verificationSummary = computed((): VerificationSummaryData | null => {
|
||||
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))
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: `<app-attestation-detail [data]="data()" />`,
|
||||
})
|
||||
class TestHostComponent {
|
||||
data = signal<AttestationData | null>(null);
|
||||
}
|
||||
|
||||
describe('AttestationDetailComponent', () => {
|
||||
let fixture: ComponentFixture<TestHostComponent>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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: `
|
||||
<section class="attestation-detail" data-testid="attestation-detail">
|
||||
<h3 class="section-title">Attestation</h3>
|
||||
|
||||
@if (!data()) {
|
||||
<div class="empty-state" data-testid="attestation-empty">
|
||||
No attestation data available.
|
||||
</div>
|
||||
} @else {
|
||||
<dl class="attestation-grid">
|
||||
<dt>Predicate Type</dt>
|
||||
<dd><code data-testid="attestation-predicate-type">{{ data()!.predicateType }}</code></dd>
|
||||
|
||||
<dt>Subject</dt>
|
||||
<dd>
|
||||
<div class="subject-info">
|
||||
<span class="subject-name" data-testid="attestation-subject">{{ data()!.subjectName }}</span>
|
||||
<div class="subject-digests">
|
||||
@for (digest of data()!.subjectDigests; track digest.algorithm) {
|
||||
<div class="digest-row">
|
||||
<span class="digest-algorithm">{{ digest.algorithm }}:</span>
|
||||
<code class="digest-value">{{ digest.hash }}</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<!-- Predicate JSON (collapsible) -->
|
||||
<div class="predicate-section">
|
||||
<button
|
||||
type="button"
|
||||
class="predicate-toggle"
|
||||
(click)="showPredicate.set(!showPredicate())"
|
||||
[attr.aria-expanded]="showPredicate()"
|
||||
data-testid="attestation-predicate-toggle"
|
||||
>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true"
|
||||
[class.rotated]="showPredicate()"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
Predicate Data
|
||||
</button>
|
||||
|
||||
@if (showPredicate()) {
|
||||
<pre class="predicate-json" data-testid="attestation-predicate-json">{{ predicateJson() }}</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
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<AttestationData | null>(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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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: `<app-evidence-payload [data]="data()" />`,
|
||||
})
|
||||
class TestHostComponent {
|
||||
data = signal<EvidencePayloadData>({
|
||||
evidenceId: 'ev-001',
|
||||
rawContent: '{"type":"attestation","verified":true}',
|
||||
metadata: {
|
||||
source: 'scanner',
|
||||
version: '0.72',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('EvidencePayloadComponent', () => {
|
||||
let fixture: ComponentFixture<TestHostComponent>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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: `
|
||||
<section class="evidence-payload" data-testid="evidence-payload">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">Raw Evidence</h3>
|
||||
<div class="section-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action"
|
||||
(click)="copyPayload()"
|
||||
data-testid="evidence-payload-copy"
|
||||
>
|
||||
@if (copied()) {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true"
|
||||
style="display:inline;vertical-align:middle">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Copied
|
||||
} @else {
|
||||
Copy
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action"
|
||||
(click)="downloadPayload()"
|
||||
data-testid="evidence-payload-download"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw Content (collapsible) -->
|
||||
@if (showRaw()) {
|
||||
<div class="raw-wrapper">
|
||||
<pre class="raw-content" data-testid="evidence-payload-raw">{{ data().rawContent }}</pre>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
(click)="showRaw.set(false)"
|
||||
>
|
||||
Hide raw content
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="show-raw-btn"
|
||||
(click)="showRaw.set(true)"
|
||||
data-testid="evidence-payload-show"
|
||||
>
|
||||
Show Raw Content
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Metadata (when available) -->
|
||||
@if (hasMetadata()) {
|
||||
<div class="metadata-section">
|
||||
<h4 class="metadata-title">Metadata</h4>
|
||||
<pre class="metadata-json" data-testid="evidence-payload-metadata">{{ metadataJson() }}</pre>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
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<EvidencePayloadData>();
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
21
src/Web/StellaOps.Web/src/app/shared/ui/witness/index.ts
Normal file
21
src/Web/StellaOps.Web/src/app/shared/ui/witness/index.ts
Normal file
@@ -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';
|
||||
@@ -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: `<app-signature-inspector [signatures]="signatures()" />`,
|
||||
})
|
||||
class TestHostComponent {
|
||||
signatures = signal<readonly SignatureData[]>([]);
|
||||
}
|
||||
|
||||
describe('SignatureInspectorComponent', () => {
|
||||
let fixture: ComponentFixture<TestHostComponent>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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: `
|
||||
<section class="signature-inspector" data-testid="signature-inspector">
|
||||
<h3 class="section-title">
|
||||
Signatures
|
||||
@if (signatures().length) {
|
||||
<span class="section-count">({{ signatures().length }})</span>
|
||||
}
|
||||
</h3>
|
||||
|
||||
@if (signatures().length === 0) {
|
||||
<div class="empty-state" data-testid="signature-empty">
|
||||
No signatures available for this evidence.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="signatures-list">
|
||||
@for (sig of signatures(); track sig.id) {
|
||||
<div
|
||||
class="signature-card"
|
||||
[class.signature-card--verified]="sig.verified"
|
||||
[attr.data-testid]="'signature-card-' + sig.id"
|
||||
>
|
||||
<div class="signature-card__header">
|
||||
<span class="signature-card__status">
|
||||
@if (sig.verified) {
|
||||
<span class="verified-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</span>
|
||||
Verified
|
||||
} @else {
|
||||
<span class="unverified-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
</svg>
|
||||
</span>
|
||||
Unverified
|
||||
}
|
||||
</span>
|
||||
<span class="signature-card__algorithm">{{ sig.algorithm }}</span>
|
||||
</div>
|
||||
|
||||
<dl class="signature-card__details">
|
||||
<dt>Key ID</dt>
|
||||
<dd><code>{{ sig.keyId }}</code></dd>
|
||||
|
||||
@if (sig.issuer) {
|
||||
<dt>Issuer</dt>
|
||||
<dd>{{ sig.issuer }}</dd>
|
||||
}
|
||||
|
||||
@if (sig.timestamp) {
|
||||
<dt>Timestamp</dt>
|
||||
<dd>{{ formatDate(sig.timestamp) }}</dd>
|
||||
}
|
||||
|
||||
<dt>Signature</dt>
|
||||
<dd class="signature-value-cell">
|
||||
<code class="signature-value">{{ truncateSignature(sig.value) }}</code>
|
||||
@if (sig.value.length > 40) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link btn-small"
|
||||
(click)="copySignature(sig.value)"
|
||||
[attr.data-testid]="'copy-sig-' + sig.id"
|
||||
>
|
||||
{{ copiedId() === sig.id ? 'Copied' : 'Copy full' }}
|
||||
</button>
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
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<readonly SignatureData[]>();
|
||||
|
||||
/** Track which signature was just copied. */
|
||||
readonly copiedId = signal<string | null>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: `<app-verification-summary [data]="data()" />`,
|
||||
})
|
||||
class TestHostComponent {
|
||||
data = signal<VerificationSummaryData>({
|
||||
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<TestHostComponent>;
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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: `
|
||||
<section class="verification-summary" data-testid="verification-summary">
|
||||
<h3 class="section-title">Verification Summary</h3>
|
||||
<div class="summary-grid">
|
||||
<!-- Status -->
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Status</span>
|
||||
<span
|
||||
class="status-badge"
|
||||
[class]="'status-badge--' + data().status"
|
||||
[attr.data-testid]="'verification-status-' + data().status"
|
||||
>
|
||||
@if (data().status === 'verified') {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true"
|
||||
style="display:inline;vertical-align:middle">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
} @else if (data().status === 'failed') {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true"
|
||||
style="display:inline;vertical-align:middle">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
} @else if (data().status === 'pending') {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true"
|
||||
style="display:inline;vertical-align:middle">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
} @else {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true"
|
||||
style="display:inline;vertical-align:middle">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
</svg>
|
||||
}
|
||||
{{ statusLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Evidence Type -->
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Type</span>
|
||||
<span class="type-badge" [class]="'type-badge--' + data().typeBadge">
|
||||
{{ data().typeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Confidence Tier (when present) -->
|
||||
@if (data().confidenceTier) {
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Confidence</span>
|
||||
<span class="confidence-badge" [class]="'confidence-badge--' + data().confidenceTier">
|
||||
{{ confidenceLabel() }}
|
||||
@if (data().confidenceScore != null) {
|
||||
<span class="confidence-score">({{ confidencePercent() }})</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Created -->
|
||||
@if (data().createdAt) {
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Created</span>
|
||||
<span class="summary-value">{{ formatDate(data().createdAt!) }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Source -->
|
||||
@if (data().source) {
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Source</span>
|
||||
<span class="summary-value">{{ data().source }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ID -->
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Evidence ID</span>
|
||||
<code class="summary-mono">{{ data().id }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
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<VerificationSummaryData>();
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
const labels: Record<VerificationStatus, string> = {
|
||||
verified: 'Verified',
|
||||
unverified: 'Unverified',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
};
|
||||
return labels[this.data().status] ?? this.data().status;
|
||||
});
|
||||
|
||||
readonly confidenceLabel = computed(() => {
|
||||
const labels: Record<ConfidenceTier, string> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
}
|
||||
Reference in New Issue
Block a user