-
-
@if (loading()) {
-
{{ 'ui.evidence_thread.loading' | translate }}
+
Loading evidence thread...
}
-
@if (error() && !loading()) {
{{ error() }}
+
}
-
@if (thread() && !loading()) {
-
-
+
+ Record Summary
+
+
+
- Canonical ID
+ {{ thread()?.canonicalId }}
+
+
+
- Format
+ - {{ thread()?.format }}
+
+
+
- Created
+ - {{ formatDate(thread()?.createdAt ?? '') }}
+
+
+
- Package URL
+ {{ thread()?.purl ?? '-' }}
+
+
+
- Artifact Digest
+
-
+ @if (thread()?.artifactDigest) {
+
+ } @else {
+ -
+ }
+
+
+
+
- Transparency
+ -
+ @if (thread()?.transparencyStatus?.mode) {
+ {{ formatTransparencyMode(thread()?.transparencyStatus?.mode) }}
+ @if (thread()?.transparencyStatus?.reason) {
+ ({{ thread()?.transparencyStatus?.reason }})
+ }
+ } @else {
+ -
+ }
+
+
+
+
-
-
-
-
- {{ 'ui.evidence_thread.graph_tab' | translate }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ 'ui.evidence_thread.timeline_tab' | translate }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ 'ui.evidence_thread.transcript_tab' | translate }}
-
-
-
-
-
-
-
-
-
-
-
- @if (selectedNodeId()) {
-
- }
+
+ Attestations
+ @if (thread()?.attestations?.length === 0) {
+
+
No attestations are currently attached to this canonical record.
+
+ } @else {
+
+
+
+ | Predicate Type |
+ Signed |
+ DSSE Digest |
+ Signer |
+ Rekor Entry |
+
+
+
+ @for (attestation of thread()?.attestations ?? []; track trackAttestation($index, attestation)) {
+
+ | {{ attestation.predicateType }} |
+ {{ formatDate(attestation.signedAt) }} |
+ {{ attestation.dsseDigest }} |
+ {{ attestation.signerKeyId ?? '-' }} |
+ {{ attestation.rekorEntryId ?? '-' }} |
+
+ }
+
+
+ }
+
}
-
@if (!thread() && !loading() && !error()) {
-
{{ 'ui.evidence_thread.not_found' | translate }}
+
No evidence thread is selected.
}
diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.scss b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.scss
index a3d6ad195..93c69bbfe 100644
--- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.scss
+++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.scss
@@ -1,18 +1,11 @@
@use 'tokens/breakpoints' as *;
-/**
- * Evidence Thread View Component Styles
- * Migrated to design system tokens
- */
-
.evidence-thread-view {
- display: flex;
- flex-direction: column;
- height: 100%;
- min-height: 0;
+ display: grid;
+ gap: var(--space-4);
+ min-height: 100%;
}
-// Header
.thread-header {
display: flex;
justify-content: space-between;
@@ -41,15 +34,13 @@
font-weight: var(--font-weight-medium);
margin: 0;
color: var(--color-text-primary);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+ word-break: break-word;
}
.thread-digest {
display: flex;
align-items: center;
- gap: var(--space-1);
+ gap: var(--space-2);
margin-top: var(--space-1);
code {
@@ -59,18 +50,7 @@
background: var(--color-surface-tertiary);
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-sm);
- }
-
- .copy-btn {
- width: 24px;
- height: 24px;
- line-height: 24px;
-
- mat-icon {
- font-size: 14px;
- width: 14px;
- height: 14px;
- }
+ word-break: break-word;
}
}
@@ -80,80 +60,10 @@
gap: var(--space-4);
flex-wrap: wrap;
}
-
- .header-actions {
- display: flex;
- align-items: center;
- gap: var(--space-2);
- }
}
-// Verdict chips styling
-.verdict-success {
- --mat-chip-elevated-container-color: var(--color-status-success-bg);
- --mat-chip-label-text-color: var(--color-status-success);
-}
-
-.verdict-warning {
- --mat-chip-elevated-container-color: var(--color-status-warning-bg);
- --mat-chip-label-text-color: var(--color-status-warning);
-}
-
-.verdict-error {
- --mat-chip-elevated-container-color: var(--color-status-error-bg);
- --mat-chip-label-text-color: var(--color-status-error);
-}
-
-.verdict-info {
- --mat-chip-elevated-container-color: var(--color-status-info-bg);
- --mat-chip-label-text-color: var(--color-status-info);
-}
-
-.verdict-neutral {
- --mat-chip-elevated-container-color: var(--color-surface-tertiary);
- --mat-chip-label-text-color: var(--color-text-muted);
-}
-
-// Loading state
-.loading-container {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: var(--space-16) var(--space-6);
- gap: var(--space-4);
-
- p {
- color: var(--color-text-muted);
- margin: 0;
- }
-}
-
-// Error state
-.error-container {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: var(--space-16) var(--space-6);
- gap: var(--space-4);
-
- mat-icon {
- font-size: 48px;
- width: 48px;
- height: 48px;
- color: var(--color-status-error);
- }
-
- p {
- color: var(--color-status-error);
- margin: 0;
- text-align: center;
- max-width: 400px;
- }
-}
-
-// Empty state
+.loading-container,
+.error-container,
.empty-container {
display: flex;
flex-direction: column;
@@ -162,96 +72,91 @@
padding: var(--space-16) var(--space-6);
gap: var(--space-4);
- mat-icon {
- font-size: 64px;
- width: 64px;
- height: 64px;
- color: var(--color-text-muted);
- opacity: 0.5;
- }
-
p {
color: var(--color-text-muted);
margin: 0;
+ text-align: center;
+ }
+
+ &.compact {
+ align-items: flex-start;
+ justify-content: flex-start;
+ padding: var(--space-6) 0;
+ }
+}
+
+.error-container {
+ p {
+ color: var(--color-status-error);
}
}
-// Content area
.thread-content {
- display: flex;
- flex: 1;
- min-height: 0;
- overflow: hidden;
+ display: grid;
+ gap: var(--space-4);
+ padding: 0 var(--space-6) var(--space-6);
+}
- mat-tab-group {
- flex: 1;
- display: flex;
- flex-direction: column;
+.summary-card {
+ padding: var(--space-2);
+}
- ::ng-deep .mat-mdc-tab-body-wrapper {
- flex: 1;
- }
+.summary-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: var(--space-4);
+
+ dt {
+ color: var(--color-text-muted);
+ font-size: var(--font-size-sm);
+ margin-bottom: var(--space-1);
}
- .tab-content {
- height: 100%;
- padding: var(--space-4);
- overflow: auto;
+ dd {
+ margin: 0;
+ color: var(--color-text-primary);
+ word-break: break-word;
}
}
-// Tab label styling
-::ng-deep .mat-mdc-tab {
- .mat-mdc-tab-label-content {
- display: flex;
- align-items: center;
- gap: var(--space-2);
+.attestations-table {
+ width: 100%;
+ border-collapse: collapse;
- mat-icon {
- font-size: 20px;
- width: 20px;
- height: 20px;
- }
+ th,
+ td {
+ padding: var(--space-3);
+ text-align: left;
+ border-bottom: 1px solid var(--color-border-primary);
+ vertical-align: top;
+ }
+
+ th {
+ color: var(--color-text-muted);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ }
+
+ code {
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-xs);
+ word-break: break-word;
}
}
-// Node detail side panel
-.node-detail-panel {
- width: 400px;
- max-width: 40%;
- border-left: 1px solid var(--color-border-primary);
- background: var(--color-surface-primary);
- overflow-y: auto;
- padding: var(--space-4);
-
- @include screen-below-md {
- position: fixed;
- top: 0;
- right: 0;
- bottom: 0;
- width: 100%;
- max-width: 100%;
- z-index: 100;
- box-shadow: var(--shadow-xl);
- }
+.transparency-reason {
+ color: var(--color-text-muted);
+ font-size: var(--font-size-sm);
}
-/* High contrast mode */
-@media (prefers-contrast: high) {
- .thread-header {
- border-bottom-width: 2px;
+@include screen-below-md {
+ .thread-content {
+ padding-left: var(--space-4);
+ padding-right: var(--space-4);
}
- .node-detail-panel {
- border-left-width: 2px;
- }
-}
-
-/* Reduced motion */
-@media (prefers-reduced-motion: reduce) {
- .evidence-thread-view *,
- .thread-header *,
- .node-detail-panel * {
- transition: none !important;
+ .attestations-table {
+ display: block;
+ overflow-x: auto;
}
}
diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.ts
index 10a894fc1..b54c2f54d 100644
--- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.ts
@@ -2,27 +2,28 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
//
-import { Component, OnInit, OnDestroy, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ OnDestroy,
+ OnInit,
+ computed,
+ inject,
+ signal,
+} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
-import { MatTabsModule } from '@angular/material/tabs';
-import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { MatButtonModule } from '@angular/material/button';
+import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
-import { MatMenuModule } from '@angular/material/menu';
-import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
-import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { Subject, takeUntil } from 'rxjs';
-import { EvidenceThreadService, EvidenceThreadGraph, EvidenceVerdict } from '../../services/evidence-thread.service';
-import { EvidenceGraphPanelComponent } from '../evidence-graph-panel/evidence-graph-panel.component';
-import { EvidenceTimelinePanelComponent } from '../evidence-timeline-panel/evidence-timeline-panel.component';
-import { EvidenceTranscriptPanelComponent } from '../evidence-transcript-panel/evidence-transcript-panel.component';
-import { EvidenceNodeCardComponent } from '../evidence-node-card/evidence-node-card.component';
-import { EvidenceExportDialogComponent } from '../evidence-export-dialog/evidence-export-dialog.component';
-import { TranslatePipe } from '../../../../core/i18n/translate.pipe';
+import {
+ EvidenceThreadAttestation,
+ EvidenceThreadService,
+} from '../../services/evidence-thread.service';
import { DigestChipComponent } from '../../../../shared/domain/digest-chip/digest-chip.component';
@Component({
@@ -31,75 +32,45 @@ import { DigestChipComponent } from '../../../../shared/domain/digest-chip/diges
imports: [
CommonModule,
RouterModule,
- MatTabsModule,
MatButtonModule,
+ MatCardModule,
MatChipsModule,
MatProgressSpinnerModule,
MatTooltipModule,
- MatMenuModule,
- MatSnackBarModule,
- MatDialogModule,
- EvidenceGraphPanelComponent,
- EvidenceTimelinePanelComponent,
- EvidenceTranscriptPanelComponent,
- EvidenceNodeCardComponent,
- TranslatePipe,
- DigestChipComponent
+ DigestChipComponent,
],
templateUrl: './evidence-thread-view.component.html',
styleUrls: ['./evidence-thread-view.component.scss'],
- changeDetection: ChangeDetectionStrategy.OnPush
+ changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EvidenceThreadViewComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
- private readonly snackBar = inject(MatSnackBar);
- private readonly dialog = inject(MatDialog);
readonly evidenceService = inject(EvidenceThreadService);
- private readonly sanitizer = inject(DomSanitizer);
-
- private readonly verdictIconSvgMap: Record
= {
- check_circle: '',
- warning: '',
- block: '',
- schedule: '',
- help_outline: '',
- };
-
- getVerdictIconSvg(verdict?: EvidenceVerdict): SafeHtml {
- const icon = this.getVerdictIcon(verdict);
- return this.sanitizer.bypassSecurityTrustHtml(this.verdictIconSvgMap[icon] || this.verdictIconSvgMap['help_outline']);
- }
-
private readonly destroy$ = new Subject();
- readonly artifactDigest = signal('');
- readonly selectedTabIndex = signal(0);
- readonly selectedNodeId = signal(null);
+ readonly canonicalId = signal('');
+ readonly returnPurl = signal(null);
readonly thread = this.evidenceService.currentThread;
readonly loading = this.evidenceService.loading;
readonly error = this.evidenceService.error;
- readonly nodes = this.evidenceService.currentNodes;
- readonly links = this.evidenceService.currentLinks;
- readonly nodesByKind = this.evidenceService.nodesByKind;
-
- readonly verdictClass = computed(() => {
- const verdict = this.thread()?.thread?.verdict;
- return this.evidenceService.getVerdictColor(verdict);
- });
-
- readonly nodeCount = computed(() => this.nodes().length);
- readonly linkCount = computed(() => this.links().length);
+ readonly attestationCount = computed(() => this.thread()?.attestations.length ?? 0);
ngOnInit(): void {
- this.route.params.pipe(takeUntil(this.destroy$)).subscribe(params => {
- const digest = params['artifactDigest'];
- if (digest) {
- this.artifactDigest.set(decodeURIComponent(digest));
- this.loadThread();
+ this.route.paramMap.pipe(takeUntil(this.destroy$)).subscribe((params) => {
+ const canonicalId = params.get('canonicalId');
+ if (!canonicalId) {
+ return;
}
+
+ this.canonicalId.set(decodeURIComponent(canonicalId));
+ this.loadThread();
+ });
+
+ this.route.queryParamMap.pipe(takeUntil(this.destroy$)).subscribe((params) => {
+ this.returnPurl.set(params.get('purl'));
});
}
@@ -109,72 +80,57 @@ export class EvidenceThreadViewComponent implements OnInit, OnDestroy {
this.evidenceService.clearCurrentThread();
}
- private loadThread(): void {
- const digest = this.artifactDigest();
- if (!digest) return;
-
- this.evidenceService.getThreadByDigest(digest)
- .pipe(takeUntil(this.destroy$))
- .subscribe({
- error: () => {
- this.snackBar.open('Failed to load evidence thread', 'Dismiss', {
- duration: 5000
- });
- }
- });
- }
-
onRefresh(): void {
this.loadThread();
}
- onTabChange(index: number): void {
- this.selectedTabIndex.set(index);
- }
-
- onNodeSelect(nodeId: string): void {
- this.selectedNodeId.set(nodeId);
- }
-
- onExport(): void {
- const thread = this.thread();
- if (!thread) return;
-
- const dialogRef = this.dialog.open(EvidenceExportDialogComponent, {
- width: '500px',
- data: {
- artifactDigest: this.artifactDigest(),
- thread: thread.thread
- }
- });
-
- dialogRef.afterClosed().subscribe(result => {
- if (result?.success) {
- this.snackBar.open('Export started successfully', 'Dismiss', {
- duration: 3000
- });
- }
- });
- }
-
onBack(): void {
- this.router.navigate(['/evidence/threads']);
+ const purl = this.returnPurl();
+ void this.router.navigate(['/evidence/threads'], {
+ queryParams: purl ? { purl } : {},
+ });
}
- getVerdictLabel(verdict?: EvidenceVerdict): string {
- if (!verdict) return 'Unknown';
- return verdict.charAt(0).toUpperCase() + verdict.slice(1);
+ formatDate(value: string): string {
+ if (!value) {
+ return '-';
+ }
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+
+ return date.toLocaleString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
}
- getVerdictIcon(verdict?: EvidenceVerdict): string {
- const icons: Record = {
- allow: 'check_circle',
- warn: 'warning',
- block: 'block',
- pending: 'schedule',
- unknown: 'help_outline'
- };
- return icons[verdict ?? 'unknown'] ?? 'help_outline';
+ formatTransparencyMode(mode?: string): string {
+ if (!mode) {
+ return 'Unknown';
+ }
+
+ return mode.charAt(0).toUpperCase() + mode.slice(1);
}
+ trackAttestation(_index: number, attestation: EvidenceThreadAttestation): string {
+ return `${attestation.dsseDigest}:${attestation.predicateType}`;
+ }
+
+ private loadThread(): void {
+ const canonicalId = this.canonicalId();
+ if (!canonicalId) {
+ return;
+ }
+
+ this.evidenceService
+ .getThreadByCanonicalId(canonicalId)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe();
+ }
}
diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/services/evidence-thread.service.ts b/src/Web/StellaOps.Web/src/app/features/evidence-thread/services/evidence-thread.service.ts
index eda20e659..3f7f2c4bf 100644
--- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/services/evidence-thread.service.ts
+++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/services/evidence-thread.service.ts
@@ -2,32 +2,74 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
//
-import { Injectable, inject, signal, computed } from '@angular/core';
-import { HttpClient, HttpParams } from '@angular/common/http';
-import { Observable, catchError, tap, of, BehaviorSubject, map } from 'rxjs';
+import { Injectable, computed, inject, signal } from '@angular/core';
+import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
+import { Observable, catchError, map, of, tap } from 'rxjs';
-// Evidence Thread Models
export type EvidenceThreadStatus = 'active' | 'archived' | 'exported';
export type EvidenceVerdict = 'allow' | 'warn' | 'block' | 'pending' | 'unknown';
-export type ReachabilityMode = 'exploitable' | 'likely_exploitable' | 'possibly_exploitable' | 'unreachable' | 'unknown';
-export type EvidenceNodeKind = 'sbom_diff' | 'reachability' | 'vex' | 'attestation' | 'policy_eval' | 'runtime_observation' | 'patch_verification' | 'approval' | 'ai_rationale';
-export type EvidenceLinkRelation = 'supports' | 'contradicts' | 'precedes' | 'triggers' | 'derived_from' | 'references';
+export type ReachabilityMode =
+ | 'exploitable'
+ | 'likely_exploitable'
+ | 'possibly_exploitable'
+ | 'unreachable'
+ | 'unknown';
+export type EvidenceNodeKind =
+ | 'sbom_diff'
+ | 'reachability'
+ | 'vex'
+ | 'attestation'
+ | 'policy_eval'
+ | 'runtime_observation'
+ | 'patch_verification'
+ | 'approval'
+ | 'ai_rationale';
+export type EvidenceLinkRelation =
+ | 'supports'
+ | 'contradicts'
+ | 'precedes'
+ | 'triggers'
+ | 'derived_from'
+ | 'references';
export type TranscriptType = 'summary' | 'detailed' | 'audit';
export type ExportFormat = 'dsse' | 'json' | 'pdf' | 'markdown';
+export interface EvidenceThreadAttestation {
+ predicateType: string;
+ dsseDigest: string;
+ signerKeyId?: string;
+ rekorEntryId?: string;
+ rekorTile?: string;
+ signedAt: string;
+}
+
+export interface EvidenceThreadTransparencyStatus {
+ mode: string;
+ reason?: string;
+}
+
export interface EvidenceThread {
- id: string;
- tenantId: string;
- artifactDigest: string;
- artifactName?: string;
- status: EvidenceThreadStatus;
- verdict?: EvidenceVerdict;
- riskScore?: number;
- reachabilityMode?: ReachabilityMode;
- knowledgeSnapshotHash?: string;
- engineVersion?: string;
+ canonicalId: string;
+ format: string;
+ artifactDigest?: string;
+ purl?: string;
+ attestations: EvidenceThreadAttestation[];
+ transparencyStatus?: EvidenceThreadTransparencyStatus;
createdAt: string;
- updatedAt: string;
+}
+
+export interface EvidenceThreadSummary {
+ canonicalId: string;
+ format: string;
+ purl?: string;
+ attestationCount: number;
+ createdAt: string;
+}
+
+export interface EvidencePaginationInfo {
+ total: number;
+ limit: number;
+ offset: number;
}
export interface EvidenceAnchor {
@@ -100,10 +142,8 @@ export interface EvidenceThreadGraph {
}
export interface EvidenceThreadListResponse {
- items: EvidenceThread[];
- total: number;
- page: number;
- pageSize: number;
+ threads: EvidenceThreadSummary[];
+ pagination: EvidencePaginationInfo;
}
export interface TranscriptRequest {
@@ -118,194 +158,217 @@ export interface ExportRequest {
}
export interface EvidenceThreadFilter {
- status?: EvidenceThreadStatus;
- verdict?: EvidenceVerdict;
- artifactName?: string;
- page?: number;
- pageSize?: number;
+ purl?: string;
+}
+
+interface EvidenceThreadListApiResponse {
+ threads?: EvidenceThreadSummaryApiModel[];
+ pagination?: EvidencePaginationApiModel;
+}
+
+interface EvidenceThreadSummaryApiModel {
+ canonical_id?: string;
+ format?: string;
+ purl?: string;
+ attestation_count?: number;
+ created_at?: string;
+}
+
+interface EvidencePaginationApiModel {
+ total?: number;
+ limit?: number;
+ offset?: number;
+}
+
+interface EvidenceThreadApiModel {
+ canonical_id?: string;
+ format?: string;
+ artifact_digest?: string;
+ purl?: string;
+ attestations?: EvidenceThreadAttestationApiModel[];
+ transparency_status?: EvidenceThreadTransparencyStatusApiModel;
+ created_at?: string;
+}
+
+interface EvidenceThreadAttestationApiModel {
+ predicate_type?: string;
+ dsse_digest?: string;
+ signer_keyid?: string;
+ rekor_entry_id?: string;
+ rekor_tile?: string;
+ signed_at?: string;
+}
+
+interface EvidenceThreadTransparencyStatusApiModel {
+ mode?: string;
+ reason?: string;
+}
+
+function emptyListResponse(): EvidenceThreadListResponse {
+ return {
+ threads: [],
+ pagination: {
+ total: 0,
+ limit: 0,
+ offset: 0,
+ },
+ };
}
-/**
- * Service for managing Evidence Threads.
- * Provides API integration and local state management for evidence thread operations.
- */
@Injectable({
- providedIn: 'root'
+ providedIn: 'root',
})
export class EvidenceThreadService {
private readonly httpClient = inject(HttpClient);
- private readonly apiBase = '/api/v1/evidence';
+ private readonly apiBase = '/api/v1/evidence/thread';
- // Local state signals
- private readonly _currentThread = signal(null);
- private readonly _threads = signal([]);
- private readonly _loading = signal(false);
+ private readonly _currentThread = signal(null);
+ private readonly _threads = signal([]);
+ private readonly _loading = signal(false);
private readonly _error = signal(null);
+ private readonly _currentNodes = signal([]);
+ private readonly _currentLinks = signal([]);
- // Public computed signals
readonly currentThread = this._currentThread.asReadonly();
readonly threads = this._threads.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
-
- readonly currentNodes = computed(() => this._currentThread()?.nodes ?? []);
- readonly currentLinks = computed(() => this._currentThread()?.links ?? []);
+ readonly currentNodes = this._currentNodes.asReadonly();
+ readonly currentLinks = this._currentLinks.asReadonly();
readonly nodesByKind = computed(() => {
- const nodes = this.currentNodes();
+ const nodes = this._currentNodes();
return nodes.reduce((acc, node) => {
if (!acc[node.kind]) {
acc[node.kind] = [];
}
+
acc[node.kind].push(node);
return acc;
}, {} as Record);
});
- /**
- * Fetches a list of evidence threads with optional filtering.
- */
getThreads(filter?: EvidenceThreadFilter): Observable {
+ const purl = filter?.purl?.trim() ?? '';
+ this._error.set(null);
+
+ if (!purl) {
+ const empty = emptyListResponse();
+ this._threads.set(empty.threads);
+ this._loading.set(false);
+ return of(empty);
+ }
+
+ this._loading.set(true);
+ const params = new HttpParams().set('purl', purl);
+
+ return this.httpClient
+ .get(`${this.apiBase}/`, { params })
+ .pipe(
+ map((response) => this.normalizeListResponse(response)),
+ tap((response) => {
+ this._threads.set(response.threads);
+ this._loading.set(false);
+ }),
+ catchError((error) => {
+ this._threads.set([]);
+ this._error.set(
+ this.buildErrorMessage(error, `Failed to load evidence threads for ${purl}.`)
+ );
+ this._loading.set(false);
+ return of(emptyListResponse());
+ })
+ );
+ }
+
+ getThreadByCanonicalId(canonicalId: string): Observable {
+ const normalizedCanonicalId = canonicalId.trim();
+ if (!normalizedCanonicalId) {
+ this._currentThread.set(null);
+ this._currentNodes.set([]);
+ this._currentLinks.set([]);
+ return of(null);
+ }
+
this._loading.set(true);
this._error.set(null);
- let params = new HttpParams();
- if (filter?.status) {
- params = params.set('status', filter.status);
- }
- if (filter?.verdict) {
- params = params.set('verdict', filter.verdict);
- }
- if (filter?.artifactName) {
- params = params.set('artifactName', filter.artifactName);
- }
- if (filter?.page !== undefined) {
- params = params.set('page', filter.page.toString());
- }
- if (filter?.pageSize !== undefined) {
- params = params.set('pageSize', filter.pageSize.toString());
- }
+ return this.httpClient
+ .get(`${this.apiBase}/${encodeURIComponent(normalizedCanonicalId)}`)
+ .pipe(
+ map((response) => this.normalizeThreadResponse(response)),
+ tap((thread) => {
+ this._currentThread.set(thread);
+ this._currentNodes.set([]);
+ this._currentLinks.set([]);
+ this._loading.set(false);
+ }),
+ catchError((error) => {
+ this._currentThread.set(null);
+ this._currentNodes.set([]);
+ this._currentLinks.set([]);
+ this._error.set(
+ this.buildErrorMessage(
+ error,
+ `Failed to load evidence thread ${normalizedCanonicalId}.`
+ )
+ );
+ this._loading.set(false);
+ return of(null);
+ })
+ );
+ }
- return this.httpClient.get(this.apiBase, { params }).pipe(
- tap(response => {
- this._threads.set(response.items);
- this._loading.set(false);
- }),
- catchError(err => {
- this._error.set(err.message ?? 'Failed to fetch evidence threads');
- this._loading.set(false);
- return of({ items: [], total: 0, page: 1, pageSize: 20 });
- })
+ // Compatibility shim for older revived components/tests.
+ getThreadByDigest(canonicalId: string): Observable {
+ return this.getThreadByCanonicalId(canonicalId);
+ }
+
+ getNodes(_canonicalId: string): Observable {
+ return of([]);
+ }
+
+ getLinks(_canonicalId: string): Observable {
+ return of([]);
+ }
+
+ generateTranscript(
+ _canonicalId: string,
+ _request: TranscriptRequest
+ ): Observable {
+ this._error.set(
+ 'Evidence transcripts are not supported by the current EvidenceLocker thread API.'
);
+ return of(null);
}
- /**
- * Fetches a single evidence thread by artifact digest, including its full graph.
- */
- getThreadByDigest(artifactDigest: string): Observable {
- this._loading.set(true);
- this._error.set(null);
-
- const encodedDigest = encodeURIComponent(artifactDigest);
- return this.httpClient.get(`${this.apiBase}/${encodedDigest}`).pipe(
- tap(graph => {
- this._currentThread.set(graph);
- this._loading.set(false);
- }),
- catchError(err => {
- this._error.set(err.message ?? 'Failed to fetch evidence thread');
- this._loading.set(false);
- return of(null);
- })
+ exportThread(
+ _canonicalId: string,
+ _request: ExportRequest
+ ): Observable {
+ this._error.set(
+ 'Evidence exports are not supported by the current EvidenceLocker thread API.'
);
+ return of(null);
}
- /**
- * Fetches nodes for a specific thread.
- */
- getNodes(artifactDigest: string): Observable {
- const encodedDigest = encodeURIComponent(artifactDigest);
- return this.httpClient.get(`${this.apiBase}/${encodedDigest}/nodes`).pipe(
- catchError(err => {
- this._error.set(err.message ?? 'Failed to fetch evidence nodes');
- return of([]);
- })
+ downloadExport(_exportId: string): Observable {
+ this._error.set(
+ 'Evidence exports are not supported by the current EvidenceLocker thread API.'
);
+ return of(new Blob());
}
- /**
- * Fetches links for a specific thread.
- */
- getLinks(artifactDigest: string): Observable {
- const encodedDigest = encodeURIComponent(artifactDigest);
- return this.httpClient.get(`${this.apiBase}/${encodedDigest}/links`).pipe(
- catchError(err => {
- this._error.set(err.message ?? 'Failed to fetch evidence links');
- return of([]);
- })
- );
- }
-
- /**
- * Generates a transcript for the evidence thread.
- */
- generateTranscript(artifactDigest: string, request: TranscriptRequest): Observable {
- const encodedDigest = encodeURIComponent(artifactDigest);
-
- return this.httpClient.post(
- `${this.apiBase}/${encodedDigest}/transcript`,
- request
- ).pipe(
- catchError(err => {
- this._error.set(err.message ?? 'Failed to generate transcript');
- return of(null);
- })
- );
- }
-
- /**
- * Exports the evidence thread in the specified format.
- */
- exportThread(artifactDigest: string, request: ExportRequest): Observable {
- const encodedDigest = encodeURIComponent(artifactDigest);
-
- return this.httpClient.post(
- `${this.apiBase}/${encodedDigest}/export`,
- request
- ).pipe(
- catchError(err => {
- this._error.set(err.message ?? 'Failed to export evidence thread');
- return of(null);
- })
- );
- }
-
- /**
- * Downloads an exported evidence bundle.
- */
- downloadExport(exportId: string): Observable {
- return this.httpClient.get(`${this.apiBase}/exports/${exportId}/download`, {
- responseType: 'blob'
- });
- }
-
- /**
- * Clears the current thread from local state.
- */
clearCurrentThread(): void {
this._currentThread.set(null);
+ this._currentNodes.set([]);
+ this._currentLinks.set([]);
}
- /**
- * Clears any error state.
- */
clearError(): void {
this._error.set(null);
}
- /**
- * Gets the display label for a node kind.
- */
getNodeKindLabel(kind: EvidenceNodeKind): string {
const labels: Record = {
sbom_diff: 'SBOM Diff',
@@ -316,14 +379,11 @@ export class EvidenceThreadService {
runtime_observation: 'Runtime Observation',
patch_verification: 'Patch Verification',
approval: 'Approval',
- ai_rationale: 'AI Rationale'
+ ai_rationale: 'AI Rationale',
};
return labels[kind] ?? kind;
}
- /**
- * Gets the icon name for a node kind.
- */
getNodeKindIcon(kind: EvidenceNodeKind): string {
const icons: Record = {
sbom_diff: 'compare_arrows',
@@ -334,29 +394,26 @@ export class EvidenceThreadService {
runtime_observation: 'visibility',
patch_verification: 'check_circle',
approval: 'thumb_up',
- ai_rationale: 'psychology'
+ ai_rationale: 'psychology',
};
return icons[kind] ?? 'help_outline';
}
- /**
- * Gets the color class for a verdict.
- */
getVerdictColor(verdict?: EvidenceVerdict): string {
- if (!verdict) return 'neutral';
+ if (!verdict) {
+ return 'neutral';
+ }
+
const colors: Record = {
allow: 'success',
warn: 'warning',
block: 'error',
pending: 'info',
- unknown: 'neutral'
+ unknown: 'neutral',
};
return colors[verdict] ?? 'neutral';
}
- /**
- * Gets the display label for a link relation.
- */
getLinkRelationLabel(relation: EvidenceLinkRelation): string {
const labels: Record = {
supports: 'Supports',
@@ -364,8 +421,80 @@ export class EvidenceThreadService {
precedes: 'Precedes',
triggers: 'Triggers',
derived_from: 'Derived From',
- references: 'References'
+ references: 'References',
};
return labels[relation] ?? relation;
}
+
+ private normalizeListResponse(
+ response: EvidenceThreadListApiResponse | null | undefined
+ ): EvidenceThreadListResponse {
+ const threads = (response?.threads ?? []).map((thread) => ({
+ canonicalId: thread.canonical_id ?? '',
+ format: thread.format ?? 'unknown',
+ purl: thread.purl ?? undefined,
+ attestationCount: thread.attestation_count ?? 0,
+ createdAt: thread.created_at ?? '',
+ }));
+
+ return {
+ threads,
+ pagination: {
+ total: response?.pagination?.total ?? threads.length,
+ limit: response?.pagination?.limit ?? threads.length,
+ offset: response?.pagination?.offset ?? 0,
+ },
+ };
+ }
+
+ private normalizeThreadResponse(response: EvidenceThreadApiModel): EvidenceThread {
+ return {
+ canonicalId: response.canonical_id ?? '',
+ format: response.format ?? 'unknown',
+ artifactDigest: response.artifact_digest ?? undefined,
+ purl: response.purl ?? undefined,
+ attestations: (response.attestations ?? []).map((attestation) => ({
+ predicateType: attestation.predicate_type ?? 'unknown',
+ dsseDigest: attestation.dsse_digest ?? '',
+ signerKeyId: attestation.signer_keyid ?? undefined,
+ rekorEntryId: attestation.rekor_entry_id ?? undefined,
+ rekorTile: attestation.rekor_tile ?? undefined,
+ signedAt: attestation.signed_at ?? '',
+ })),
+ transparencyStatus: response.transparency_status?.mode
+ ? {
+ mode: response.transparency_status.mode,
+ reason: response.transparency_status.reason ?? undefined,
+ }
+ : undefined,
+ createdAt: response.created_at ?? '',
+ };
+ }
+
+ private buildErrorMessage(error: unknown, fallback: string): string {
+ if (error instanceof HttpErrorResponse) {
+ const apiError =
+ typeof error.error === 'object' && error.error && 'error' in error.error
+ ? String(error.error.error)
+ : null;
+
+ if (apiError) {
+ return apiError;
+ }
+
+ if (typeof error.error === 'string' && error.error.trim()) {
+ return error.error.trim();
+ }
+
+ if (error.status === 404) {
+ return 'Evidence thread not found.';
+ }
+ }
+
+ if (error instanceof Error && error.message.trim()) {
+ return error.message;
+ }
+
+ return fallback;
+ }
}
diff --git a/src/Web/StellaOps.Web/src/app/features/timeline/components/timeline-filter/timeline-filter.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/timeline/components/timeline-filter/timeline-filter.component.spec.ts
new file mode 100644
index 000000000..f1a7fb3be
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/features/timeline/components/timeline-filter/timeline-filter.component.spec.ts
@@ -0,0 +1,33 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, provideRouter } from '@angular/router';
+import { of } from 'rxjs';
+
+import { TimelineFilterComponent } from './timeline-filter.component';
+
+describe('TimelineFilterComponent', () => {
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TimelineFilterComponent],
+ providers: [
+ provideRouter([]),
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ queryParams: of({}),
+ },
+ },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TimelineFilterComponent);
+ });
+
+ it('renders the filter form without Angular Material control errors', () => {
+ expect(() => fixture.detectChanges()).not.toThrow();
+ expect(
+ fixture.nativeElement.querySelector('input[formControlName="fromHlc"]')
+ ).not.toBeNull();
+ });
+});
diff --git a/src/Web/StellaOps.Web/src/app/features/timeline/components/timeline-filter/timeline-filter.component.ts b/src/Web/StellaOps.Web/src/app/features/timeline/components/timeline-filter/timeline-filter.component.ts
index 6681ba5a3..9f6128470 100644
--- a/src/Web/StellaOps.Web/src/app/features/timeline/components/timeline-filter/timeline-filter.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/timeline/components/timeline-filter/timeline-filter.component.ts
@@ -7,6 +7,7 @@ import { Component, Output, EventEmitter, inject, OnInit, OnDestroy } from '@ang
import { FormBuilder, ReactiveFormsModule, FormGroup } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
@@ -28,6 +29,7 @@ import {
imports: [
ReactiveFormsModule,
MatFormFieldModule,
+ MatInputModule,
MatSelectModule,
MatButtonModule,
MatChipsModule
diff --git a/src/Web/StellaOps.Web/src/tests/evidence/evidence-thread-browser.component.spec.ts b/src/Web/StellaOps.Web/src/tests/evidence/evidence-thread-browser.component.spec.ts
index 55bc72669..037fc3082 100644
--- a/src/Web/StellaOps.Web/src/tests/evidence/evidence-thread-browser.component.spec.ts
+++ b/src/Web/StellaOps.Web/src/tests/evidence/evidence-thread-browser.component.spec.ts
@@ -1,51 +1,52 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { MatDialog } from '@angular/material/dialog';
-import { ActivatedRoute, Router, provideRouter } from '@angular/router';
+import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject, of } from 'rxjs';
import { EvidenceThreadListComponent } from '../../app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component';
import { EvidenceThreadViewComponent } from '../../app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component';
-import {
- EvidenceThread,
- EvidenceThreadGraph,
- EvidenceThreadService,
-} from '../../app/features/evidence-thread/services/evidence-thread.service';
+import { EvidenceThreadService } from '../../app/features/evidence-thread/services/evidence-thread.service';
describe('Evidence thread browser', () => {
describe('EvidenceThreadListComponent', () => {
let fixture: ComponentFixture;
let component: EvidenceThreadListComponent;
let router: Router;
+ let queryParamMap$: BehaviorSubject>;
- const thread: EvidenceThread = {
- id: 'thread-1',
- tenantId: 'tenant-1',
- artifactDigest: 'sha256:artifact-1',
- artifactName: 'artifact-a',
- status: 'active',
- verdict: 'allow',
- riskScore: 2.2,
- createdAt: '2026-02-10T00:00:00Z',
- updatedAt: '2026-02-10T00:00:00Z',
+ const thread = {
+ canonicalId: 'canon-1',
+ format: 'dsse-envelope',
+ purl: 'pkg:oci/acme/api@sha256:abc123',
+ attestationCount: 2,
+ createdAt: '2026-03-08T09:00:00Z',
};
const listServiceStub = {
- threads: signal([thread]),
+ threads: signal([thread]),
loading: signal(false),
error: signal(null),
getThreads: jasmine
.createSpy('getThreads')
- .and.returnValue(of({ items: [thread], total: 1, page: 1, pageSize: 20 })),
- getVerdictColor: jasmine.createSpy('getVerdictColor').and.returnValue('success'),
+ .and.returnValue(of({ threads: [thread], pagination: { total: 1, limit: 25, offset: 0 } })),
};
beforeEach(async () => {
+ queryParamMap$ = new BehaviorSubject(
+ convertToParamMap({ purl: 'pkg:oci/acme/api@sha256:abc123' })
+ );
+
await TestBed.configureTestingModule({
imports: [EvidenceThreadListComponent],
providers: [
provideRouter([]),
{ provide: EvidenceThreadService, useValue: listServiceStub },
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ queryParamMap: queryParamMap$.asObservable(),
+ },
+ },
],
}).compileComponents();
@@ -57,18 +58,23 @@ describe('Evidence thread browser', () => {
fixture.detectChanges();
});
- it('loads thread list on init and tracks total rows', () => {
- expect(listServiceStub.getThreads).toHaveBeenCalled();
- expect(component.totalItems()).toBe(1);
+ it('loads thread list from the current PURL query parameter', () => {
+ expect(component.searchQuery).toBe('pkg:oci/acme/api@sha256:abc123');
expect(component.threads().length).toBe(1);
+ expect(listServiceStub.getThreads).toHaveBeenCalledWith({
+ purl: 'pkg:oci/acme/api@sha256:abc123',
+ });
});
- it('navigates to encoded thread digest when a row is opened', () => {
+ it('navigates to the canonical-id detail route and preserves the lookup PURL', () => {
component.onRowClick(thread);
- expect(router.navigate).toHaveBeenCalledWith([
- '/evidence/threads',
- encodeURIComponent('sha256:artifact-1'),
- ]);
+
+ expect(router.navigate).toHaveBeenCalledWith(
+ ['/evidence/threads', encodeURIComponent('canon-1')],
+ {
+ queryParams: { purl: 'pkg:oci/acme/api@sha256:abc123' },
+ }
+ );
});
});
@@ -76,79 +82,45 @@ describe('Evidence thread browser', () => {
let fixture: ComponentFixture;
let component: EvidenceThreadViewComponent;
let router: Router;
- let routeParams$: BehaviorSubject>;
- const graph: EvidenceThreadGraph = {
- thread: {
- id: 'thread-1',
- tenantId: 'tenant-1',
- artifactDigest: 'sha256:artifact-1',
- artifactName: 'artifact-a',
- status: 'active',
- verdict: 'allow',
- riskScore: 2.2,
- createdAt: '2026-02-10T00:00:00Z',
- updatedAt: '2026-02-10T00:00:00Z',
- },
- nodes: [
+ const thread = {
+ canonicalId: 'canon-1',
+ format: 'dsse-envelope',
+ artifactDigest: 'sha256:artifact-1',
+ purl: 'pkg:oci/acme/api@sha256:abc123',
+ createdAt: '2026-03-08T09:00:00Z',
+ attestations: [
{
- id: 'node-1',
- tenantId: 'tenant-1',
- threadId: 'thread-1',
- kind: 'sbom_diff',
- refId: 'ref-1',
- title: 'SBOM',
- anchors: [],
- content: {},
- createdAt: '2026-02-10T00:00:00Z',
+ predicateType: 'https://slsa.dev/provenance/v1',
+ dsseDigest: 'sha256:dsse-1',
+ signerKeyId: 'signer-1',
+ rekorEntryId: 'entry-1',
+ signedAt: '2026-03-08T09:05:00Z',
},
],
- links: [],
+ };
+
+ const viewServiceStub = {
+ currentThread: signal(thread),
+ loading: signal(false),
+ error: signal(null),
+ getThreadByCanonicalId: jasmine
+ .createSpy('getThreadByCanonicalId')
+ .and.returnValue(of(thread)),
+ clearCurrentThread: jasmine.createSpy('clearCurrentThread'),
};
beforeEach(async () => {
- Object.defineProperty(globalThis, 'ResizeObserver', {
- configurable: true,
- writable: true,
- value: class {
- observe(): void {}
- unobserve(): void {}
- disconnect(): void {}
- },
- });
-
- routeParams$ = new BehaviorSubject>({
- artifactDigest: encodeURIComponent('sha256:artifact-1'),
- });
-
- const dialogStub = {
- open: jasmine.createSpy('open').and.returnValue({
- afterClosed: () => of(null),
- }),
- };
-
- const viewServiceStub = {
- currentThread: signal(graph),
- loading: signal(false),
- error: signal(null),
- currentNodes: signal(graph.nodes),
- currentLinks: signal(graph.links),
- nodesByKind: signal({ sbom_diff: graph.nodes }),
- getThreadByDigest: jasmine.createSpy('getThreadByDigest').and.returnValue(of(graph)),
- clearCurrentThread: jasmine.createSpy('clearCurrentThread'),
- getVerdictColor: jasmine.createSpy('getVerdictColor').and.returnValue('success'),
- };
-
await TestBed.configureTestingModule({
imports: [EvidenceThreadViewComponent],
providers: [
provideRouter([]),
{ provide: EvidenceThreadService, useValue: viewServiceStub },
- { provide: MatDialog, useValue: dialogStub },
{
provide: ActivatedRoute,
useValue: {
- params: routeParams$.asObservable(),
+ paramMap: of(convertToParamMap({ canonicalId: 'canon-1' })),
+ queryParamMap: of(convertToParamMap({ purl: 'pkg:oci/acme/api@sha256:abc123' })),
},
},
],
@@ -162,15 +134,18 @@ describe('Evidence thread browser', () => {
fixture.detectChanges();
});
- it('decodes route digest and loads thread details', () => {
- expect(component.artifactDigest()).toBe('sha256:artifact-1');
- expect(component.thread()?.thread.id).toBe('thread-1');
+ it('loads thread details from the canonical-id route parameter', () => {
+ expect(component.canonicalId()).toBe('canon-1');
+ expect(component.thread()?.canonicalId).toBe('canon-1');
+ expect(viewServiceStub.getThreadByCanonicalId).toHaveBeenCalledWith('canon-1');
});
- it('navigates back to the canonical evidence threads list', () => {
+ it('navigates back to the PURL-filtered evidence threads list', () => {
component.onBack();
- expect(router.navigate).toHaveBeenCalledWith(['/evidence/threads']);
+ expect(router.navigate).toHaveBeenCalledWith(['/evidence/threads'], {
+ queryParams: { purl: 'pkg:oci/acme/api@sha256:abc123' },
+ });
});
});
});
diff --git a/src/Web/StellaOps.Web/src/tests/evidence/evidence-thread.service.spec.ts b/src/Web/StellaOps.Web/src/tests/evidence/evidence-thread.service.spec.ts
index 650cb41e4..119ac44aa 100644
--- a/src/Web/StellaOps.Web/src/tests/evidence/evidence-thread.service.spec.ts
+++ b/src/Web/StellaOps.Web/src/tests/evidence/evidence-thread.service.spec.ts
@@ -4,7 +4,7 @@ import { TestBed } from '@angular/core/testing';
import { EvidenceThreadService } from '../../app/features/evidence-thread/services/evidence-thread.service';
-describe('EvidenceThreadService loading behavior', () => {
+describe('EvidenceThreadService compatibility actions', () => {
let service: EvidenceThreadService;
let httpMock: HttpTestingController;
@@ -21,56 +21,29 @@ describe('EvidenceThreadService loading behavior', () => {
httpMock.verify();
});
- it('does not toggle global loading when generating transcript', () => {
- expect(service.loading()).toBeFalse();
+ it('fails closed without network traffic when transcript generation is requested', () => {
+ let actual: unknown = 'pending';
- let actual: unknown = null;
- service.generateTranscript('sha256:artifact-dev', { transcriptType: 'summary' }).subscribe((value) => {
+ service.generateTranscript('canon-1', { transcriptType: 'summary' }).subscribe((value) => {
actual = value;
});
- const request = httpMock.expectOne('/api/v1/evidence/sha256%3Aartifact-dev/transcript');
- expect(request.request.method).toBe('POST');
+ expect(actual).toBeNull();
expect(service.loading()).toBeFalse();
-
- request.flush({
- id: 'transcript-1',
- tenantId: 'tenant-a',
- threadId: 'thread-1',
- transcriptType: 'summary',
- templateVersion: 'v1',
- content: 'summary text',
- anchors: [],
- generatedAt: '2026-02-11T00:00:00Z',
- });
-
- expect(service.loading()).toBeFalse();
- expect(actual).not.toBeNull();
+ expect(service.error()).toContain('not supported');
+ expect(httpMock.match(() => true).length).toBe(0);
});
- it('does not toggle global loading when exporting thread content', () => {
- expect(service.loading()).toBeFalse();
+ it('fails closed without network traffic when evidence export is requested', () => {
+ let actual: unknown = 'pending';
- let actual: unknown = null;
- service.exportThread('sha256:artifact-dev', { format: 'json', sign: false }).subscribe((value) => {
+ service.exportThread('canon-1', { format: 'json', sign: false }).subscribe((value) => {
actual = value;
});
- const request = httpMock.expectOne('/api/v1/evidence/sha256%3Aartifact-dev/export');
- expect(request.request.method).toBe('POST');
+ expect(actual).toBeNull();
expect(service.loading()).toBeFalse();
-
- request.flush({
- id: 'export-1',
- tenantId: 'tenant-a',
- threadId: 'thread-1',
- exportFormat: 'json',
- contentHash: 'sha256:export-1',
- storagePath: '/tmp/export-1.json',
- createdAt: '2026-02-11T00:00:00Z',
- });
-
- expect(service.loading()).toBeFalse();
- expect(actual).not.toBeNull();
+ expect(service.error()).toContain('not supported');
+ expect(httpMock.match(() => true).length).toBe(0);
});
});