sprints completion. new product advisories prepared

This commit is contained in:
master
2026-01-16 16:30:03 +02:00
parent a927d924e3
commit 4ca3ce8fb4
255 changed files with 42434 additions and 1020 deletions

View File

@@ -156,6 +156,11 @@ import {
HttpDoctorClient,
MockDoctorClient,
} from './features/doctor/services/doctor.client';
import {
WITNESS_API,
WitnessHttpClient,
WitnessMockClient,
} from './core/api/witness.client';
export const appConfig: ApplicationConfig = {
providers: [
@@ -696,5 +701,17 @@ export const appConfig: ApplicationConfig = {
mock: MockDoctorClient
) => (config.config.quickstartMode ? mock : http),
},
// Witness API (Sprint 20260112_013_FE_witness_ui_wiring)
WitnessHttpClient,
WitnessMockClient,
{
provide: WITNESS_API,
deps: [AppConfigService, WitnessHttpClient, WitnessMockClient],
useFactory: (
config: AppConfigService,
http: WitnessHttpClient,
mock: WitnessMockClient
) => (config.config.quickstartMode ? mock : http),
},
],
};

View File

@@ -0,0 +1,255 @@
// -----------------------------------------------------------------------------
// binary-index-ops.client.ts
// Sprint: SPRINT_20260112_005_FE_binaryindex_ops_ui
// Task: FE-BINOPS-01 — BinaryIndex ops API client
// -----------------------------------------------------------------------------
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, catchError, throwError } from 'rxjs';
/**
* Health status of a BinaryIndex component.
*/
export interface BinaryIndexComponentHealth {
readonly name: string;
readonly status: 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
readonly message?: string;
readonly lastCheckAt?: string;
}
/**
* ISA-specific lifter warmness information.
*/
export interface BinaryIndexIsaWarmness {
readonly isa: string;
readonly warm: boolean;
readonly poolSize: number;
readonly availableCount: number;
readonly lastUsedAt?: string;
}
/**
* Response from GET /api/v1/ops/binaryindex/health
*/
export interface BinaryIndexOpsHealthResponse {
readonly status: 'healthy' | 'degraded' | 'unhealthy';
readonly timestamp: string;
readonly components: readonly BinaryIndexComponentHealth[];
readonly lifterWarmness: readonly BinaryIndexIsaWarmness[];
readonly cacheStatus?: {
readonly connected: boolean;
readonly backend: string;
};
}
/**
* Latency summary statistics from benchmark run.
*/
export interface BinaryIndexBenchLatencySummary {
readonly min: number;
readonly max: number;
readonly mean: number;
readonly p50: number;
readonly p95: number;
readonly p99: number;
}
/**
* Individual benchmark operation result.
*/
export interface BinaryIndexBenchOperationResult {
readonly operation: string;
readonly latencyMs: number;
readonly success: boolean;
readonly error?: string;
}
/**
* Response from POST /api/v1/ops/binaryindex/bench/run
*/
export interface BinaryIndexBenchResponse {
readonly timestamp: string;
readonly sampleSize: number;
readonly latencySummary: BinaryIndexBenchLatencySummary;
readonly operations: readonly BinaryIndexBenchOperationResult[];
}
/**
* Response from GET /api/v1/ops/binaryindex/cache
*/
export interface BinaryIndexFunctionCacheStats {
readonly enabled: boolean;
readonly backend: string;
readonly hits: number;
readonly misses: number;
readonly evictions: number;
readonly hitRate: number;
readonly keyPrefix: string;
readonly cacheTtlSeconds: number;
readonly estimatedEntries?: number;
readonly estimatedMemoryBytes?: number;
}
/**
* B2R2 pool configuration view (sanitized).
*/
export interface B2R2PoolConfigView {
readonly maxPoolSizePerIsa: number;
readonly warmPreload: boolean;
readonly acquireTimeoutMs: number;
readonly enableMetrics: boolean;
}
/**
* Semantic lifting configuration view (sanitized).
*/
export interface SemanticLiftingConfigView {
readonly b2r2Version: string;
readonly normalizationRecipeVersion: string;
readonly maxInstructionsPerFunction: number;
readonly maxFunctionsPerBinary: number;
readonly functionLiftTimeoutMs: number;
readonly enableDeduplication: boolean;
}
/**
* Function cache configuration view (sanitized).
*/
export interface FunctionCacheConfigView {
readonly enabled: boolean;
readonly backend: string;
readonly keyPrefix: string;
readonly cacheTtlSeconds: number;
readonly maxTtlSeconds: number;
readonly earlyExpiryPercent: number;
readonly maxEntrySizeBytes: number;
}
/**
* Persistence configuration view (sanitized).
*/
export interface PersistenceConfigView {
readonly schema: string;
readonly minPoolSize: number;
readonly maxPoolSize: number;
readonly commandTimeoutSeconds: number;
readonly retryOnFailure: boolean;
readonly batchSize: number;
}
/**
* Backend version information.
*/
export interface BackendVersions {
readonly binaryIndex: string;
readonly b2r2: string;
readonly valkey?: string;
readonly postgresql?: string;
}
/**
* Response from GET /api/v1/ops/binaryindex/config
*/
export interface BinaryIndexEffectiveConfig {
readonly b2r2Pool: B2R2PoolConfigView;
readonly semanticLifting: SemanticLiftingConfigView;
readonly functionCache: FunctionCacheConfigView;
readonly persistence: PersistenceConfigView;
readonly versions: BackendVersions;
}
/**
* Error response from ops endpoints.
*/
export interface BinaryIndexOpsError {
readonly code: string;
readonly message: string;
readonly details?: string;
}
/**
* Injection token for BinaryIndex ops API.
*/
export const BINARY_INDEX_OPS_API = new InjectionToken<BinaryIndexOpsApi>('BinaryIndexOpsApi');
/**
* BinaryIndex Ops API interface.
*/
export interface BinaryIndexOpsApi {
getHealth(): Observable<BinaryIndexOpsHealthResponse>;
runBench(iterations?: number): Observable<BinaryIndexBenchResponse>;
getCacheStats(): Observable<BinaryIndexFunctionCacheStats>;
getEffectiveConfig(): Observable<BinaryIndexEffectiveConfig>;
}
/**
* HTTP client for BinaryIndex ops endpoints.
*/
@Injectable({ providedIn: 'root' })
export class BinaryIndexOpsClient implements BinaryIndexOpsApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/ops/binaryindex';
/**
* Get BinaryIndex health status including lifter warmness and cache status.
*/
getHealth(): Observable<BinaryIndexOpsHealthResponse> {
return this.http.get<BinaryIndexOpsHealthResponse>(`${this.baseUrl}/health`).pipe(
catchError(this.handleError)
);
}
/**
* Run benchmark sample and get latency statistics.
* @param iterations Optional number of iterations (default: server-defined)
*/
runBench(iterations?: number): Observable<BinaryIndexBenchResponse> {
const body = iterations !== undefined ? { iterations } : {};
return this.http.post<BinaryIndexBenchResponse>(`${this.baseUrl}/bench/run`, body).pipe(
catchError(this.handleError)
);
}
/**
* Get function cache statistics.
*/
getCacheStats(): Observable<BinaryIndexFunctionCacheStats> {
return this.http.get<BinaryIndexFunctionCacheStats>(`${this.baseUrl}/cache`).pipe(
catchError(this.handleError)
);
}
/**
* Get effective configuration (sanitized, secrets redacted).
*/
getEffectiveConfig(): Observable<BinaryIndexEffectiveConfig> {
return this.http.get<BinaryIndexEffectiveConfig>(`${this.baseUrl}/config`).pipe(
catchError(this.handleError)
);
}
private handleError(error: HttpErrorResponse): Observable<never> {
let message = 'BinaryIndex ops request failed';
if (error.status === 0) {
message = 'BinaryIndex service is unreachable (offline or network error)';
} else if (error.status === 401) {
message = 'Unauthorized: authentication required for BinaryIndex ops';
} else if (error.status === 403) {
message = 'Forbidden: insufficient permissions for BinaryIndex ops';
} else if (error.status === 429) {
message = 'Rate limited: too many BinaryIndex ops requests';
} else if (error.status >= 500) {
message = `BinaryIndex service error: ${error.statusText || 'internal error'}`;
} else if (error.error?.message) {
message = error.error.message;
}
return throwError(() => ({
code: `BINOPS_${error.status || 0}`,
message,
details: error.message,
} as BinaryIndexOpsError));
}
}

View File

@@ -198,6 +198,45 @@ export interface VexActorRef {
readonly displayName: string;
}
/**
* Signature metadata for signed VEX decisions.
* Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui (FE-RISK-005)
*/
export interface VexDecisionSignatureInfo {
/** Whether the decision is cryptographically signed */
readonly isSigned: boolean;
/** DSSE envelope digest (base64-encoded) */
readonly dsseDigest?: string;
/** Signature algorithm used (e.g., 'ecdsa-p256', 'rsa-sha256') */
readonly signatureAlgorithm?: string;
/** Key ID used for signing */
readonly signingKeyId?: string;
/** Signer identity (e.g., email, OIDC subject) */
readonly signerIdentity?: string;
/** Timestamp when signed (ISO-8601) */
readonly signedAt?: string;
/** Signature verification status */
readonly verificationStatus?: 'verified' | 'failed' | 'pending' | 'unknown';
/** Rekor transparency log entry if logged */
readonly rekorEntry?: VexRekorEntry;
}
/**
* Rekor transparency log entry for VEX decisions.
*/
export interface VexRekorEntry {
/** Rekor log index */
readonly logIndex: number;
/** Rekor log ID (tree hash) */
readonly logId?: string;
/** Entry UUID in Rekor */
readonly entryUuid?: string;
/** Time integrated into the log (ISO-8601) */
readonly integratedTime?: string;
/** URL to view/verify the entry */
readonly verifyUrl?: string;
}
export interface VexDecision {
readonly id: string;
readonly vulnerabilityId: string;
@@ -212,6 +251,8 @@ export interface VexDecision {
readonly createdBy: VexActorRef;
readonly createdAt: string;
readonly updatedAt?: string;
/** Signature metadata for signed decisions (FE-RISK-005) */
readonly signatureInfo?: VexDecisionSignatureInfo;
}
// VEX status summary for UI display

View File

@@ -0,0 +1,377 @@
// -----------------------------------------------------------------------------
// binary-index-ops.component.spec.ts
// Sprint: SPRINT_20260112_005_FE_binaryindex_ops_ui
// Task: FE-BINOPS-04 — Tests for BinaryIndex Ops UI
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { BinaryIndexOpsComponent } from './binary-index-ops.component';
import {
BinaryIndexOpsClient,
BinaryIndexOpsHealthResponse,
BinaryIndexBenchResponse,
BinaryIndexFunctionCacheStats,
BinaryIndexEffectiveConfig,
} from '../../core/api/binary-index-ops.client';
describe('BinaryIndexOpsComponent', () => {
let fixture: ComponentFixture<BinaryIndexOpsComponent>;
let component: BinaryIndexOpsComponent;
let mockClient: jasmine.SpyObj<BinaryIndexOpsClient>;
const mockHealth: BinaryIndexOpsHealthResponse = {
status: 'healthy',
timestamp: '2026-01-16T10:00:00Z',
components: [
{ name: 'B2R2Pool', status: 'healthy', message: 'All lifters available' },
{ name: 'FunctionCache', status: 'healthy', message: 'Connected to Valkey' },
],
lifterWarmness: [
{ isa: 'x86-64', warm: true, poolSize: 4, availableCount: 4 },
{ isa: 'arm64', warm: false, poolSize: 2, availableCount: 0 },
],
cacheStatus: { connected: true, backend: 'valkey' },
};
const mockBench: BinaryIndexBenchResponse = {
timestamp: '2026-01-16T10:05:00Z',
sampleSize: 10,
latencySummary: {
min: 1.2,
max: 15.8,
mean: 5.4,
p50: 4.5,
p95: 12.3,
p99: 14.9,
},
operations: [
{ operation: 'lifter_acquire', latencyMs: 2.1, success: true },
{ operation: 'cache_lookup', latencyMs: 0.8, success: true },
],
};
const mockCache: BinaryIndexFunctionCacheStats = {
enabled: true,
backend: 'valkey',
hits: 1500,
misses: 250,
evictions: 50,
hitRate: 0.857,
keyPrefix: 'binidx:fn:',
cacheTtlSeconds: 3600,
estimatedEntries: 1200,
estimatedMemoryBytes: 52428800,
};
const mockConfig: BinaryIndexEffectiveConfig = {
b2r2Pool: {
maxPoolSizePerIsa: 4,
warmPreload: true,
acquireTimeoutMs: 5000,
enableMetrics: true,
},
semanticLifting: {
b2r2Version: '2.1.0',
normalizationRecipeVersion: '1.0.0',
maxInstructionsPerFunction: 10000,
maxFunctionsPerBinary: 5000,
functionLiftTimeoutMs: 30000,
enableDeduplication: true,
},
functionCache: {
enabled: true,
backend: 'valkey',
keyPrefix: 'binidx:fn:',
cacheTtlSeconds: 3600,
maxTtlSeconds: 86400,
earlyExpiryPercent: 10,
maxEntrySizeBytes: 1048576,
},
persistence: {
schema: 'binary_index',
minPoolSize: 2,
maxPoolSize: 10,
commandTimeoutSeconds: 30,
retryOnFailure: true,
batchSize: 100,
},
versions: {
binaryIndex: '1.0.0',
b2r2: '2.1.0',
valkey: '7.0.0',
postgresql: '16.1',
},
};
beforeEach(async () => {
mockClient = jasmine.createSpyObj<BinaryIndexOpsClient>('BinaryIndexOpsClient', [
'getHealth',
'runBench',
'getCacheStats',
'getEffectiveConfig',
]);
mockClient.getHealth.and.returnValue(of(mockHealth));
mockClient.runBench.and.returnValue(of(mockBench));
mockClient.getCacheStats.and.returnValue(of(mockCache));
mockClient.getEffectiveConfig.and.returnValue(of(mockConfig));
await TestBed.configureTestingModule({
imports: [BinaryIndexOpsComponent],
providers: [{ provide: BinaryIndexOpsClient, useValue: mockClient }],
}).compileComponents();
fixture = TestBed.createComponent(BinaryIndexOpsComponent);
component = fixture.componentInstance;
});
afterEach(() => {
component.ngOnDestroy();
});
describe('initialization', () => {
it('should load health data on init', () => {
fixture.detectChanges();
expect(mockClient.getHealth).toHaveBeenCalled();
});
it('should set loading to false after data loads', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(component.loading()).toBe(false);
});
it('should display overall status', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(component.overallStatus()).toBe('healthy');
});
});
describe('error handling', () => {
it('should display error when health check fails', async () => {
mockClient.getHealth.and.returnValue(
throwError(() => ({ message: 'Service unavailable' }))
);
fixture.detectChanges();
await fixture.whenStable();
expect(component.error()).toBe('Service unavailable');
});
it('should allow retry after error', async () => {
mockClient.getHealth.and.returnValue(
throwError(() => ({ message: 'Network error' }))
);
fixture.detectChanges();
await fixture.whenStable();
// Reset to succeed
mockClient.getHealth.and.returnValue(of(mockHealth));
component.refresh();
await fixture.whenStable();
expect(component.error()).toBeNull();
expect(component.health()).toEqual(mockHealth);
});
});
describe('tabs', () => {
it('should default to health tab', () => {
expect(component.activeTab()).toBe('health');
});
it('should switch to bench tab', () => {
component.setTab('bench');
expect(component.activeTab()).toBe('bench');
});
it('should load cache stats when switching to cache tab', async () => {
fixture.detectChanges();
await fixture.whenStable();
component.setTab('cache');
await fixture.whenStable();
expect(mockClient.getCacheStats).toHaveBeenCalled();
});
it('should load config when switching to config tab', async () => {
fixture.detectChanges();
await fixture.whenStable();
component.setTab('config');
await fixture.whenStable();
expect(mockClient.getEffectiveConfig).toHaveBeenCalled();
});
});
describe('health tab', () => {
beforeEach(async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
});
it('should display lifter warmness', () => {
const lifterCards = fixture.nativeElement.querySelectorAll('.lifter-card');
expect(lifterCards.length).toBe(2);
});
it('should indicate warm lifters', () => {
const warmCard = fixture.nativeElement.querySelector('.lifter-card--warm');
expect(warmCard).toBeTruthy();
});
it('should display component health table', () => {
const healthTable = fixture.nativeElement.querySelector('.health-table');
expect(healthTable).toBeTruthy();
});
});
describe('benchmark tab', () => {
beforeEach(async () => {
fixture.detectChanges();
await fixture.whenStable();
component.setTab('bench');
fixture.detectChanges();
});
it('should show run benchmark button', () => {
const button = fixture.nativeElement.querySelector('.bench-button');
expect(button).toBeTruthy();
expect(button.textContent).toContain('Run Benchmark Sample');
});
it('should run benchmark when button clicked', async () => {
component.runBench();
await fixture.whenStable();
expect(mockClient.runBench).toHaveBeenCalled();
expect(component.bench()).toEqual(mockBench);
});
it('should disable button while running', () => {
component.benchRunning.set(true);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.bench-button');
expect(button.disabled).toBe(true);
});
it('should display latency summary after benchmark', async () => {
component.runBench();
await fixture.whenStable();
fixture.detectChanges();
const latencyCards = fixture.nativeElement.querySelectorAll('.latency-card');
expect(latencyCards.length).toBe(6); // min, mean, max, p50, p95, p99
});
});
describe('cache tab', () => {
beforeEach(async () => {
fixture.detectChanges();
await fixture.whenStable();
component.setTab('cache');
await fixture.whenStable();
fixture.detectChanges();
});
it('should display cache overview', () => {
const cacheCards = fixture.nativeElement.querySelectorAll('.cache-card');
expect(cacheCards.length).toBe(4); // backend, enabled, prefix, ttl
});
it('should display hit rate', () => {
const statCards = fixture.nativeElement.querySelectorAll('.stat-card');
expect(statCards.length).toBeGreaterThan(0);
});
});
describe('config tab', () => {
beforeEach(async () => {
fixture.detectChanges();
await fixture.whenStable();
component.setTab('config');
await fixture.whenStable();
fixture.detectChanges();
});
it('should display read-only notice', () => {
const notice = fixture.nativeElement.querySelector('.config-notice');
expect(notice).toBeTruthy();
expect(notice.textContent).toContain('Read-only');
});
it('should display config tables', () => {
const tables = fixture.nativeElement.querySelectorAll('.config-table');
expect(tables.length).toBeGreaterThan(0);
});
it('should display backend versions', () => {
const versionCells = fixture.nativeElement.querySelectorAll('.config-value.monospace');
const versions = Array.from(versionCells).map((el: any) => el.textContent);
expect(versions.some((v: string) => v.includes('1.0.0'))).toBe(true);
});
});
describe('formatBytes', () => {
it('should format bytes correctly', () => {
expect(component.formatBytes(500)).toBe('500 B');
expect(component.formatBytes(1536)).toBe('1.5 KB');
expect(component.formatBytes(1572864)).toBe('1.5 MB');
expect(component.formatBytes(1610612736)).toBe('1.50 GB');
});
});
describe('formatStatus', () => {
it('should format known statuses', () => {
expect(component.formatStatus('healthy')).toBe('Healthy');
expect(component.formatStatus('degraded')).toBe('Degraded');
expect(component.formatStatus('unhealthy')).toBe('Unhealthy');
expect(component.formatStatus('unknown')).toBe('Unknown');
});
});
describe('deterministic output', () => {
it('should use ASCII-only status indicators', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const html = fixture.nativeElement.innerHTML;
// Check for ASCII indicators
expect(html).toContain('[+]');
expect(html).toContain('[-]');
// Ensure no emoji or non-ASCII symbols
const nonAsciiPattern = /[^\x00-\x7F]/;
const textContent = fixture.nativeElement.textContent;
expect(nonAsciiPattern.test(textContent)).toBe(false);
});
});
describe('auto-refresh', () => {
it('should set up refresh interval on init', fakeAsync(() => {
fixture.detectChanges();
expect(mockClient.getHealth).toHaveBeenCalledTimes(1);
tick(30000);
expect(mockClient.getHealth).toHaveBeenCalledTimes(2);
component.ngOnDestroy();
}));
it('should clear interval on destroy', fakeAsync(() => {
fixture.detectChanges();
component.ngOnDestroy();
tick(60000);
// Should not have called more than initial + one refresh
expect(mockClient.getHealth.calls.count()).toBeLessThanOrEqual(2);
}));
});
});

View File

@@ -0,0 +1,948 @@
// -----------------------------------------------------------------------------
// binary-index-ops.component.ts
// Sprint: SPRINT_20260112_005_FE_binaryindex_ops_ui
// Task: FE-BINOPS-02, FE-BINOPS-03 — BinaryIndex Ops page with config panel
// -----------------------------------------------------------------------------
import {
Component,
ChangeDetectionStrategy,
signal,
computed,
inject,
OnInit,
OnDestroy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import {
BinaryIndexOpsClient,
BinaryIndexOpsHealthResponse,
BinaryIndexBenchResponse,
BinaryIndexFunctionCacheStats,
BinaryIndexEffectiveConfig,
BinaryIndexOpsError,
} from '../../core/api/binary-index-ops.client';
type Tab = 'health' | 'bench' | 'cache' | 'config';
@Component({
selector: 'app-binary-index-ops',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="binidx-ops">
<header class="binidx-ops__header">
<div class="binidx-ops__title-row">
<div>
<h1 class="binidx-ops__title">BinaryIndex Operations</h1>
<p class="binidx-ops__subtitle">
Lifter warmness, benchmark latency, cache stats, and configuration
</p>
</div>
<div class="binidx-ops__status">
<span class="status-badge" [class]="'status-badge--' + overallStatus()">
{{ formatStatus(overallStatus()) }}
</span>
<span class="status-timestamp" *ngIf="health()?.timestamp">
{{ health()!.timestamp | date:'medium' }}
</span>
</div>
</div>
</header>
<nav class="binidx-ops__tabs" role="tablist">
<button
class="binidx-ops__tab"
[class.binidx-ops__tab--active]="activeTab() === 'health'"
(click)="setTab('health')"
role="tab"
[attr.aria-selected]="activeTab() === 'health'"
>
Health
</button>
<button
class="binidx-ops__tab"
[class.binidx-ops__tab--active]="activeTab() === 'bench'"
(click)="setTab('bench')"
role="tab"
[attr.aria-selected]="activeTab() === 'bench'"
>
Benchmark
</button>
<button
class="binidx-ops__tab"
[class.binidx-ops__tab--active]="activeTab() === 'cache'"
(click)="setTab('cache')"
role="tab"
[attr.aria-selected]="activeTab() === 'cache'"
>
Cache
</button>
<button
class="binidx-ops__tab"
[class.binidx-ops__tab--active]="activeTab() === 'config'"
(click)="setTab('config')"
role="tab"
[attr.aria-selected]="activeTab() === 'config'"
>
Configuration
</button>
</nav>
<main class="binidx-ops__content">
@if (loading()) {
<div class="loading-state">Loading BinaryIndex status...</div>
} @else if (error()) {
<div class="error-state">
<span class="error-icon">[!]</span>
<span class="error-message">{{ error() }}</span>
<button class="retry-button" (click)="refresh()">Retry</button>
</div>
} @else {
@switch (activeTab()) {
@case ('health') {
<section class="tab-content">
<h2 class="section-title">Lifter Warmness</h2>
@if (health()?.lifterWarmness?.length) {
<div class="lifter-grid">
@for (isa of health()!.lifterWarmness; track isa.isa) {
<div class="lifter-card" [class.lifter-card--warm]="isa.warm">
<span class="lifter-isa">{{ isa.isa }}</span>
<span class="lifter-status">{{ isa.warm ? '[+] Warm' : '[-] Cold' }}</span>
<span class="lifter-pool">{{ isa.availableCount }}/{{ isa.poolSize }} available</span>
@if (isa.lastUsedAt) {
<span class="lifter-last-used">Last: {{ isa.lastUsedAt | date:'short' }}</span>
}
</div>
}
</div>
} @else {
<p class="empty-state">No lifter warmness data available</p>
}
<h2 class="section-title">Component Health</h2>
@if (health()?.components?.length) {
<table class="health-table">
<thead>
<tr>
<th>Component</th>
<th>Status</th>
<th>Message</th>
<th>Last Check</th>
</tr>
</thead>
<tbody>
@for (comp of health()!.components; track comp.name) {
<tr>
<td>{{ comp.name }}</td>
<td>
<span class="status-badge status-badge--{{ comp.status }}">
{{ formatStatus(comp.status) }}
</span>
</td>
<td>{{ comp.message || '--' }}</td>
<td>{{ comp.lastCheckAt ? (comp.lastCheckAt | date:'short') : '--' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<p class="empty-state">No component health data available</p>
}
<h2 class="section-title">Cache Connection</h2>
@if (health()?.cacheStatus) {
<div class="cache-status-row">
<span class="cache-backend">Backend: {{ health()!.cacheStatus!.backend }}</span>
<span class="cache-connected" [class.cache-connected--yes]="health()!.cacheStatus!.connected">
{{ health()!.cacheStatus!.connected ? '[+] Connected' : '[-] Disconnected' }}
</span>
</div>
} @else {
<p class="empty-state">No cache status available</p>
}
</section>
}
@case ('bench') {
<section class="tab-content">
<div class="bench-controls">
<button
class="bench-button"
[disabled]="benchRunning()"
(click)="runBench()"
>
{{ benchRunning() ? 'Running...' : 'Run Benchmark Sample' }}
</button>
<span class="bench-note">Rate limited to prevent load spikes</span>
</div>
@if (bench()) {
<h2 class="section-title">Latency Summary</h2>
<div class="latency-grid">
<div class="latency-card">
<span class="latency-label">Min</span>
<span class="latency-value">{{ bench()!.latencySummary.min | number:'1.2-2' }} ms</span>
</div>
<div class="latency-card">
<span class="latency-label">Mean</span>
<span class="latency-value">{{ bench()!.latencySummary.mean | number:'1.2-2' }} ms</span>
</div>
<div class="latency-card">
<span class="latency-label">Max</span>
<span class="latency-value">{{ bench()!.latencySummary.max | number:'1.2-2' }} ms</span>
</div>
<div class="latency-card">
<span class="latency-label">P50</span>
<span class="latency-value">{{ bench()!.latencySummary.p50 | number:'1.2-2' }} ms</span>
</div>
<div class="latency-card">
<span class="latency-label">P95</span>
<span class="latency-value">{{ bench()!.latencySummary.p95 | number:'1.2-2' }} ms</span>
</div>
<div class="latency-card">
<span class="latency-label">P99</span>
<span class="latency-value">{{ bench()!.latencySummary.p99 | number:'1.2-2' }} ms</span>
</div>
</div>
<h2 class="section-title">Operation Results</h2>
<table class="bench-table">
<thead>
<tr>
<th>Operation</th>
<th>Latency</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (op of bench()!.operations; track op.operation) {
<tr>
<td>{{ op.operation }}</td>
<td>{{ op.latencyMs | number:'1.2-2' }} ms</td>
<td>
<span [class]="op.success ? 'status--success' : 'status--failure'">
{{ op.success ? '[OK]' : '[!] ' + (op.error || 'Failed') }}
</span>
</td>
</tr>
}
</tbody>
</table>
<div class="bench-meta">
<span>Sample size: {{ bench()!.sampleSize }}</span>
<span>Timestamp: {{ bench()!.timestamp | date:'medium' }}</span>
</div>
} @else {
<p class="empty-state">Click "Run Benchmark Sample" to collect latency data</p>
}
</section>
}
@case ('cache') {
<section class="tab-content">
@if (cache()) {
<div class="cache-overview">
<div class="cache-card">
<span class="cache-label">Backend</span>
<span class="cache-value">{{ cache()!.backend }}</span>
</div>
<div class="cache-card">
<span class="cache-label">Enabled</span>
<span class="cache-value">{{ cache()!.enabled ? '[+] Yes' : '[-] No' }}</span>
</div>
<div class="cache-card">
<span class="cache-label">Key Prefix</span>
<span class="cache-value monospace">{{ cache()!.keyPrefix }}</span>
</div>
<div class="cache-card">
<span class="cache-label">TTL</span>
<span class="cache-value">{{ cache()!.cacheTtlSeconds }} seconds</span>
</div>
</div>
<h2 class="section-title">Hit/Miss Statistics</h2>
<div class="stats-grid">
<div class="stat-card stat-card--primary">
<span class="stat-value">{{ (cache()!.hitRate * 100) | number:'1.1-1' }}%</span>
<span class="stat-label">Hit Rate</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ cache()!.hits | number }}</span>
<span class="stat-label">Hits</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ cache()!.misses | number }}</span>
<span class="stat-label">Misses</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ cache()!.evictions | number }}</span>
<span class="stat-label">Evictions</span>
</div>
</div>
@if (cache()!.estimatedEntries !== undefined || cache()!.estimatedMemoryBytes !== undefined) {
<h2 class="section-title">Resource Usage</h2>
<div class="stats-grid">
@if (cache()!.estimatedEntries !== undefined) {
<div class="stat-card">
<span class="stat-value">{{ cache()!.estimatedEntries | number }}</span>
<span class="stat-label">Entries</span>
</div>
}
@if (cache()!.estimatedMemoryBytes !== undefined) {
<div class="stat-card">
<span class="stat-value">{{ formatBytes(cache()!.estimatedMemoryBytes!) }}</span>
<span class="stat-label">Memory</span>
</div>
}
</div>
}
} @else {
<p class="empty-state">No cache statistics available</p>
}
</section>
}
@case ('config') {
<section class="tab-content">
@if (config()) {
<div class="config-notice">
<span class="notice-icon">[i]</span>
Read-only view. Secrets are redacted. Change configuration via YAML files.
</div>
<h2 class="section-title">B2R2 Pool</h2>
<table class="config-table">
<tbody>
<tr>
<td>Max Pool Size Per ISA</td>
<td class="config-value">{{ config()!.b2r2Pool.maxPoolSizePerIsa }}</td>
</tr>
<tr>
<td>Warm Preload</td>
<td class="config-value">{{ config()!.b2r2Pool.warmPreload ? 'Yes' : 'No' }}</td>
</tr>
<tr>
<td>Acquire Timeout</td>
<td class="config-value">{{ config()!.b2r2Pool.acquireTimeoutMs }} ms</td>
</tr>
<tr>
<td>Enable Metrics</td>
<td class="config-value">{{ config()!.b2r2Pool.enableMetrics ? 'Yes' : 'No' }}</td>
</tr>
</tbody>
</table>
<h2 class="section-title">Semantic Lifting</h2>
<table class="config-table">
<tbody>
<tr>
<td>B2R2 Version</td>
<td class="config-value monospace">{{ config()!.semanticLifting.b2r2Version }}</td>
</tr>
<tr>
<td>Normalization Recipe</td>
<td class="config-value monospace">{{ config()!.semanticLifting.normalizationRecipeVersion }}</td>
</tr>
<tr>
<td>Max Instructions/Function</td>
<td class="config-value">{{ config()!.semanticLifting.maxInstructionsPerFunction | number }}</td>
</tr>
<tr>
<td>Max Functions/Binary</td>
<td class="config-value">{{ config()!.semanticLifting.maxFunctionsPerBinary | number }}</td>
</tr>
<tr>
<td>Function Lift Timeout</td>
<td class="config-value">{{ config()!.semanticLifting.functionLiftTimeoutMs }} ms</td>
</tr>
<tr>
<td>Enable Deduplication</td>
<td class="config-value">{{ config()!.semanticLifting.enableDeduplication ? 'Yes' : 'No' }}</td>
</tr>
</tbody>
</table>
<h2 class="section-title">Function Cache (Valkey)</h2>
<table class="config-table">
<tbody>
<tr>
<td>Enabled</td>
<td class="config-value">{{ config()!.functionCache.enabled ? 'Yes' : 'No' }}</td>
</tr>
<tr>
<td>Backend</td>
<td class="config-value">{{ config()!.functionCache.backend }}</td>
</tr>
<tr>
<td>Key Prefix</td>
<td class="config-value monospace">{{ config()!.functionCache.keyPrefix }}</td>
</tr>
<tr>
<td>Cache TTL</td>
<td class="config-value">{{ config()!.functionCache.cacheTtlSeconds }} seconds</td>
</tr>
<tr>
<td>Max TTL</td>
<td class="config-value">{{ config()!.functionCache.maxTtlSeconds }} seconds</td>
</tr>
<tr>
<td>Early Expiry</td>
<td class="config-value">{{ config()!.functionCache.earlyExpiryPercent }}%</td>
</tr>
<tr>
<td>Max Entry Size</td>
<td class="config-value">{{ formatBytes(config()!.functionCache.maxEntrySizeBytes) }}</td>
</tr>
</tbody>
</table>
<h2 class="section-title">Persistence (PostgreSQL)</h2>
<table class="config-table">
<tbody>
<tr>
<td>Schema</td>
<td class="config-value monospace">{{ config()!.persistence.schema }}</td>
</tr>
<tr>
<td>Min Pool Size</td>
<td class="config-value">{{ config()!.persistence.minPoolSize }}</td>
</tr>
<tr>
<td>Max Pool Size</td>
<td class="config-value">{{ config()!.persistence.maxPoolSize }}</td>
</tr>
<tr>
<td>Command Timeout</td>
<td class="config-value">{{ config()!.persistence.commandTimeoutSeconds }} seconds</td>
</tr>
<tr>
<td>Retry on Failure</td>
<td class="config-value">{{ config()!.persistence.retryOnFailure ? 'Yes' : 'No' }}</td>
</tr>
<tr>
<td>Batch Size</td>
<td class="config-value">{{ config()!.persistence.batchSize }}</td>
</tr>
</tbody>
</table>
<h2 class="section-title">Backend Versions</h2>
<table class="config-table">
<tbody>
<tr>
<td>BinaryIndex</td>
<td class="config-value monospace">{{ config()!.versions.binaryIndex }}</td>
</tr>
<tr>
<td>B2R2</td>
<td class="config-value monospace">{{ config()!.versions.b2r2 }}</td>
</tr>
@if (config()!.versions.valkey) {
<tr>
<td>Valkey</td>
<td class="config-value monospace">{{ config()!.versions.valkey }}</td>
</tr>
}
@if (config()!.versions.postgresql) {
<tr>
<td>PostgreSQL</td>
<td class="config-value monospace">{{ config()!.versions.postgresql }}</td>
</tr>
}
</tbody>
</table>
} @else {
<p class="empty-state">No configuration data available</p>
}
</section>
}
}
}
</main>
</div>
`,
styles: [`
.binidx-ops {
padding: 1.5rem;
max-width: 1200px;
}
.binidx-ops__header {
margin-bottom: 1.5rem;
}
.binidx-ops__title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.binidx-ops__title {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
font-weight: 600;
color: #e2e8f0;
}
.binidx-ops__subtitle {
margin: 0;
color: #94a3b8;
}
.binidx-ops__status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
}
.status-badge {
padding: 0.375rem 0.75rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge--healthy { background: #14532d; color: #86efac; }
.status-badge--degraded { background: #713f12; color: #fde047; }
.status-badge--unhealthy { background: #450a0a; color: #fca5a5; }
.status-badge--unknown { background: #1e293b; color: #94a3b8; }
.status-timestamp {
font-size: 0.75rem;
color: #64748b;
}
.binidx-ops__tabs {
display: flex;
gap: 0;
border-bottom: 1px solid #334155;
margin-bottom: 1.5rem;
}
.binidx-ops__tab {
padding: 0.75rem 1.25rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: #94a3b8;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.15s ease;
}
.binidx-ops__tab:hover {
color: #e2e8f0;
}
.binidx-ops__tab--active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.binidx-ops__content {
min-height: 400px;
}
.loading-state, .empty-state {
padding: 3rem;
text-align: center;
color: #64748b;
}
.error-state {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
background: #450a0a;
border: 1px solid #ef4444;
border-radius: 4px;
color: #fca5a5;
}
.error-icon {
font-family: ui-monospace, monospace;
font-weight: 600;
}
.retry-button {
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid #ef4444;
border-radius: 4px;
color: #fca5a5;
cursor: pointer;
}
.retry-button:hover {
background: rgba(239, 68, 68, 0.1);
}
.tab-content {
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.section-title {
margin: 1.5rem 0 1rem 0;
font-size: 1rem;
font-weight: 600;
color: #e2e8f0;
}
.section-title:first-child {
margin-top: 0;
}
/* Health Tab */
.lifter-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.lifter-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 4px;
}
.lifter-card--warm {
border-color: #22c55e;
}
.lifter-isa {
font-family: ui-monospace, monospace;
font-weight: 600;
color: #e2e8f0;
}
.lifter-status {
font-size: 0.8125rem;
}
.lifter-pool, .lifter-last-used {
font-size: 0.75rem;
color: #64748b;
}
.health-table, .bench-table, .config-table {
width: 100%;
border-collapse: collapse;
}
.health-table th, .health-table td,
.bench-table th, .bench-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #334155;
}
.health-table th, .bench-table th {
font-weight: 500;
color: #94a3b8;
background: #0f172a;
}
.cache-status-row {
display: flex;
gap: 2rem;
padding: 1rem;
background: #1e293b;
border-radius: 4px;
}
.cache-connected--yes { color: #4ade80; }
/* Bench Tab */
.bench-controls {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.bench-button {
padding: 0.625rem 1.25rem;
background: #3b82f6;
border: none;
border-radius: 4px;
color: white;
font-weight: 500;
cursor: pointer;
}
.bench-button:hover:not(:disabled) {
background: #2563eb;
}
.bench-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.bench-note {
font-size: 0.8125rem;
color: #64748b;
}
.latency-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 1rem;
}
.latency-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
background: #1e293b;
border-radius: 4px;
}
.latency-label {
font-size: 0.75rem;
color: #64748b;
text-transform: uppercase;
}
.latency-value {
font-size: 1.25rem;
font-weight: 600;
color: #e2e8f0;
}
.status--success { color: #4ade80; }
.status--failure { color: #f87171; }
.bench-meta {
display: flex;
gap: 2rem;
margin-top: 1rem;
font-size: 0.8125rem;
color: #64748b;
}
/* Cache Tab */
.cache-overview {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
.cache-card {
display: flex;
flex-direction: column;
padding: 1rem;
background: #1e293b;
border-radius: 4px;
}
.cache-label {
font-size: 0.75rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.cache-value {
font-weight: 500;
color: #e2e8f0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem;
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.25rem;
background: #1e293b;
border-radius: 4px;
}
.stat-card--primary {
background: #1e3a5f;
border: 1px solid #3b82f6;
}
.stat-value {
font-size: 1.5rem;
font-weight: 600;
color: #e2e8f0;
}
.stat-label {
font-size: 0.75rem;
color: #94a3b8;
margin-top: 0.25rem;
}
/* Config Tab */
.config-notice {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: #0c4a6e;
border: 1px solid #0ea5e9;
border-radius: 4px;
color: #7dd3fc;
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.notice-icon {
font-family: ui-monospace, monospace;
}
.config-table {
background: #1e293b;
border-radius: 4px;
overflow: hidden;
}
.config-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #334155;
}
.config-table tr:last-child td {
border-bottom: none;
}
.config-table td:first-child {
color: #94a3b8;
width: 40%;
}
.config-value {
color: #e2e8f0;
}
.config-value.monospace {
font-family: ui-monospace, monospace;
}
`],
})
export class BinaryIndexOpsComponent implements OnInit, OnDestroy {
private readonly client = inject(BinaryIndexOpsClient);
private refreshInterval: ReturnType<typeof setInterval> | null = null;
readonly activeTab = signal<Tab>('health');
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly health = signal<BinaryIndexOpsHealthResponse | null>(null);
readonly bench = signal<BinaryIndexBenchResponse | null>(null);
readonly cache = signal<BinaryIndexFunctionCacheStats | null>(null);
readonly config = signal<BinaryIndexEffectiveConfig | null>(null);
readonly benchRunning = signal(false);
readonly overallStatus = computed(() => this.health()?.status || 'unknown');
ngOnInit(): void {
this.refresh();
// Auto-refresh every 30 seconds
this.refreshInterval = setInterval(() => this.refresh(), 30000);
}
ngOnDestroy(): void {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
setTab(tab: Tab): void {
this.activeTab.set(tab);
// Load tab-specific data if not loaded
if (tab === 'cache' && !this.cache()) {
this.loadCache();
} else if (tab === 'config' && !this.config()) {
this.loadConfig();
}
}
refresh(): void {
this.loading.set(true);
this.error.set(null);
this.client.getHealth().subscribe({
next: (data) => {
this.health.set(data);
this.loading.set(false);
},
error: (err: BinaryIndexOpsError) => {
this.error.set(err.message);
this.loading.set(false);
},
});
}
loadCache(): void {
this.client.getCacheStats().subscribe({
next: (data) => this.cache.set(data),
error: () => {}, // Silently fail, show empty state
});
}
loadConfig(): void {
this.client.getEffectiveConfig().subscribe({
next: (data) => this.config.set(data),
error: () => {}, // Silently fail, show empty state
});
}
runBench(): void {
this.benchRunning.set(true);
this.client.runBench().subscribe({
next: (data) => {
this.bench.set(data);
this.benchRunning.set(false);
},
error: (err: BinaryIndexOpsError) => {
this.error.set(err.message);
this.benchRunning.set(false);
},
});
}
formatStatus(status: string): string {
const labels: Record<string, string> = {
healthy: 'Healthy',
degraded: 'Degraded',
unhealthy: 'Unhealthy',
unknown: 'Unknown',
};
return labels[status] || status;
}
formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
}

View File

@@ -149,6 +149,8 @@
<tr
class="finding-row"
[class.selected]="isSelected(finding.id)"
[class.hard-fail-row]="isHardFail(finding)"
[class.anchored-row]="isAnchored(finding)"
(click)="onFindingClick(finding)"
>
<td class="col-checkbox" (click)="$event.stopPropagation()">

View File

@@ -247,6 +247,31 @@
background: var(--color-selection-hover, #dbeafe);
}
}
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-004)
// Hard-fail row highlighting
&.hard-fail-row {
background: var(--color-hard-fail-bg, rgba(220, 38, 38, 0.05));
border-left: 3px solid var(--color-hard-fail-border, #dc2626);
&:hover {
background: var(--color-hard-fail-hover, rgba(220, 38, 38, 0.1));
}
&.selected {
background: var(--color-hard-fail-selected, rgba(220, 38, 38, 0.15));
}
}
// Anchored row indicator (subtle violet glow on left border)
&.anchored-row {
border-left: 3px solid var(--color-anchored-border, #7c3aed);
// If also hard-fail, hard-fail takes precedence visually
&.hard-fail-row {
border-left-color: var(--color-hard-fail-border, #dc2626);
}
}
}
.empty-row td {

View File

@@ -163,6 +163,9 @@ export class FindingsListComponent {
{ flag: 'proven-path', label: 'Proven Path' },
{ flag: 'vendor-na', label: 'Vendor N/A' },
{ flag: 'speculative', label: 'Speculative' },
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-004)
{ flag: 'anchored', label: 'Anchored' },
{ flag: 'hard-fail', label: 'Hard Fail' },
];
/** Filtered and sorted findings */
@@ -480,4 +483,20 @@ export class FindingsListComponent {
if (this.sortField() !== field) return '';
return this.sortDirection() === 'asc' ? '\u25B2' : '\u25BC';
}
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-004)
/** Check if finding has hard-fail flag */
isHardFail(finding: ScoredFinding): boolean {
return finding.score?.flags?.includes('hard-fail') ?? false;
}
/** Check if finding is anchored */
isAnchored(finding: ScoredFinding): boolean {
return finding.score?.flags?.includes('anchored') ?? false;
}
/** Check if finding has hard-fail status set */
hasHardFailStatus(finding: ScoredFinding): boolean {
return finding.score?.isHardFail === true;
}
}

View File

@@ -0,0 +1,264 @@
// -----------------------------------------------------------------------------
// remediation-pr-settings.component.spec.ts
// Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring
// Task: REMPR-FE-004 — Tests for remediation PR settings
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { RemediationPrSettingsComponent, RemediationPrPreferences } from './remediation-pr-settings.component';
import { ADVISORY_AI_API, AdvisoryAiApi } from '../../core/api/advisory-ai.client';
import { RemediationPrSettings } from '../../core/api/advisory-ai.models';
describe('RemediationPrSettingsComponent', () => {
let fixture: ComponentFixture<RemediationPrSettingsComponent>;
let component: RemediationPrSettingsComponent;
let mockApi: jasmine.SpyObj<AdvisoryAiApi>;
const mockServerSettings: RemediationPrSettings = {
enabled: true,
defaultAttachEvidenceCard: true,
defaultAddPrComment: true,
requireApproval: false,
defaultLabels: ['security', 'remediation'],
defaultReviewers: ['security-team'],
};
beforeEach(async () => {
mockApi = jasmine.createSpyObj<AdvisoryAiApi>('AdvisoryAiApi', [
'getRemediationPrSettings',
]);
mockApi.getRemediationPrSettings.and.returnValue(of(mockServerSettings));
// Clear localStorage
localStorage.removeItem('stellaops.remediation-pr.preferences');
await TestBed.configureTestingModule({
imports: [RemediationPrSettingsComponent],
providers: [{ provide: ADVISORY_AI_API, useValue: mockApi }],
}).compileComponents();
fixture = TestBed.createComponent(RemediationPrSettingsComponent);
component = fixture.componentInstance;
});
afterEach(() => {
localStorage.removeItem('stellaops.remediation-pr.preferences');
});
describe('initialization', () => {
it('should load server settings on init', () => {
fixture.detectChanges();
expect(mockApi.getRemediationPrSettings).toHaveBeenCalled();
});
it('should show loading state initially', () => {
expect(component.loading()).toBe(true);
});
it('should hide loading after settings load', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(component.loading()).toBe(false);
});
it('should populate server settings', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(component.serverSettings()).toEqual(mockServerSettings);
});
});
describe('error handling', () => {
it('should display error when settings fail to load', async () => {
mockApi.getRemediationPrSettings.and.returnValue(
throwError(() => ({ message: 'Network error' }))
);
fixture.detectChanges();
await fixture.whenStable();
expect(component.error()).toBe('Network error');
expect(fixture.nativeElement.querySelector('.settings-error')).toBeTruthy();
});
it('should allow retry after error', async () => {
mockApi.getRemediationPrSettings.and.returnValue(
throwError(() => ({ message: 'Network error' }))
);
fixture.detectChanges();
await fixture.whenStable();
// Reset mock to succeed
mockApi.getRemediationPrSettings.and.returnValue(of(mockServerSettings));
// Click retry
component.loadServerSettings();
await fixture.whenStable();
expect(component.error()).toBeNull();
expect(component.serverSettings()).toEqual(mockServerSettings);
});
});
describe('preferences', () => {
it('should have default preferences', () => {
const prefs = component.preferences();
expect(prefs.enabled).toBe(true);
expect(prefs.attachEvidenceCard).toBe(true);
expect(prefs.addPrComment).toBe(true);
expect(prefs.autoAssignReviewers).toBe(false);
expect(prefs.applyDefaultLabels).toBe(true);
});
it('should toggle enabled preference', async () => {
fixture.detectChanges();
await fixture.whenStable();
component.onToggle('enabled', { target: { checked: false } } as any);
expect(component.preferences().enabled).toBe(false);
});
it('should toggle attachEvidenceCard preference', async () => {
fixture.detectChanges();
await fixture.whenStable();
component.onToggle('attachEvidenceCard', { target: { checked: false } } as any);
expect(component.preferences().attachEvidenceCard).toBe(false);
});
it('should reset to defaults', async () => {
fixture.detectChanges();
await fixture.whenStable();
// Change preferences
component.onToggle('enabled', { target: { checked: false } } as any);
component.onToggle('addPrComment', { target: { checked: false } } as any);
// Reset
component.onReset();
expect(component.preferences().enabled).toBe(true);
expect(component.preferences().addPrComment).toBe(true);
});
});
describe('localStorage persistence', () => {
it('should persist preferences to localStorage', async () => {
fixture.detectChanges();
await fixture.whenStable();
component.onToggle('autoAssignReviewers', { target: { checked: true } } as any);
// Force effect to run
fixture.detectChanges();
const stored = localStorage.getItem('stellaops.remediation-pr.preferences');
expect(stored).toBeTruthy();
const parsed = JSON.parse(stored!);
expect(parsed.autoAssignReviewers).toBe(true);
});
it('should load preferences from localStorage', () => {
const savedPrefs: RemediationPrPreferences = {
enabled: false,
attachEvidenceCard: false,
addPrComment: true,
autoAssignReviewers: true,
applyDefaultLabels: false,
};
localStorage.setItem(
'stellaops.remediation-pr.preferences',
JSON.stringify(savedPrefs)
);
// Create new component instance
const newFixture = TestBed.createComponent(RemediationPrSettingsComponent);
const newComponent = newFixture.componentInstance;
expect(newComponent.preferences().enabled).toBe(false);
expect(newComponent.preferences().autoAssignReviewers).toBe(true);
});
});
describe('server settings display', () => {
it('should display default labels when present', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const noteValue = fixture.nativeElement.querySelector('.note-value');
expect(noteValue?.textContent).toContain('security');
});
it('should display default reviewers when present', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const noteValues = fixture.nativeElement.querySelectorAll('.note-value');
const reviewersNote = Array.from(noteValues).find((el: any) =>
el.textContent?.includes('security-team')
);
expect(reviewersNote).toBeTruthy();
});
it('should show warning when PRs disabled at org level', async () => {
mockApi.getRemediationPrSettings.and.returnValue(
of({ ...mockServerSettings, enabled: false })
);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const warning = fixture.nativeElement.querySelector('.settings-note--warning');
expect(warning).toBeTruthy();
expect(warning.textContent).toContain('disabled at the organization level');
});
it('should show info note when approval required', async () => {
mockApi.getRemediationPrSettings.and.returnValue(
of({ ...mockServerSettings, requireApproval: true })
);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const info = fixture.nativeElement.querySelector('.settings-note--info');
expect(info).toBeTruthy();
expect(info.textContent).toContain('require approval');
});
});
describe('accessibility', () => {
it('should have proper checkbox labels', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const labels = fixture.nativeElement.querySelectorAll('.toggle-label');
expect(labels.length).toBeGreaterThan(0);
});
it('should disable checkboxes when main toggle is off', async () => {
fixture.detectChanges();
await fixture.whenStable();
component.onToggle('enabled', { target: { checked: false } } as any);
fixture.detectChanges();
const checkboxes = fixture.nativeElement.querySelectorAll(
'input[type="checkbox"]:not(:first-of-type)'
);
checkboxes.forEach((cb: HTMLInputElement) => {
expect(cb.disabled).toBe(true);
});
});
});
});

View File

@@ -0,0 +1,422 @@
// -----------------------------------------------------------------------------
// remediation-pr-settings.component.ts
// Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring
// Task: REMPR-FE-004 — Settings toggles for remediation PR enablement
// -----------------------------------------------------------------------------
import { Component, inject, signal, computed, effect, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ADVISORY_AI_API, type AdvisoryAiApi } from '../../core/api/advisory-ai.client';
import type { RemediationPrSettings } from '../../core/api/advisory-ai.models';
/**
* Local preferences for remediation PR behavior.
* These are user-level overrides stored in localStorage.
*/
export interface RemediationPrPreferences {
/** Enable/disable PR creation feature */
enabled: boolean;
/** Attach evidence card to PR by default */
attachEvidenceCard: boolean;
/** Add AI summary comment to PR by default */
addPrComment: boolean;
/** Auto-assign reviewers from default list */
autoAssignReviewers: boolean;
/** Apply default labels */
applyDefaultLabels: boolean;
}
const STORAGE_KEY = 'stellaops.remediation-pr.preferences';
const DEFAULT_PREFERENCES: RemediationPrPreferences = {
enabled: true,
attachEvidenceCard: true,
addPrComment: true,
autoAssignReviewers: false,
applyDefaultLabels: true,
};
@Component({
selector: 'stella-remediation-pr-settings',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="remediation-pr-settings">
<header class="settings-header">
<h3 class="settings-title">Remediation Pull Requests</h3>
<p class="settings-description">
Configure how AI-generated remediation pull requests are created
</p>
</header>
@if (loading()) {
<div class="settings-loading">Loading settings...</div>
} @else if (error()) {
<div class="settings-error">
<span class="error-icon">[!]</span>
<span class="error-message">{{ error() }}</span>
<button class="retry-button" (click)="loadServerSettings()">Retry</button>
</div>
} @else {
<div class="settings-sections">
<!-- Enable/Disable -->
<section class="settings-section">
<label class="toggle-option">
<span class="toggle-text">
<span class="toggle-label">Enable Remediation PRs</span>
<span class="toggle-description">
Allow creating pull requests from AI remediation suggestions
</span>
</span>
<input type="checkbox"
[checked]="preferences().enabled"
(change)="onToggle('enabled', $event)"
[disabled]="!serverSettings()?.enabled" />
</label>
@if (!serverSettings()?.enabled) {
<div class="settings-note settings-note--warning">
<span class="note-icon">[--]</span>
Remediation PRs are disabled at the organization level
</div>
}
</section>
<!-- Evidence Card Attachment -->
<section class="settings-section">
<label class="toggle-option">
<span class="toggle-text">
<span class="toggle-label">Attach Evidence Card</span>
<span class="toggle-description">
Include evidence card reference in the PR description
</span>
</span>
<input type="checkbox"
[checked]="preferences().attachEvidenceCard"
(change)="onToggle('attachEvidenceCard', $event)"
[disabled]="!preferences().enabled" />
</label>
</section>
<!-- PR Comment -->
<section class="settings-section">
<label class="toggle-option">
<span class="toggle-text">
<span class="toggle-label">Add AI Summary Comment</span>
<span class="toggle-description">
Post an AI-generated summary comment on the PR
</span>
</span>
<input type="checkbox"
[checked]="preferences().addPrComment"
(change)="onToggle('addPrComment', $event)"
[disabled]="!preferences().enabled" />
</label>
</section>
<!-- Auto-assign Reviewers -->
<section class="settings-section">
<label class="toggle-option">
<span class="toggle-text">
<span class="toggle-label">Auto-assign Reviewers</span>
<span class="toggle-description">
Automatically assign default reviewers to created PRs
</span>
</span>
<input type="checkbox"
[checked]="preferences().autoAssignReviewers"
(change)="onToggle('autoAssignReviewers', $event)"
[disabled]="!preferences().enabled" />
</label>
@if (serverSettings()?.defaultReviewers?.length) {
<div class="settings-note">
<span class="note-label">Default reviewers:</span>
<span class="note-value">{{ serverSettings()!.defaultReviewers.join(', ') }}</span>
</div>
}
</section>
<!-- Apply Labels -->
<section class="settings-section">
<label class="toggle-option">
<span class="toggle-text">
<span class="toggle-label">Apply Default Labels</span>
<span class="toggle-description">
Add configured labels to created PRs
</span>
</span>
<input type="checkbox"
[checked]="preferences().applyDefaultLabels"
(change)="onToggle('applyDefaultLabels', $event)"
[disabled]="!preferences().enabled" />
</label>
@if (serverSettings()?.defaultLabels?.length) {
<div class="settings-note">
<span class="note-label">Default labels:</span>
<span class="note-value">{{ serverSettings()!.defaultLabels.join(', ') }}</span>
</div>
}
</section>
<!-- Approval Required Note -->
@if (serverSettings()?.requireApproval) {
<section class="settings-section">
<div class="settings-note settings-note--info">
<span class="note-icon">[i]</span>
PRs require approval before merging (organization policy)
</div>
</section>
}
<!-- Actions -->
<div class="settings-actions">
<button class="settings-button settings-button--secondary"
(click)="onReset()">
Reset to Defaults
</button>
</div>
</div>
}
</div>
`,
styles: [`
.remediation-pr-settings {
padding: 1rem;
}
.settings-header {
margin-bottom: 1.5rem;
}
.settings-title {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
font-weight: 600;
color: #e2e8f0;
}
.settings-description {
margin: 0;
font-size: 0.875rem;
color: #94a3b8;
}
.settings-loading {
padding: 2rem;
text-align: center;
color: #94a3b8;
}
.settings-error {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: #450a0a;
border: 1px solid #ef4444;
border-radius: 4px;
color: #fca5a5;
}
.error-icon {
font-family: ui-monospace, monospace;
font-weight: 600;
}
.error-message {
flex: 1;
}
.retry-button {
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid #ef4444;
border-radius: 4px;
color: #fca5a5;
cursor: pointer;
}
.retry-button:hover {
background: rgba(239, 68, 68, 0.1);
}
.settings-sections {
display: flex;
flex-direction: column;
gap: 1rem;
}
.settings-section {
padding-bottom: 1rem;
border-bottom: 1px solid #334155;
}
.settings-section:last-of-type {
border-bottom: none;
}
.toggle-option {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
cursor: pointer;
}
.toggle-option input[type="checkbox"] {
margin-top: 0.25rem;
width: 1.125rem;
height: 1.125rem;
cursor: pointer;
}
.toggle-option input[type="checkbox"]:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toggle-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.toggle-label {
font-weight: 500;
color: #e2e8f0;
}
.toggle-description {
font-size: 0.8125rem;
color: #94a3b8;
}
.settings-note {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: #1e293b;
border-radius: 4px;
font-size: 0.8125rem;
color: #94a3b8;
}
.settings-note--warning {
background: #422006;
border: 1px solid #f59e0b;
color: #fbbf24;
}
.settings-note--info {
background: #0c4a6e;
border: 1px solid #0ea5e9;
color: #7dd3fc;
}
.note-icon {
font-family: ui-monospace, monospace;
margin-right: 0.5rem;
}
.note-label {
margin-right: 0.5rem;
}
.note-value {
font-family: ui-monospace, monospace;
color: #cbd5e1;
}
.settings-actions {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #334155;
}
.settings-button {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
cursor: pointer;
}
.settings-button--secondary {
background: transparent;
border: 1px solid #475569;
color: #94a3b8;
}
.settings-button--secondary:hover {
background: rgba(71, 85, 105, 0.2);
color: #e2e8f0;
}
`],
})
export class RemediationPrSettingsComponent implements OnInit {
private readonly api = inject<AdvisoryAiApi>(ADVISORY_AI_API);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly serverSettings = signal<RemediationPrSettings | null>(null);
readonly preferences = signal<RemediationPrPreferences>(this.loadPreferences());
ngOnInit(): void {
this.loadServerSettings();
// Auto-persist on changes
effect(() => {
const prefs = this.preferences();
this.persistPreferences(prefs);
});
}
loadServerSettings(): void {
this.loading.set(true);
this.error.set(null);
this.api.getRemediationPrSettings().subscribe({
next: (settings) => {
this.serverSettings.set(settings);
this.loading.set(false);
},
error: (err) => {
this.error.set(err?.message || 'Failed to load settings');
this.loading.set(false);
},
});
}
onToggle(key: keyof RemediationPrPreferences, event: Event): void {
const checked = (event.target as HTMLInputElement).checked;
this.preferences.update((p) => ({ ...p, [key]: checked }));
}
onReset(): void {
this.preferences.set({ ...DEFAULT_PREFERENCES });
}
private loadPreferences(): RemediationPrPreferences {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return { ...DEFAULT_PREFERENCES, ...parsed };
}
} catch {
// Ignore parse errors
}
return { ...DEFAULT_PREFERENCES };
}
private persistPreferences(prefs: RemediationPrPreferences): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// Ignore storage errors
}
}
}

View File

@@ -0,0 +1,207 @@
// -----------------------------------------------------------------------------
// ai-code-guard-badge.component.spec.ts
// Sprint: SPRINT_20260112_010_FE_ai_code_guard_console
// Task: FE-AIGUARD-004 — Unit tests for AI Code Guard badge component
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { AiCodeGuardBadgeComponent, AiCodeGuardVerdict } from './ai-code-guard-badge.component';
@Component({
standalone: true,
imports: [AiCodeGuardBadgeComponent],
template: `
<app-ai-code-guard-badge
[verdict]="verdict"
[totalFindings]="totalFindings"
[criticalCount]="criticalCount"
[highCount]="highCount"
[mediumCount]="mediumCount"
[lowCount]="lowCount"
[showCount]="showCount"
/>
`,
})
class TestHostComponent {
verdict: AiCodeGuardVerdict = 'pending';
totalFindings = 0;
criticalCount = 0;
highCount = 0;
mediumCount = 0;
lowCount = 0;
showCount = true;
}
describe('AiCodeGuardBadgeComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let hostComponent: TestHostComponent;
let badgeElement: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
hostComponent = fixture.componentInstance;
fixture.detectChanges();
badgeElement = fixture.nativeElement.querySelector('.guard-badge');
});
it('should create', () => {
expect(badgeElement).toBeTruthy();
});
describe('verdict states', () => {
it('should display pass state', () => {
hostComponent.verdict = 'pass';
fixture.detectChanges();
expect(badgeElement.classList.contains('guard-badge--pass')).toBeTrue();
expect(badgeElement.querySelector('.badge-text')?.textContent).toBe('Pass');
});
it('should display review state for pass_with_warnings', () => {
hostComponent.verdict = 'pass_with_warnings';
fixture.detectChanges();
expect(badgeElement.classList.contains('guard-badge--review')).toBeTrue();
expect(badgeElement.querySelector('.badge-text')?.textContent).toBe('Review');
});
it('should display block state for fail', () => {
hostComponent.verdict = 'fail';
fixture.detectChanges();
expect(badgeElement.classList.contains('guard-badge--block')).toBeTrue();
expect(badgeElement.querySelector('.badge-text')?.textContent).toBe('Block');
});
it('should display error state', () => {
hostComponent.verdict = 'error';
fixture.detectChanges();
expect(badgeElement.classList.contains('guard-badge--error')).toBeTrue();
expect(badgeElement.querySelector('.badge-text')?.textContent).toBe('Error');
});
it('should display pending state by default', () => {
hostComponent.verdict = 'pending';
fixture.detectChanges();
expect(badgeElement.classList.contains('guard-badge--pending')).toBeTrue();
expect(badgeElement.querySelector('.badge-text')?.textContent).toBe('Pending');
});
});
describe('count badge', () => {
it('should show count when totalFindings > 0', () => {
hostComponent.totalFindings = 5;
fixture.detectChanges();
const countBadge = badgeElement.querySelector('.badge-count');
expect(countBadge).toBeTruthy();
expect(countBadge?.textContent?.trim()).toBe('5');
});
it('should not show count when totalFindings is 0', () => {
hostComponent.totalFindings = 0;
fixture.detectChanges();
const countBadge = badgeElement.querySelector('.badge-count');
expect(countBadge).toBeNull();
});
it('should not show count when showCount is false', () => {
hostComponent.totalFindings = 5;
hostComponent.showCount = false;
fixture.detectChanges();
const countBadge = badgeElement.querySelector('.badge-count');
expect(countBadge).toBeNull();
});
});
describe('severity class', () => {
it('should use critical class when criticalCount > 0', () => {
hostComponent.totalFindings = 5;
hostComponent.criticalCount = 1;
fixture.detectChanges();
const countBadge = badgeElement.querySelector('.badge-count');
expect(countBadge?.classList.contains('badge-count--critical')).toBeTrue();
});
it('should use high class when highCount > 0 and no critical', () => {
hostComponent.totalFindings = 5;
hostComponent.highCount = 2;
fixture.detectChanges();
const countBadge = badgeElement.querySelector('.badge-count');
expect(countBadge?.classList.contains('badge-count--high')).toBeTrue();
});
it('should use medium class when mediumCount > 0 and no critical/high', () => {
hostComponent.totalFindings = 5;
hostComponent.mediumCount = 3;
fixture.detectChanges();
const countBadge = badgeElement.querySelector('.badge-count');
expect(countBadge?.classList.contains('badge-count--medium')).toBeTrue();
});
it('should use low class when lowCount > 0 and no critical/high/medium', () => {
hostComponent.totalFindings = 5;
hostComponent.lowCount = 5;
fixture.detectChanges();
const countBadge = badgeElement.querySelector('.badge-count');
expect(countBadge?.classList.contains('badge-count--low')).toBeTrue();
});
it('should use info class when no severity counts', () => {
hostComponent.totalFindings = 5;
fixture.detectChanges();
const countBadge = badgeElement.querySelector('.badge-count');
expect(countBadge?.classList.contains('badge-count--info')).toBeTrue();
});
});
describe('accessibility', () => {
it('should have role="status"', () => {
expect(badgeElement.getAttribute('role')).toBe('status');
});
it('should have aria-label with verdict', () => {
hostComponent.verdict = 'pass';
fixture.detectChanges();
expect(badgeElement.getAttribute('aria-label')).toBe('AI Code Guard: Pass');
});
it('should have aria-label with count when findings exist', () => {
hostComponent.verdict = 'fail';
hostComponent.totalFindings = 3;
fixture.detectChanges();
expect(badgeElement.getAttribute('aria-label')).toBe('AI Code Guard: Block, 3 findings');
});
it('should use singular "finding" for count of 1', () => {
hostComponent.verdict = 'fail';
hostComponent.totalFindings = 1;
fixture.detectChanges();
expect(badgeElement.getAttribute('aria-label')).toBe('AI Code Guard: Block, 1 finding');
});
});
describe('icon rendering', () => {
it('should render check icon for pass', () => {
hostComponent.verdict = 'pass';
fixture.detectChanges();
const icon = badgeElement.querySelector('.badge-icon svg');
expect(icon).toBeTruthy();
});
it('should render warning icon for review', () => {
hostComponent.verdict = 'pass_with_warnings';
fixture.detectChanges();
const icon = badgeElement.querySelector('.badge-icon svg');
expect(icon).toBeTruthy();
});
it('should render x icon for block', () => {
hostComponent.verdict = 'fail';
fixture.detectChanges();
const icon = badgeElement.querySelector('.badge-icon svg');
expect(icon).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,288 @@
// -----------------------------------------------------------------------------
// ai-code-guard-badge.component.ts
// Sprint: SPRINT_20260112_010_FE_ai_code_guard_console
// Task: FE-AIGUARD-001 — AI Code Guard badge and summary panels
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
input,
computed,
} from '@angular/core';
/**
* AI Code Guard verdict status.
*/
export type AiCodeGuardVerdict = 'pass' | 'pass_with_warnings' | 'fail' | 'error' | 'pending';
/**
* AI Code Guard badge component.
* Displays Pass/Review/Block states with counts and status for scan/PR views.
*/
@Component({
selector: 'app-ai-code-guard-badge',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="guard-badge"
[class]="'guard-badge--' + badgeState()"
[attr.aria-label]="ariaLabel()"
role="status"
>
<!-- Status Icon -->
<span class="badge-icon" aria-hidden="true">
@switch (badgeState()) {
@case ('pass') {
<svg viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L6.75 9.19 5.28 7.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l4.5-4.5z"/>
</svg>
}
@case ('review') {
<svg viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9 3a1 1 0 11-2 0 1 1 0 012 0zm-.25-6.25a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5z"/>
</svg>
}
@case ('block') {
<svg viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M2.343 13.657A8 8 0 1113.657 2.343 8 8 0 012.343 13.657zM6.03 4.97a.75.75 0 00-1.06 1.06L6.94 8 4.97 9.97a.75.75 0 101.06 1.06L8 9.06l1.97 1.97a.75.75 0 101.06-1.06L9.06 8l1.97-1.97a.75.75 0 10-1.06-1.06L8 6.94 6.03 4.97z"/>
</svg>
}
@case ('error') {
<svg viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm6.5-.25A.75.75 0 017.25 7h1.5a.75.75 0 010 1.5h-1.5a.75.75 0 01-.75-.75zm0 2.5A.75.75 0 017.25 9.5h1.5a.75.75 0 010 1.5h-1.5a.75.75 0 01-.75-.75z"/>
</svg>
}
@default {
<svg viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"/>
</svg>
}
}
</span>
<!-- Badge Text -->
<span class="badge-text">{{ badgeText() }}</span>
<!-- Count Badge -->
@if (showCount() && totalFindings() > 0) {
<span class="badge-count" [class]="'badge-count--' + severityClass()">
{{ totalFindings() }}
</span>
}
</div>
`,
styles: [`
.guard-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
.badge-icon {
display: flex;
width: 14px;
height: 14px;
}
.badge-icon svg {
width: 100%;
height: 100%;
}
.badge-text {
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 9px;
font-size: 11px;
font-weight: 700;
}
/* Pass State */
.guard-badge--pass {
background: #dcfce7;
color: #166534;
border: 1px solid #bbf7d0;
}
/* Review State */
.guard-badge--review {
background: #fef3c7;
color: #92400e;
border: 1px solid #fde68a;
}
/* Block State */
.guard-badge--block {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fecaca;
}
/* Error State */
.guard-badge--error {
background: #f3f4f6;
color: #6b7280;
border: 1px solid #e5e7eb;
}
/* Pending State */
.guard-badge--pending {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
/* Count severity colors */
.badge-count--critical {
background: #991b1b;
color: #fff;
}
.badge-count--high {
background: #dc2626;
color: #fff;
}
.badge-count--medium {
background: #f59e0b;
color: #fff;
}
.badge-count--low {
background: #6b7280;
color: #fff;
}
.badge-count--info {
background: #3b82f6;
color: #fff;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.guard-badge--pass {
background: rgba(22, 101, 52, 0.2);
border-color: rgba(22, 101, 52, 0.4);
color: #86efac;
}
.guard-badge--review {
background: rgba(146, 64, 14, 0.2);
border-color: rgba(146, 64, 14, 0.4);
color: #fcd34d;
}
.guard-badge--block {
background: rgba(153, 27, 27, 0.2);
border-color: rgba(153, 27, 27, 0.4);
color: #fca5a5;
}
.guard-badge--error {
background: rgba(107, 114, 128, 0.2);
border-color: rgba(107, 114, 128, 0.4);
color: #d1d5db;
}
.guard-badge--pending {
background: rgba(29, 78, 216, 0.2);
border-color: rgba(29, 78, 216, 0.4);
color: #93c5fd;
}
}
`],
})
export class AiCodeGuardBadgeComponent {
/** Verdict status from scanner. */
readonly verdict = input<AiCodeGuardVerdict>('pending');
/** Total findings count. */
readonly totalFindings = input<number>(0);
/** Critical findings count. */
readonly criticalCount = input<number>(0);
/** High findings count. */
readonly highCount = input<number>(0);
/** Medium findings count. */
readonly mediumCount = input<number>(0);
/** Low findings count. */
readonly lowCount = input<number>(0);
/** Whether to show the count badge. */
readonly showCount = input<boolean>(true);
/** Map verdict to badge state. */
readonly badgeState = computed(() => {
const v = this.verdict();
switch (v) {
case 'pass':
return 'pass';
case 'pass_with_warnings':
return 'review';
case 'fail':
return 'block';
case 'error':
return 'error';
default:
return 'pending';
}
});
/** Badge display text. */
readonly badgeText = computed(() => {
const state = this.badgeState();
switch (state) {
case 'pass':
return 'Pass';
case 'review':
return 'Review';
case 'block':
return 'Block';
case 'error':
return 'Error';
default:
return 'Pending';
}
});
/** Severity class for count badge. */
readonly severityClass = computed(() => {
if (this.criticalCount() > 0) return 'critical';
if (this.highCount() > 0) return 'high';
if (this.mediumCount() > 0) return 'medium';
if (this.lowCount() > 0) return 'low';
return 'info';
});
/** Aria label for accessibility. */
readonly ariaLabel = computed(() => {
const text = this.badgeText();
const count = this.totalFindings();
if (count > 0) {
return `AI Code Guard: ${text}, ${count} finding${count > 1 ? 's' : ''}`;
}
return `AI Code Guard: ${text}`;
});
}

View File

@@ -0,0 +1,367 @@
// -----------------------------------------------------------------------------
// binary-diff-tab.component.spec.ts
// Sprint: SPRINT_20260112_010_FE_binary_diff_explain_panel
// Task: BINDIFF-FE-003 — Component tests for binary diff explain panel
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { Component, signal } from '@angular/core';
import { of, throwError } from 'rxjs';
import {
BinaryDiffTabComponent,
BinaryDiffSummary,
} from './binary-diff-tab.component';
import { BinaryDiffEvidenceService } from '../../services/binary-diff-evidence.service';
const mockSummary: BinaryDiffSummary = {
baseHash: 'sha256:abc123def456789012345678901234567890123456789012345678901234567890',
headHash: 'sha256:def456abc789012345678901234567890123456789012345678901234567890abc',
baseSize: 1024000,
headSize: 1048576,
totalSections: 10,
modifiedSections: 3,
addedSections: 1,
removedSections: 0,
totalSymbolChanges: 15,
sections: [
{
name: '.text',
offset: 0x1000,
size: 4096,
status: 'modified',
segmentType: 'code',
addedBytes: 0,
removedBytes: 0,
modifiedBytes: 128,
hash: 'sha256:section123',
},
{
name: '.data',
offset: 0x5000,
size: 2048,
status: 'identical',
segmentType: 'data',
addedBytes: 0,
removedBytes: 0,
modifiedBytes: 0,
},
{
name: '.rodata',
offset: 0x7000,
size: 1024,
status: 'added',
segmentType: 'rodata',
addedBytes: 1024,
removedBytes: 0,
modifiedBytes: 0,
},
],
symbolChanges: [
{
name: 'main',
type: 'function',
status: 'modified',
oldAddress: 0x1000,
newAddress: 0x1000,
sizeChange: 24,
},
{
name: 'helper_func',
type: 'function',
status: 'added',
newAddress: 0x2000,
},
{
name: 'old_func',
type: 'function',
status: 'removed',
oldAddress: 0x3000,
},
],
confidence: 0.95,
analysisTimestamp: '2026-01-16T12:00:00Z',
};
@Component({
standalone: true,
imports: [BinaryDiffTabComponent],
template: `
<app-binary-diff-tab [artifactId]="artifactId()" />
`,
})
class TestHostComponent {
artifactId = signal('test-artifact-123');
}
describe('BinaryDiffTabComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let hostComponent: TestHostComponent;
let mockService: jasmine.SpyObj<BinaryDiffEvidenceService>;
beforeEach(async () => {
mockService = jasmine.createSpyObj('BinaryDiffEvidenceService', ['getBinaryDiffSummary']);
mockService.getBinaryDiffSummary.and.returnValue(of(mockSummary));
await TestBed.configureTestingModule({
imports: [TestHostComponent],
providers: [
{ provide: BinaryDiffEvidenceService, useValue: mockService },
],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
hostComponent = fixture.componentInstance;
});
it('should create', () => {
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.binary-diff-tab')).toBeTruthy();
});
describe('loading state', () => {
it('should show loading state initially', fakeAsync(() => {
mockService.getBinaryDiffSummary.and.returnValue(of(mockSummary));
fixture.detectChanges();
// Loading is very brief, check if service was called
expect(mockService.getBinaryDiffSummary).toHaveBeenCalledWith('test-artifact-123');
}));
});
describe('error state', () => {
it('should show error state on API failure', fakeAsync(() => {
mockService.getBinaryDiffSummary.and.returnValue(
throwError(() => new Error('Network error'))
);
fixture.detectChanges();
tick();
fixture.detectChanges();
const errorState = fixture.nativeElement.querySelector('.error-state');
expect(errorState).toBeTruthy();
expect(errorState.textContent).toContain('Network error');
}));
it('should have retry button in error state', fakeAsync(() => {
mockService.getBinaryDiffSummary.and.returnValue(
throwError(() => new Error('Network error'))
);
fixture.detectChanges();
tick();
fixture.detectChanges();
const retryBtn = fixture.nativeElement.querySelector('.retry-btn');
expect(retryBtn).toBeTruthy();
}));
});
describe('empty state', () => {
it('should show empty state when no data', fakeAsync(() => {
mockService.getBinaryDiffSummary.and.returnValue(
throwError(() => new Error('No binary diff data available'))
);
fixture.detectChanges();
tick();
fixture.detectChanges();
const errorState = fixture.nativeElement.querySelector('.error-state');
expect(errorState).toBeTruthy();
}));
});
describe('summary display', () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
}));
it('should display summary section', () => {
const summarySection = fixture.nativeElement.querySelector('.summary-section');
expect(summarySection).toBeTruthy();
});
it('should display confidence badge', () => {
const confidenceBadge = fixture.nativeElement.querySelector('.confidence-badge');
expect(confidenceBadge).toBeTruthy();
expect(confidenceBadge.textContent).toContain('95%');
});
it('should show high confidence for >= 90%', () => {
const confidenceBadge = fixture.nativeElement.querySelector('.confidence-badge');
expect(confidenceBadge.classList.contains('confidence--high')).toBeTrue();
});
it('should display hash comparison', () => {
const baseCard = fixture.nativeElement.querySelector('.hash-card--base');
const headCard = fixture.nativeElement.querySelector('.hash-card--head');
expect(baseCard).toBeTruthy();
expect(headCard).toBeTruthy();
});
it('should display stats row', () => {
const statsRow = fixture.nativeElement.querySelector('.stats-row');
expect(statsRow).toBeTruthy();
expect(statsRow.textContent).toContain('3'); // modifiedSections
expect(statsRow.textContent).toContain('1'); // addedSections
expect(statsRow.textContent).toContain('0'); // removedSections
expect(statsRow.textContent).toContain('15'); // symbolChanges
});
});
describe('sections panel', () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
}));
it('should display sections panel', () => {
const sectionsPanel = fixture.nativeElement.querySelector('.sections-panel');
expect(sectionsPanel).toBeTruthy();
});
it('should display section items', () => {
const sectionItems = fixture.nativeElement.querySelectorAll('.section-item');
expect(sectionItems.length).toBe(3); // All 3 sections visible by default (< 5)
});
it('should show section name', () => {
const sectionName = fixture.nativeElement.querySelector('.section-name');
expect(sectionName.textContent).toBe('.text');
});
it('should show segment type badge', () => {
const segmentType = fixture.nativeElement.querySelector('.segment-type');
expect(segmentType.textContent).toBe('code');
});
it('should show status badge', () => {
const status = fixture.nativeElement.querySelector('.section-status');
expect(status.textContent).toBe('modified');
});
it('should apply correct status class', () => {
const sectionItem = fixture.nativeElement.querySelector('.section-item');
expect(sectionItem.classList.contains('section-item--modified')).toBeTrue();
});
});
describe('symbol changes panel', () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
}));
it('should display symbols panel', () => {
const symbolsPanel = fixture.nativeElement.querySelector('.symbols-panel');
expect(symbolsPanel).toBeTruthy();
});
it('should display symbol items', () => {
const symbolItems = fixture.nativeElement.querySelectorAll('.symbol-item');
expect(symbolItems.length).toBe(3);
});
it('should show symbol name', () => {
const symbolName = fixture.nativeElement.querySelector('.symbol-name');
expect(symbolName.textContent).toBe('main');
});
it('should show symbol type badge', () => {
const symbolType = fixture.nativeElement.querySelector('.symbol-type');
expect(symbolType.textContent).toBe('function');
});
it('should show size change for modified symbols', () => {
const sizeChange = fixture.nativeElement.querySelector('.size-change');
expect(sizeChange).toBeTruthy();
expect(sizeChange.textContent).toContain('+24 bytes');
});
});
describe('footer', () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
}));
it('should display analysis footer', () => {
const footer = fixture.nativeElement.querySelector('.analysis-footer');
expect(footer).toBeTruthy();
});
it('should display timestamp', () => {
const timestamp = fixture.nativeElement.querySelector('.timestamp');
expect(timestamp).toBeTruthy();
});
it('should have export button', () => {
const exportBtn = fixture.nativeElement.querySelector('.export-btn');
expect(exportBtn).toBeTruthy();
});
});
describe('show more functionality', () => {
it('should not show "Show More" when sections <= 5', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const showMoreBtn = fixture.nativeElement.querySelector('.show-more-btn');
// With 3 sections, no "show more" should be visible
// Actually the component has 2 show more buttons potential - one for sections, one for symbols
// Since we have 3 of each, neither should show
}));
});
describe('utility functions', () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
}));
it('should format bytes correctly', () => {
const sizeValue = fixture.nativeElement.querySelector('.size-value');
expect(sizeValue).toBeTruthy();
// baseSize: 1024000 should show as ~1000 KB or ~1 MB
});
it('should truncate hash correctly', () => {
const hashValue = fixture.nativeElement.querySelector('.hash-value');
expect(hashValue).toBeTruthy();
expect(hashValue.textContent?.length).toBeLessThan(64); // Should be truncated
});
});
describe('clipboard functionality', () => {
beforeEach(fakeAsync(() => {
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
fixture.detectChanges();
tick();
fixture.detectChanges();
}));
it('should have copy buttons for hashes', () => {
const copyBtn = fixture.nativeElement.querySelector('.copy-btn');
expect(copyBtn).toBeTruthy();
});
});
describe('artifact ID changes', () => {
it('should reload data when artifactId changes', fakeAsync(() => {
fixture.detectChanges();
tick();
expect(mockService.getBinaryDiffSummary).toHaveBeenCalledWith('test-artifact-123');
hostComponent.artifactId.set('new-artifact-456');
fixture.detectChanges();
tick();
expect(mockService.getBinaryDiffSummary).toHaveBeenCalledWith('new-artifact-456');
}));
});
});

View File

@@ -0,0 +1,874 @@
// -----------------------------------------------------------------------------
// binary-diff-tab.component.ts
// Sprint: SPRINT_20260112_010_FE_binary_diff_explain_panel
// Task: BINDIFF-FE-002 — Binary diff explain component for evidence panel
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
input,
signal,
computed,
inject,
OnInit,
OnDestroy,
effect,
} from '@angular/core';
import { Subject, takeUntil } from 'rxjs';
import { BinaryDiffEvidenceService } from '../../services/binary-diff-evidence.service';
/**
* Binary diff status.
*/
export type BinaryDiffStatus = 'identical' | 'modified' | 'added' | 'removed' | 'unknown';
/**
* Binary diff segment type.
*/
export type SegmentType = 'code' | 'data' | 'rodata' | 'header' | 'symbol' | 'unknown';
/**
* Binary diff section.
*/
export interface BinaryDiffSection {
name: string;
offset: number;
size: number;
status: BinaryDiffStatus;
segmentType: SegmentType;
addedBytes: number;
removedBytes: number;
modifiedBytes: number;
hash?: string;
}
/**
* Symbol change record.
*/
export interface SymbolChange {
name: string;
type: 'function' | 'variable' | 'import' | 'export';
status: 'added' | 'removed' | 'modified';
oldAddress?: number;
newAddress?: number;
sizeChange?: number;
}
/**
* Binary diff summary.
*/
export interface BinaryDiffSummary {
baseHash: string;
headHash: string;
baseSize: number;
headSize: number;
totalSections: number;
modifiedSections: number;
addedSections: number;
removedSections: number;
totalSymbolChanges: number;
sections: BinaryDiffSection[];
symbolChanges: SymbolChange[];
confidence: number;
analysisTimestamp: string;
}
/**
* Binary diff explain panel component.
* Displays binary diff evidence in the evidence panel tabs.
*/
@Component({
selector: 'app-binary-diff-tab',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="binary-diff-tab" role="region" aria-label="Binary Diff Evidence">
@if (isLoading()) {
<div class="loading-state">
<span class="spinner"></span>
<span>Loading binary diff analysis...</span>
</div>
} @else if (error()) {
<div class="error-state" role="alert">
<span class="error-icon">!</span>
<span>{{ error() }}</span>
<button class="retry-btn" (click)="loadData()">Retry</button>
</div>
} @else if (!summary()) {
<div class="empty-state">
<svg viewBox="0 0 16 16" fill="currentColor" class="empty-icon">
<path fill-rule="evenodd" d="M1.75 1.5a.25.25 0 00-.25.25v12.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25V4.664a.25.25 0 00-.073-.177l-2.914-2.914a.25.25 0 00-.177-.073H1.75zM0 1.75C0 .784.784 0 1.75 0h9.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0114.25 16H1.75A1.75 1.75 0 010 14.25V1.75z"/>
</svg>
<p>No binary diff evidence available for this artifact.</p>
</div>
} @else {
<!-- Summary Header -->
<div class="summary-section">
<div class="summary-header">
<h3 class="section-title">Binary Diff Summary</h3>
<span class="confidence-badge" [class]="'confidence--' + confidenceLevel()">
{{ confidenceLabel() }}
</span>
</div>
<div class="hash-comparison">
<div class="hash-card hash-card--base">
<span class="hash-label">Base</span>
<code class="hash-value">{{ truncateHash(summary()!.baseHash) }}</code>
<span class="size-value">{{ formatBytes(summary()!.baseSize) }}</span>
</div>
<span class="arrow-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M8.22 2.97a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06l2.97-2.97H3.75a.75.75 0 010-1.5h7.44L8.22 4.03a.75.75 0 010-1.06z"/>
</svg>
</span>
<div class="hash-card hash-card--head">
<span class="hash-label">Head</span>
<code class="hash-value">{{ truncateHash(summary()!.headHash) }}</code>
<span class="size-value">{{ formatBytes(summary()!.headSize) }}</span>
</div>
</div>
<div class="stats-row">
<div class="stat-item">
<span class="stat-value">{{ summary()!.modifiedSections }}</span>
<span class="stat-label">Modified</span>
</div>
<div class="stat-item stat-item--added">
<span class="stat-value">{{ summary()!.addedSections }}</span>
<span class="stat-label">Added</span>
</div>
<div class="stat-item stat-item--removed">
<span class="stat-value">{{ summary()!.removedSections }}</span>
<span class="stat-label">Removed</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ summary()!.totalSymbolChanges }}</span>
<span class="stat-label">Symbols</span>
</div>
</div>
</div>
<!-- Sections -->
@if (summary()!.sections && summary()!.sections.length > 0) {
<div class="sections-panel">
<h3 class="section-title">
Sections
<span class="section-count">({{ summary()!.sections.length }})</span>
</h3>
<div class="sections-list">
@for (section of visibleSections(); track section.name) {
<div class="section-item" [class]="'section-item--' + section.status">
<div class="section-header">
<span class="section-name">{{ section.name }}</span>
<span class="segment-type" [class]="'segment--' + section.segmentType">
{{ section.segmentType }}
</span>
<span class="section-status" [class]="'status--' + section.status">
{{ section.status }}
</span>
</div>
<div class="section-details">
<span class="detail-item">
<span class="detail-label">Offset:</span>
<code>0x{{ section.offset.toString(16).toUpperCase() }}</code>
</span>
<span class="detail-item">
<span class="detail-label">Size:</span>
<code>{{ formatBytes(section.size) }}</code>
</span>
@if (section.modifiedBytes > 0) {
<span class="detail-item detail-item--modified">
<span class="detail-label">Changed:</span>
<code>{{ formatBytes(section.modifiedBytes) }}</code>
</span>
}
</div>
@if (section.hash) {
<div class="section-hash">
<span class="hash-prefix">sha256:</span>
<code>{{ truncateHash(section.hash) }}</code>
<button
class="copy-btn"
(click)="copyToClipboard(section.hash)"
aria-label="Copy hash"
>
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"/>
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"/>
</svg>
</button>
</div>
}
</div>
}
</div>
@if (hasMoreSections()) {
<button class="show-more-btn" (click)="toggleShowAllSections()">
{{ showAllSections() ? 'Show Less' : 'Show All (' + summary()!.sections.length + ')' }}
</button>
}
</div>
}
<!-- Symbol Changes -->
@if (summary()!.symbolChanges && summary()!.symbolChanges.length > 0) {
<div class="symbols-panel">
<h3 class="section-title">
Symbol Changes
<span class="section-count">({{ summary()!.symbolChanges.length }})</span>
</h3>
<div class="symbols-list">
@for (symbol of visibleSymbols(); track symbol.name) {
<div class="symbol-item" [class]="'symbol-item--' + symbol.status">
<div class="symbol-header">
<span class="symbol-type" [class]="'type--' + symbol.type">
{{ symbol.type }}
</span>
<code class="symbol-name">{{ symbol.name }}</code>
<span class="symbol-status" [class]="'status--' + symbol.status">
{{ symbol.status }}
</span>
</div>
@if (symbol.oldAddress || symbol.newAddress) {
<div class="symbol-addresses">
@if (symbol.oldAddress) {
<span class="address-item">
<span class="address-label">Old:</span>
<code>0x{{ symbol.oldAddress.toString(16).toUpperCase() }}</code>
</span>
}
@if (symbol.newAddress) {
<span class="address-item">
<span class="address-label">New:</span>
<code>0x{{ symbol.newAddress.toString(16).toUpperCase() }}</code>
</span>
}
@if (symbol.sizeChange && symbol.sizeChange !== 0) {
<span class="size-change" [class.positive]="symbol.sizeChange > 0" [class.negative]="symbol.sizeChange < 0">
{{ symbol.sizeChange > 0 ? '+' : '' }}{{ symbol.sizeChange }} bytes
</span>
}
</div>
}
</div>
}
</div>
@if (hasMoreSymbols()) {
<button class="show-more-btn" (click)="toggleShowAllSymbols()">
{{ showAllSymbols() ? 'Show Less' : 'Show All (' + summary()!.symbolChanges.length + ')' }}
</button>
}
</div>
}
<!-- Footer -->
<div class="analysis-footer">
<span class="timestamp">
Analyzed: {{ formatTimestamp(summary()!.analysisTimestamp) }}
</span>
<button class="export-btn" (click)="exportEvidence()">
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="M2.75 14A1.75 1.75 0 011 12.25v-2.5a.75.75 0 011.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 00.25-.25v-2.5a.75.75 0 011.5 0v2.5A1.75 1.75 0 0113.25 14H2.75z"/>
<path d="M7.25 7.689V2a.75.75 0 011.5 0v5.689l1.97-1.969a.749.749 0 111.06 1.06l-3.25 3.25a.749.749 0 01-1.06 0L4.22 6.78a.749.749 0 111.06-1.06l1.97 1.969z"/>
</svg>
Export
</button>
</div>
}
</div>
`,
styles: [`
.binary-diff-tab {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
max-height: 100%;
}
.loading-state,
.error-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
gap: 12px;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-color, #e5e7eb);
border-top-color: var(--primary-color, #2563eb);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-icon {
width: 48px;
height: 48px;
background: #fee2e2;
color: #dc2626;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
}
.retry-btn,
.export-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: var(--primary-color, #2563eb);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
}
.retry-btn:hover,
.export-btn:hover {
opacity: 0.9;
}
.export-btn {
background: var(--surface-color, #fff);
color: var(--text-primary, #374151);
border: 1px solid var(--border-color, #e5e7eb);
}
.export-btn svg {
width: 14px;
height: 14px;
}
.empty-icon {
width: 48px;
height: 48px;
color: var(--text-tertiary, #9ca3af);
}
.section-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 8px;
}
.section-count {
font-weight: 400;
color: var(--text-tertiary, #9ca3af);
}
/* Summary Section */
.summary-section {
background: var(--surface-variant, #f9fafb);
padding: 12px;
border-radius: 8px;
}
.summary-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.confidence-badge {
font-size: 10px;
font-weight: 600;
padding: 4px 8px;
border-radius: 4px;
text-transform: uppercase;
}
.confidence--high {
background: #dcfce7;
color: #166534;
}
.confidence--medium {
background: #fef3c7;
color: #92400e;
}
.confidence--low {
background: #fee2e2;
color: #991b1b;
}
.hash-comparison {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.hash-card {
flex: 1;
padding: 10px;
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.hash-card--base {
background: #fee2e2;
border: 1px solid #fecaca;
}
.hash-card--head {
background: #dcfce7;
border: 1px solid #bbf7d0;
}
.hash-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-tertiary, #6b7280);
}
.hash-value {
font-family: ui-monospace, monospace;
font-size: 11px;
}
.size-value {
font-size: 11px;
color: var(--text-secondary, #6b7280);
}
.arrow-icon {
color: var(--text-tertiary, #9ca3af);
}
.arrow-icon svg {
width: 20px;
height: 20px;
}
.stats-row {
display: flex;
gap: 16px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
font-size: 18px;
font-weight: 700;
color: var(--text-primary, #374151);
}
.stat-label {
font-size: 10px;
color: var(--text-tertiary, #9ca3af);
text-transform: uppercase;
}
.stat-item--added .stat-value {
color: #16a34a;
}
.stat-item--removed .stat-value {
color: #dc2626;
}
/* Sections Panel */
.sections-panel,
.symbols-panel {
display: flex;
flex-direction: column;
gap: 8px;
}
.sections-list,
.symbols-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.section-item,
.symbol-item {
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
padding: 10px;
background: var(--surface-color, #fff);
}
.section-item--modified {
border-left: 3px solid #f59e0b;
}
.section-item--added {
border-left: 3px solid #16a34a;
}
.section-item--removed {
border-left: 3px solid #dc2626;
}
.section-header,
.symbol-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.section-name,
.symbol-name {
font-family: ui-monospace, monospace;
font-size: 12px;
font-weight: 600;
}
.segment-type,
.symbol-type {
font-size: 9px;
font-weight: 600;
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
}
.segment--code { background: #dbeafe; color: #1d4ed8; }
.segment--data { background: #fae8ff; color: #a21caf; }
.segment--rodata { background: #fef3c7; color: #92400e; }
.segment--header { background: #f3f4f6; color: #4b5563; }
.segment--symbol { background: #dcfce7; color: #166534; }
.segment--unknown { background: #f1f5f9; color: #64748b; }
.type--function { background: #dbeafe; color: #1d4ed8; }
.type--variable { background: #fae8ff; color: #a21caf; }
.type--import { background: #fef3c7; color: #92400e; }
.type--export { background: #dcfce7; color: #166534; }
.section-status,
.symbol-status {
font-size: 10px;
font-weight: 500;
margin-left: auto;
}
.status--identical { color: #16a34a; }
.status--modified { color: #f59e0b; }
.status--added { color: #16a34a; }
.status--removed { color: #dc2626; }
.status--unknown { color: #6b7280; }
.section-details,
.symbol-addresses {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.detail-item,
.address-item {
font-size: 11px;
color: var(--text-secondary, #6b7280);
}
.detail-label,
.address-label {
margin-right: 4px;
}
.detail-item code,
.address-item code {
font-family: ui-monospace, monospace;
font-size: 11px;
}
.detail-item--modified {
color: #f59e0b;
}
.size-change {
font-size: 11px;
font-weight: 500;
}
.size-change.positive {
color: #16a34a;
}
.size-change.negative {
color: #dc2626;
}
.section-hash {
display: flex;
align-items: center;
gap: 4px;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border-color, #e5e7eb);
}
.hash-prefix {
font-size: 10px;
color: var(--text-tertiary, #9ca3af);
}
.section-hash code {
font-family: ui-monospace, monospace;
font-size: 10px;
color: var(--text-secondary, #6b7280);
}
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: none;
border: none;
cursor: pointer;
color: var(--text-tertiary, #9ca3af);
border-radius: 3px;
margin-left: auto;
}
.copy-btn:hover {
background: var(--surface-hover, #f3f4f6);
color: var(--text-secondary, #6b7280);
}
.copy-btn svg {
width: 12px;
height: 12px;
}
.show-more-btn {
padding: 8px 12px;
background: none;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
color: var(--link-color, #2563eb);
}
.show-more-btn:hover {
background: var(--surface-hover, #f9fafb);
}
/* Symbol items */
.symbol-item--added {
border-left: 3px solid #16a34a;
}
.symbol-item--removed {
border-left: 3px solid #dc2626;
}
.symbol-item--modified {
border-left: 3px solid #f59e0b;
}
/* Footer */
.analysis-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
border-top: 1px solid var(--border-color, #e5e7eb);
}
.timestamp {
font-size: 11px;
color: var(--text-tertiary, #9ca3af);
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.hash-card--base {
background: rgba(220, 38, 38, 0.1);
border-color: rgba(220, 38, 38, 0.3);
}
.hash-card--head {
background: rgba(22, 163, 74, 0.1);
border-color: rgba(22, 163, 74, 0.3);
}
}
`],
})
export class BinaryDiffTabComponent implements OnInit, OnDestroy {
private readonly binaryDiffService = inject(BinaryDiffEvidenceService);
private readonly destroy$ = new Subject<void>();
/** Artifact ID or evidence reference. */
readonly artifactId = input.required<string>();
/** Default number of items to show before "Show More". */
private readonly defaultVisibleCount = 5;
readonly isLoading = signal(false);
readonly error = signal<string | null>(null);
readonly summary = signal<BinaryDiffSummary | null>(null);
readonly showAllSections = signal(false);
readonly showAllSymbols = signal(false);
readonly confidenceLevel = computed(() => {
const s = this.summary();
if (!s) return 'low';
if (s.confidence >= 0.9) return 'high';
if (s.confidence >= 0.7) return 'medium';
return 'low';
});
readonly confidenceLabel = computed(() => {
const level = this.confidenceLevel();
const s = this.summary();
if (!s) return 'Unknown';
const pct = Math.round(s.confidence * 100);
return `${level.charAt(0).toUpperCase() + level.slice(1)} (${pct}%)`;
});
readonly visibleSections = computed(() => {
const s = this.summary();
if (!s || !s.sections) return [];
if (this.showAllSections()) return s.sections;
return s.sections.slice(0, this.defaultVisibleCount);
});
readonly visibleSymbols = computed(() => {
const s = this.summary();
if (!s || !s.symbolChanges) return [];
if (this.showAllSymbols()) return s.symbolChanges;
return s.symbolChanges.slice(0, this.defaultVisibleCount);
});
readonly hasMoreSections = computed(() => {
const s = this.summary();
return s && s.sections && s.sections.length > this.defaultVisibleCount;
});
readonly hasMoreSymbols = computed(() => {
const s = this.summary();
return s && s.symbolChanges && s.symbolChanges.length > this.defaultVisibleCount;
});
constructor() {
effect(() => {
const id = this.artifactId();
if (id) {
this.loadData();
}
});
}
ngOnInit(): void {
// Initial load handled by effect
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
loadData(): void {
const id = this.artifactId();
if (!id) return;
this.isLoading.set(true);
this.error.set(null);
this.binaryDiffService.getBinaryDiffSummary(id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response) => {
this.summary.set(response);
this.isLoading.set(false);
},
error: (err) => {
this.error.set(err?.message || 'Failed to load binary diff evidence');
this.isLoading.set(false);
},
});
}
toggleShowAllSections(): void {
this.showAllSections.update(v => !v);
}
toggleShowAllSymbols(): void {
this.showAllSymbols.update(v => !v);
}
truncateHash(hash: string): string {
if (!hash) return '';
const withoutPrefix = hash.replace('sha256:', '');
return withoutPrefix.slice(0, 12) + '...';
}
formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
}
formatTimestamp(timestamp: string): string {
try {
const date = new Date(timestamp);
return date.toLocaleString();
} catch {
return timestamp;
}
}
copyToClipboard(text: string): void {
navigator.clipboard.writeText(text);
}
exportEvidence(): void {
const s = this.summary();
if (!s) return;
const data = JSON.stringify(s, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `binary-diff-${this.artifactId()}.json`;
a.click();
window.URL.revokeObjectURL(url);
}
}

View File

@@ -32,3 +32,6 @@ export { EvidenceUriLinkComponent } from './evidence-uri-link.component';
export { StaticEvidenceCardComponent } from './static-evidence-card.component';
export { RuntimeEvidenceCardComponent } from './runtime-evidence-card.component';
export { SymbolPathViewerComponent } from './symbol-path-viewer.component';
// Binary Diff Components (Sprint 010)
export { BinaryDiffTabComponent } from './binary-diff-tab.component';

View File

@@ -55,6 +55,14 @@ export {
type ReachabilityData,
} from './reachability-context/reachability-context.component';
// Risk Line (Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui)
export {
RiskLineComponent,
type RiskLineData,
type RuntimeStatus,
type RekorTimestampLink,
} from './risk-line/risk-line.component';
// Bulk Actions
export {
BulkActionModalComponent,
@@ -95,3 +103,17 @@ export {
NoiseGatingDeltaReportComponent,
GatingStatisticsCardComponent,
} from './noise-gating';
// Risk Line (Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui)
export { RiskLineComponent } from './risk-line/risk-line.component';
// Signed Override Badge (Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui FE-RISK-005)
export { SignedOverrideBadgeComponent } from './signed-override-badge/signed-override-badge.component';
// Trace Export Actions (Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui FE-RISK-003)
export {
TraceExportActionsComponent,
type TraceExportFormat,
type TraceExportRequest,
type TraceExportResult,
} from './trace-export-actions/trace-export-actions.component';

View File

@@ -0,0 +1,415 @@
// -----------------------------------------------------------------------------
// risk-line.component.spec.ts
// Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui
// Task: FE-RISK-001 — Unit tests for risk-line component
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import {
RiskLineComponent,
RiskLineData,
RekorTimestampLink,
} from './risk-line.component';
describe('RiskLineComponent', () => {
let component: RiskLineComponent;
let fixture: ComponentFixture<RiskLineComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RiskLineComponent],
}).compileComponents();
fixture = TestBed.createComponent(RiskLineComponent);
component = fixture.componentInstance;
});
describe('initialization', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render with null data', () => {
fixture.detectChanges();
const element = fixture.nativeElement;
expect(element.querySelector('.risk-line')).toBeTruthy();
});
});
describe('reachability score display', () => {
it('should display reachability score when available', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.85,
runtimeStatus: 'confirmed',
analysisMethod: 'hybrid',
} as RiskLineData);
fixture.detectChanges();
const scoreEl = fixture.debugElement.query(By.css('.risk-line__score'));
expect(scoreEl.nativeElement.textContent).toContain('85%');
});
it('should display -- when reachability score is null', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: null,
runtimeStatus: 'unknown',
analysisMethod: 'none',
} as RiskLineData);
fixture.detectChanges();
const scoreEl = fixture.debugElement.query(By.css('.risk-line__score--unknown'));
expect(scoreEl.nativeElement.textContent).toContain('--');
});
it('should apply high class for scores >= 0.7', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.75,
runtimeStatus: 'confirmed',
analysisMethod: 'hybrid',
} as RiskLineData);
fixture.detectChanges();
expect(component.reachabilityLevel()).toBe('high');
const scoreEl = fixture.debugElement.query(By.css('.risk-line__score--high'));
expect(scoreEl).toBeTruthy();
});
it('should apply medium class for scores between 0.3 and 0.7', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.5,
runtimeStatus: 'unknown',
analysisMethod: 'static',
} as RiskLineData);
fixture.detectChanges();
expect(component.reachabilityLevel()).toBe('medium');
const scoreEl = fixture.debugElement.query(By.css('.risk-line__score--medium'));
expect(scoreEl).toBeTruthy();
});
it('should apply low class for scores < 0.3', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.2,
runtimeStatus: 'not_observed',
analysisMethod: 'static',
} as RiskLineData);
fixture.detectChanges();
expect(component.reachabilityLevel()).toBe('low');
const scoreEl = fixture.debugElement.query(By.css('.risk-line__score--low'));
expect(scoreEl).toBeTruthy();
});
it('should render progress bar with correct width', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.65,
runtimeStatus: 'unknown',
analysisMethod: 'static',
} as RiskLineData);
fixture.detectChanges();
const barFill = fixture.debugElement.query(By.css('.risk-line__bar-fill'));
expect(barFill.styles['width']).toBe('65%');
});
});
describe('runtime status badge', () => {
it('should display confirmed badge with [+] icon', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.8,
runtimeStatus: 'confirmed',
analysisMethod: 'runtime',
} as RiskLineData);
fixture.detectChanges();
const badge = fixture.debugElement.query(By.css('.risk-line__badge--confirmed'));
expect(badge).toBeTruthy();
expect(badge.nativeElement.textContent).toContain('[+]');
expect(badge.nativeElement.textContent).toContain('Confirmed');
});
it('should display not_observed badge with [-] icon', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.2,
runtimeStatus: 'not_observed',
analysisMethod: 'runtime',
} as RiskLineData);
fixture.detectChanges();
const badge = fixture.debugElement.query(By.css('.risk-line__badge--not-observed'));
expect(badge).toBeTruthy();
expect(badge.nativeElement.textContent).toContain('[-]');
expect(badge.nativeElement.textContent).toContain('Not Observed');
});
it('should display unknown badge with [--] icon', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: null,
runtimeStatus: 'unknown',
analysisMethod: 'none',
} as RiskLineData);
fixture.detectChanges();
const badge = fixture.debugElement.query(By.css('.risk-line__badge--unknown'));
expect(badge).toBeTruthy();
expect(badge.nativeElement.textContent).toContain('[--]');
expect(badge.nativeElement.textContent).toContain('Unknown');
});
it('should display pending badge with [?] icon', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.5,
runtimeStatus: 'pending',
analysisMethod: 'hybrid',
} as RiskLineData);
fixture.detectChanges();
const badge = fixture.debugElement.query(By.css('.risk-line__badge--pending'));
expect(badge).toBeTruthy();
expect(badge.nativeElement.textContent).toContain('[?]');
expect(badge.nativeElement.textContent).toContain('Pending');
});
it('should display observation timestamp when available', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.9,
runtimeStatus: 'confirmed',
runtimeObservedAt: '2026-01-16T10:30:00Z',
analysisMethod: 'runtime',
} as RiskLineData);
fixture.detectChanges();
const timestamp = fixture.debugElement.query(By.css('.risk-line__timestamp'));
expect(timestamp).toBeTruthy();
});
});
describe('Rekor link', () => {
const mockRekorLink: RekorTimestampLink = {
logIndex: 12345678,
entryId: 'abc123-def456',
timestamp: '2026-01-16T09:00:00Z',
url: 'https://search.sigstore.dev/?logIndex=12345678',
verified: true,
};
it('should display Rekor link when available', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.8,
runtimeStatus: 'confirmed',
rekorLink: mockRekorLink,
analysisMethod: 'hybrid',
} as RiskLineData);
fixture.detectChanges();
const link = fixture.debugElement.query(By.css('.risk-line__link'));
expect(link).toBeTruthy();
expect(link.nativeElement.textContent).toContain('Log #12345678');
});
it('should apply verified class when link is verified', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.8,
runtimeStatus: 'confirmed',
rekorLink: mockRekorLink,
analysisMethod: 'hybrid',
} as RiskLineData);
fixture.detectChanges();
const link = fixture.debugElement.query(By.css('.risk-line__link--verified'));
expect(link).toBeTruthy();
expect(link.nativeElement.textContent).toContain('[OK]');
});
it('should not apply verified class when link is not verified', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.8,
runtimeStatus: 'confirmed',
rekorLink: { ...mockRekorLink, verified: false },
analysisMethod: 'hybrid',
} as RiskLineData);
fixture.detectChanges();
const link = fixture.debugElement.query(By.css('.risk-line__link'));
expect(link).toBeTruthy();
expect(link.nativeElement.classList.contains('risk-line__link--verified')).toBeFalse();
});
it('should display no evidence message when Rekor link is absent', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.5,
runtimeStatus: 'unknown',
analysisMethod: 'static',
} as RiskLineData);
fixture.detectChanges();
const noEvidence = fixture.debugElement.query(By.css('.risk-line__no-evidence'));
expect(noEvidence).toBeTruthy();
expect(noEvidence.nativeElement.textContent).toContain('No Rekor entry');
});
it('should emit rekorLinkClicked event on link click', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.8,
runtimeStatus: 'confirmed',
rekorLink: mockRekorLink,
analysisMethod: 'hybrid',
} as RiskLineData);
fixture.detectChanges();
const emitSpy = jest.spyOn(component.rekorLinkClicked, 'emit');
const link = fixture.debugElement.query(By.css('.risk-line__link'));
link.triggerEventHandler('click', new MouseEvent('click'));
expect(emitSpy).toHaveBeenCalledWith(mockRekorLink);
});
});
describe('analysis method badge', () => {
it('should display hybrid badge', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.9,
runtimeStatus: 'confirmed',
analysisMethod: 'hybrid',
} as RiskLineData);
fixture.detectChanges();
const badge = fixture.debugElement.query(By.css('.risk-line__method-badge--hybrid'));
expect(badge).toBeTruthy();
expect(badge.nativeElement.textContent).toContain('Hybrid');
});
it('should display runtime badge', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.9,
runtimeStatus: 'confirmed',
analysisMethod: 'runtime',
} as RiskLineData);
fixture.detectChanges();
const badge = fixture.debugElement.query(By.css('.risk-line__method-badge--runtime'));
expect(badge).toBeTruthy();
expect(badge.nativeElement.textContent).toContain('Runtime');
});
it('should display static badge', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.5,
runtimeStatus: 'unknown',
analysisMethod: 'static',
} as RiskLineData);
fixture.detectChanges();
const badge = fixture.debugElement.query(By.css('.risk-line__method-badge--static'));
expect(badge).toBeTruthy();
expect(badge.nativeElement.textContent).toContain('Static');
});
it('should display none badge', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: null,
runtimeStatus: 'unknown',
analysisMethod: 'none',
} as RiskLineData);
fixture.detectChanges();
const badge = fixture.debugElement.query(By.css('.risk-line__method-badge--none'));
expect(badge).toBeTruthy();
expect(badge.nativeElement.textContent).toContain('None');
});
});
describe('evidence age', () => {
it('should display < 1h ago for fresh evidence', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.8,
runtimeStatus: 'confirmed',
analysisMethod: 'hybrid',
evidenceAgeHours: 0.5,
} as RiskLineData);
fixture.detectChanges();
expect(component.evidenceAgeText()).toBe('< 1h ago');
});
it('should display hours for recent evidence', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.8,
runtimeStatus: 'confirmed',
analysisMethod: 'hybrid',
evidenceAgeHours: 12,
} as RiskLineData);
fixture.detectChanges();
expect(component.evidenceAgeText()).toBe('12h ago');
});
it('should display days for older evidence', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.8,
runtimeStatus: 'confirmed',
analysisMethod: 'hybrid',
evidenceAgeHours: 72,
} as RiskLineData);
fixture.detectChanges();
expect(component.evidenceAgeText()).toBe('3d ago');
});
it('should return null when evidenceAgeHours is undefined', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.8,
runtimeStatus: 'confirmed',
analysisMethod: 'hybrid',
} as RiskLineData);
fixture.detectChanges();
expect(component.evidenceAgeText()).toBeNull();
});
});
describe('accessibility', () => {
it('should have proper ARIA labels', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.75,
runtimeStatus: 'confirmed',
analysisMethod: 'hybrid',
} as RiskLineData);
fixture.detectChanges();
const region = fixture.debugElement.query(By.css('[role="region"]'));
expect(region.attributes['aria-label']).toBe('Risk assessment summary');
const progressbar = fixture.debugElement.query(By.css('[role="progressbar"]'));
expect(progressbar.attributes['aria-valuenow']).toBe('75');
});
it('should use ASCII-only icons for screen reader compatibility', () => {
fixture.componentRef.setInput('data', {
reachabilityScore: 0.8,
runtimeStatus: 'confirmed',
rekorLink: {
logIndex: 123,
entryId: 'abc',
timestamp: '2026-01-16T09:00:00Z',
url: 'https://example.com',
verified: true,
},
analysisMethod: 'hybrid',
} as RiskLineData);
fixture.detectChanges();
// Verify no non-ASCII characters in icon elements
const iconElements = fixture.debugElement.queryAll(By.css('[class*="icon"]'));
iconElements.forEach(el => {
const text = el.nativeElement.textContent;
// ASCII characters are 0-127
for (let i = 0; i < text.length; i++) {
expect(text.charCodeAt(i)).toBeLessThan(128);
}
});
});
});
});

View File

@@ -0,0 +1,437 @@
// -----------------------------------------------------------------------------
// risk-line.component.ts
// Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui
// Task: FE-RISK-001 — Add risk-line component with reachability score, runtime badge, Rekor link
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
input,
output,
computed,
} from '@angular/core';
/**
* Runtime confirmation status
*/
export type RuntimeStatus = 'confirmed' | 'not_observed' | 'unknown' | 'pending';
/**
* Rekor timestamp link data
*/
export interface RekorTimestampLink {
/** Rekor log index */
logIndex: number;
/** Rekor entry ID (UUID) */
entryId: string;
/** ISO-8601 timestamp from Rekor */
timestamp: string;
/** Full Rekor entry URL */
url: string;
/** Verification status */
verified: boolean;
}
/**
* Risk line data input
*/
export interface RiskLineData {
/** Reachability score (0-1) */
reachabilityScore: number | null;
/** Runtime confirmation status */
runtimeStatus: RuntimeStatus;
/** Runtime observation timestamp (if observed) */
runtimeObservedAt?: string;
/** Rekor transparency log link */
rekorLink?: RekorTimestampLink;
/** Analysis method used */
analysisMethod: 'static' | 'runtime' | 'hybrid' | 'none';
/** Evidence freshness in hours */
evidenceAgeHours?: number;
}
@Component({
selector: 'app-risk-line',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="risk-line" role="region" aria-label="Risk assessment summary">
<!-- Reachability Score -->
<div class="risk-line__section risk-line__reachability">
<span class="risk-line__label">Reachability</span>
@if (hasReachabilityScore()) {
<span
class="risk-line__score"
[class.risk-line__score--high]="reachabilityLevel() === 'high'"
[class.risk-line__score--medium]="reachabilityLevel() === 'medium'"
[class.risk-line__score--low]="reachabilityLevel() === 'low'"
[attr.aria-label]="'Reachability score: ' + formattedScore() + ' percent'"
>
{{ formattedScore() }}%
</span>
<div
class="risk-line__bar"
role="progressbar"
[attr.aria-valuenow]="formattedScore()"
aria-valuemin="0"
aria-valuemax="100"
>
<div
class="risk-line__bar-fill"
[class.risk-line__bar-fill--high]="reachabilityLevel() === 'high'"
[class.risk-line__bar-fill--medium]="reachabilityLevel() === 'medium'"
[class.risk-line__bar-fill--low]="reachabilityLevel() === 'low'"
[style.width.%]="formattedScore()"
></div>
</div>
} @else {
<span class="risk-line__score risk-line__score--unknown" aria-label="Reachability unknown">
--
</span>
<span class="risk-line__hint">(no data)</span>
}
</div>
<!-- Runtime Status Badge -->
<div class="risk-line__section risk-line__runtime">
<span class="risk-line__label">Runtime</span>
<span
class="risk-line__badge"
[class.risk-line__badge--confirmed]="runtimeStatus() === 'confirmed'"
[class.risk-line__badge--not-observed]="runtimeStatus() === 'not_observed'"
[class.risk-line__badge--unknown]="runtimeStatus() === 'unknown'"
[class.risk-line__badge--pending]="runtimeStatus() === 'pending'"
[attr.aria-label]="runtimeStatusLabel()"
>
<span class="risk-line__badge-icon">{{ runtimeStatusIcon() }}</span>
{{ runtimeStatusText() }}
</span>
@if (data()?.runtimeObservedAt) {
<span class="risk-line__timestamp" [attr.aria-label]="'Observed at ' + data()!.runtimeObservedAt">
{{ formatTimestamp(data()!.runtimeObservedAt) }}
</span>
}
</div>
<!-- Rekor Timestamp Link -->
<div class="risk-line__section risk-line__rekor">
<span class="risk-line__label">Evidence</span>
@if (data()?.rekorLink) {
<a
class="risk-line__link"
[class.risk-line__link--verified]="data()!.rekorLink!.verified"
[href]="data()!.rekorLink!.url"
target="_blank"
rel="noopener noreferrer"
[attr.aria-label]="'Rekor log entry ' + data()!.rekorLink!.logIndex + (data()!.rekorLink!.verified ? ' (verified)' : '')"
(click)="onRekorLinkClick($event)"
>
<span class="risk-line__link-icon">[R]</span>
<span class="risk-line__link-text">Log #{{ data()!.rekorLink!.logIndex }}</span>
@if (data()!.rekorLink!.verified) {
<span class="risk-line__verified-badge" aria-hidden="true">[OK]</span>
}
</a>
<span class="risk-line__rekor-time">
{{ formatTimestamp(data()!.rekorLink!.timestamp) }}
</span>
} @else {
<span class="risk-line__no-evidence" aria-label="No transparency log entry">
<span class="risk-line__no-evidence-icon">[--]</span>
No Rekor entry
</span>
}
</div>
<!-- Analysis Method Indicator -->
<div class="risk-line__section risk-line__method">
<span class="risk-line__label">Method</span>
<span
class="risk-line__method-badge"
[class.risk-line__method-badge--hybrid]="analysisMethod() === 'hybrid'"
[class.risk-line__method-badge--runtime]="analysisMethod() === 'runtime'"
[class.risk-line__method-badge--static]="analysisMethod() === 'static'"
[class.risk-line__method-badge--none]="analysisMethod() === 'none'"
[attr.aria-label]="'Analysis method: ' + analysisMethod()"
>
{{ analysisMethodLabel() }}
</span>
@if (evidenceAgeText()) {
<span class="risk-line__age" [attr.aria-label]="'Evidence age: ' + evidenceAgeText()">
{{ evidenceAgeText() }}
</span>
}
</div>
</div>
`,
styles: [`
.risk-line {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--risk-line-bg, #f8fafc);
border: 1px solid var(--risk-line-border, #e2e8f0);
border-radius: 0.5rem;
font-size: 0.875rem;
align-items: center;
}
.risk-line__section {
display: flex;
align-items: center;
gap: 0.5rem;
}
.risk-line__label {
color: var(--risk-line-label, #64748b);
font-weight: 500;
min-width: 5rem;
}
/* Reachability Score */
.risk-line__score {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.risk-line__score--high { color: var(--risk-score-high, #dc2626); }
.risk-line__score--medium { color: var(--risk-score-medium, #f59e0b); }
.risk-line__score--low { color: var(--risk-score-low, #10b981); }
.risk-line__score--unknown { color: var(--risk-score-unknown, #94a3b8); }
.risk-line__bar {
width: 4rem;
height: 0.375rem;
background: var(--risk-bar-bg, #e2e8f0);
border-radius: 0.25rem;
overflow: hidden;
}
.risk-line__bar-fill {
height: 100%;
border-radius: 0.25rem;
transition: width 0.3s ease;
}
.risk-line__bar-fill--high { background: var(--risk-bar-high, #dc2626); }
.risk-line__bar-fill--medium { background: var(--risk-bar-medium, #f59e0b); }
.risk-line__bar-fill--low { background: var(--risk-bar-low, #10b981); }
.risk-line__hint {
color: var(--risk-hint, #94a3b8);
font-style: italic;
}
/* Runtime Badge */
.risk-line__badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 500;
}
.risk-line__badge--confirmed {
background: var(--badge-confirmed-bg, #dcfce7);
color: var(--badge-confirmed-text, #166534);
}
.risk-line__badge--not-observed {
background: var(--badge-not-observed-bg, #fef3c7);
color: var(--badge-not-observed-text, #92400e);
}
.risk-line__badge--unknown {
background: var(--badge-unknown-bg, #f1f5f9);
color: var(--badge-unknown-text, #475569);
}
.risk-line__badge--pending {
background: var(--badge-pending-bg, #e0e7ff);
color: var(--badge-pending-text, #3730a3);
}
.risk-line__badge-icon {
font-family: monospace;
}
.risk-line__timestamp {
color: var(--risk-timestamp, #64748b);
font-size: 0.75rem;
}
/* Rekor Link */
.risk-line__link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: var(--link-color, #2563eb);
text-decoration: none;
font-family: monospace;
font-size: 0.8125rem;
}
.risk-line__link:hover {
text-decoration: underline;
}
.risk-line__link--verified {
color: var(--link-verified, #059669);
}
.risk-line__verified-badge {
font-size: 0.6875rem;
padding: 0.125rem 0.25rem;
background: var(--verified-badge-bg, #d1fae5);
border-radius: 0.125rem;
}
.risk-line__no-evidence {
color: var(--no-evidence, #94a3b8);
font-family: monospace;
}
.risk-line__rekor-time {
color: var(--risk-timestamp, #64748b);
font-size: 0.75rem;
}
/* Method Badge */
.risk-line__method-badge {
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.risk-line__method-badge--hybrid {
background: var(--method-hybrid-bg, #dbeafe);
color: var(--method-hybrid-text, #1d4ed8);
}
.risk-line__method-badge--runtime {
background: var(--method-runtime-bg, #dcfce7);
color: var(--method-runtime-text, #166534);
}
.risk-line__method-badge--static {
background: var(--method-static-bg, #f3e8ff);
color: var(--method-static-text, #7c3aed);
}
.risk-line__method-badge--none {
background: var(--method-none-bg, #f1f5f9);
color: var(--method-none-text, #64748b);
}
.risk-line__age {
color: var(--risk-age, #94a3b8);
font-size: 0.75rem;
}
`],
})
export class RiskLineComponent {
/** Risk line data input */
readonly data = input<RiskLineData | null>(null);
/** Emitted when Rekor link is clicked */
readonly rekorLinkClicked = output<RekorTimestampLink>();
// Computed properties
readonly hasReachabilityScore = computed(() => {
const d = this.data();
return d !== null && d.reachabilityScore !== null;
});
readonly formattedScore = computed(() => {
const d = this.data();
if (!d || d.reachabilityScore === null) return 0;
return Math.round(d.reachabilityScore * 100);
});
readonly reachabilityLevel = computed((): 'high' | 'medium' | 'low' | 'unknown' => {
const d = this.data();
if (!d || d.reachabilityScore === null) return 'unknown';
if (d.reachabilityScore >= 0.7) return 'high';
if (d.reachabilityScore >= 0.3) return 'medium';
return 'low';
});
readonly runtimeStatus = computed((): RuntimeStatus => {
return this.data()?.runtimeStatus ?? 'unknown';
});
readonly runtimeStatusIcon = computed((): string => {
switch (this.runtimeStatus()) {
case 'confirmed': return '[+]';
case 'not_observed': return '[-]';
case 'pending': return '[?]';
default: return '[--]';
}
});
readonly runtimeStatusText = computed((): string => {
switch (this.runtimeStatus()) {
case 'confirmed': return 'Confirmed';
case 'not_observed': return 'Not Observed';
case 'pending': return 'Pending';
default: return 'Unknown';
}
});
readonly runtimeStatusLabel = computed((): string => {
return `Runtime status: ${this.runtimeStatusText()}`;
});
readonly analysisMethod = computed(() => {
return this.data()?.analysisMethod ?? 'none';
});
readonly analysisMethodLabel = computed((): string => {
switch (this.analysisMethod()) {
case 'hybrid': return 'Hybrid';
case 'runtime': return 'Runtime';
case 'static': return 'Static';
default: return 'None';
}
});
readonly evidenceAgeText = computed((): string | null => {
const hours = this.data()?.evidenceAgeHours;
if (hours === undefined || hours === null) return null;
if (hours < 1) return '< 1h ago';
if (hours < 24) return `${Math.round(hours)}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
});
// Methods
formatTimestamp(iso: string): string {
try {
const date = new Date(iso);
return date.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
}
onRekorLinkClick(event: Event): void {
const link = this.data()?.rekorLink;
if (link) {
this.rekorLinkClicked.emit(link);
}
}
}

View File

@@ -0,0 +1,181 @@
// -----------------------------------------------------------------------------
// signed-override-badge.component.spec.ts
// Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui
// Task: FE-RISK-005 — Tests for signed VEX override badge
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SignedOverrideBadgeComponent } from './signed-override-badge.component';
import type { VexDecisionSignatureInfo } from '../../../../core/api/evidence.models';
describe('SignedOverrideBadgeComponent', () => {
let fixture: ComponentFixture<SignedOverrideBadgeComponent>;
let component: SignedOverrideBadgeComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SignedOverrideBadgeComponent],
}).compileComponents();
fixture = TestBed.createComponent(SignedOverrideBadgeComponent);
component = fixture.componentInstance;
});
describe('when signatureInfo is null', () => {
it('should not render badge by default', () => {
fixture.componentRef.setInput('signatureInfo', null);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.signed-badge')).toBeNull();
expect(fixture.nativeElement.querySelector('.unsigned-badge')).toBeNull();
});
it('should render unsigned badge when showUnsigned is true', () => {
fixture.componentRef.setInput('signatureInfo', null);
fixture.componentRef.setInput('showUnsigned', true);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.unsigned-badge')).toBeTruthy();
});
});
describe('when signatureInfo.isSigned is false', () => {
it('should not render signed badge', () => {
fixture.componentRef.setInput('signatureInfo', { isSigned: false });
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.signed-badge')).toBeNull();
});
});
describe('when signed', () => {
const signedInfo: VexDecisionSignatureInfo = {
isSigned: true,
verificationStatus: 'verified',
dsseDigest: 'abc123def456ghi789jkl012',
signerIdentity: 'security@example.com',
signedAt: '2026-01-16T10:00:00Z',
};
beforeEach(() => {
fixture.componentRef.setInput('signatureInfo', signedInfo);
fixture.detectChanges();
});
it('should render signed badge', () => {
expect(fixture.nativeElement.querySelector('.signed-badge')).toBeTruthy();
});
it('should show "Signed" label', () => {
const label = fixture.nativeElement.querySelector('.badge-label');
expect(label?.textContent).toContain('Signed');
});
it('should apply verified class when verified', () => {
expect(fixture.nativeElement.querySelector('.signed-badge--verified')).toBeTruthy();
});
});
describe('verification status icons', () => {
it('should show [OK] for verified', () => {
fixture.componentRef.setInput('signatureInfo', { isSigned: true, verificationStatus: 'verified' });
fixture.detectChanges();
expect(component.statusIcon()).toBe('[OK]');
});
it('should show [!] for failed', () => {
fixture.componentRef.setInput('signatureInfo', { isSigned: true, verificationStatus: 'failed' });
fixture.detectChanges();
expect(component.statusIcon()).toBe('[!]');
});
it('should show [?] for pending', () => {
fixture.componentRef.setInput('signatureInfo', { isSigned: true, verificationStatus: 'pending' });
fixture.detectChanges();
expect(component.statusIcon()).toBe('[?]');
});
it('should show [S] for unknown', () => {
fixture.componentRef.setInput('signatureInfo', { isSigned: true, verificationStatus: 'unknown' });
fixture.detectChanges();
expect(component.statusIcon()).toBe('[S]');
});
});
describe('expanded details', () => {
const fullSignedInfo: VexDecisionSignatureInfo = {
isSigned: true,
verificationStatus: 'verified',
dsseDigest: 'abc123def456ghi789jkl012mno345pqr678',
signerIdentity: 'security@example.com',
signedAt: '2026-01-16T10:00:00Z',
signatureAlgorithm: 'ecdsa-p256',
signingKeyId: 'key-123',
rekorEntry: {
logIndex: 12345,
logId: 'tree-hash-abc',
verifyUrl: 'https://rekor.sigstore.dev/api/v1/log/entries/12345',
},
};
it('should not show details when not expanded', () => {
fixture.componentRef.setInput('signatureInfo', fullSignedInfo);
fixture.componentRef.setInput('expanded', false);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.badge-details')).toBeNull();
});
it('should show details when expanded', () => {
fixture.componentRef.setInput('signatureInfo', fullSignedInfo);
fixture.componentRef.setInput('expanded', true);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.badge-details')).toBeTruthy();
});
it('should show Rekor link when available', () => {
fixture.componentRef.setInput('signatureInfo', fullSignedInfo);
fixture.componentRef.setInput('expanded', true);
fixture.detectChanges();
const rekorLink = fixture.nativeElement.querySelector('.rekor-link');
expect(rekorLink).toBeTruthy();
expect(rekorLink?.href).toContain('rekor.sigstore.dev');
});
});
describe('truncateDigest', () => {
it('should return short digest unchanged', () => {
expect(component.truncateDigest('abc123')).toBe('abc123');
});
it('should truncate long digest', () => {
const longDigest = 'abc123def456ghi789jkl012mno345pqr678';
expect(component.truncateDigest(longDigest)).toBe('abc123de...qr678');
});
});
describe('formatVerificationStatus', () => {
it('should format known statuses', () => {
expect(component.formatVerificationStatus('verified')).toBe('Verified');
expect(component.formatVerificationStatus('failed')).toBe('Failed');
expect(component.formatVerificationStatus('pending')).toBe('Pending');
expect(component.formatVerificationStatus('unknown')).toBe('Unknown');
});
it('should return unknown status as-is', () => {
expect(component.formatVerificationStatus('other')).toBe('other');
});
});
describe('accessibility', () => {
it('should have aria-label on status icon', () => {
fixture.componentRef.setInput('signatureInfo', { isSigned: true, verificationStatus: 'verified' });
fixture.detectChanges();
const icon = fixture.nativeElement.querySelector('.badge-icon');
expect(icon?.getAttribute('aria-label')).toBe('Signature verified');
});
});
describe('ASCII-only output', () => {
it('should use only ASCII characters in icons', () => {
const asciiPattern = /^[\x00-\x7F]*$/;
expect(component.statusIcon()).toMatch(asciiPattern);
});
});
});

View File

@@ -0,0 +1,228 @@
// -----------------------------------------------------------------------------
// signed-override-badge.component.ts
// Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui
// Task: FE-RISK-005 — Surface signed VEX override status
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import type { VexDecisionSignatureInfo } from '../../../core/api/evidence.models';
/**
* Badge component displaying signed VEX override status.
* Shows DSSE badge, Rekor link, and attestation details.
*/
@Component({
selector: 'app-signed-override-badge',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (signatureInfo()?.isSigned) {
<div class="signed-badge" [class.signed-badge--verified]="isVerified()" [class.signed-badge--failed]="isFailed()">
<span class="badge-icon" [attr.aria-label]="ariaLabel()">{{ statusIcon() }}</span>
<span class="badge-label">Signed</span>
@if (expanded()) {
<div class="badge-details">
@if (signatureInfo()?.verificationStatus) {
<div class="detail-row">
<span class="detail-label">Status:</span>
<span class="detail-value" [class]="'status--' + signatureInfo()!.verificationStatus">
{{ formatVerificationStatus(signatureInfo()!.verificationStatus) }}
</span>
</div>
}
@if (signatureInfo()?.dsseDigest) {
<div class="detail-row">
<span class="detail-label">DSSE Digest:</span>
<span class="detail-value monospace">{{ truncateDigest(signatureInfo()!.dsseDigest!) }}</span>
</div>
}
@if (signatureInfo()?.signerIdentity) {
<div class="detail-row">
<span class="detail-label">Signer:</span>
<span class="detail-value">{{ signatureInfo()!.signerIdentity }}</span>
</div>
}
@if (signatureInfo()?.signedAt) {
<div class="detail-row">
<span class="detail-label">Signed:</span>
<span class="detail-value">{{ signatureInfo()!.signedAt | date:'medium' }}</span>
</div>
}
@if (signatureInfo()?.rekorEntry) {
<div class="detail-row rekor-row">
<span class="detail-label">Rekor:</span>
<span class="detail-value">
@if (signatureInfo()!.rekorEntry!.verifyUrl) {
<a
[href]="signatureInfo()!.rekorEntry!.verifyUrl"
target="_blank"
rel="noopener noreferrer"
class="rekor-link"
>
Log #{{ signatureInfo()!.rekorEntry!.logIndex }}
</a>
} @else {
<span class="monospace">Log #{{ signatureInfo()!.rekorEntry!.logIndex }}</span>
}
</span>
</div>
}
</div>
}
</div>
} @else if (showUnsigned()) {
<div class="unsigned-badge">
<span class="badge-icon" aria-label="Not signed">[--]</span>
<span class="badge-label">Unsigned</span>
</div>
}
`,
styles: [`
:host {
display: inline-flex;
}
.signed-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
background: #1e3a5f;
border: 1px solid #3b82f6;
border-radius: 4px;
font-size: 0.75rem;
color: #93c5fd;
}
.signed-badge--verified {
background: #14532d;
border-color: #22c55e;
color: #86efac;
}
.signed-badge--failed {
background: #450a0a;
border-color: #ef4444;
color: #fca5a5;
}
.badge-icon {
font-family: ui-monospace, monospace;
font-weight: 600;
}
.badge-label {
font-weight: 500;
}
.badge-details {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.detail-row {
display: flex;
gap: 0.5rem;
font-size: 0.6875rem;
}
.detail-label {
color: #94a3b8;
min-width: 5rem;
}
.detail-value {
color: #e2e8f0;
}
.detail-value.monospace {
font-family: ui-monospace, monospace;
}
.status--verified { color: #4ade80; }
.status--failed { color: #f87171; }
.status--pending { color: #fbbf24; }
.status--unknown { color: #94a3b8; }
.rekor-link {
color: #60a5fa;
text-decoration: none;
}
.rekor-link:hover {
text-decoration: underline;
}
.unsigned-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
background: #1e293b;
border: 1px solid #475569;
border-radius: 4px;
font-size: 0.75rem;
color: #64748b;
}
`],
})
export class SignedOverrideBadgeComponent {
readonly signatureInfo = input<VexDecisionSignatureInfo | null | undefined>(null);
readonly expanded = input<boolean>(false);
readonly showUnsigned = input<boolean>(false);
readonly isVerified = computed(() => this.signatureInfo()?.verificationStatus === 'verified');
readonly isFailed = computed(() => this.signatureInfo()?.verificationStatus === 'failed');
readonly statusIcon = computed(() => {
const status = this.signatureInfo()?.verificationStatus;
switch (status) {
case 'verified': return '[OK]';
case 'failed': return '[!]';
case 'pending': return '[?]';
default: return '[S]';
}
});
readonly ariaLabel = computed(() => {
const status = this.signatureInfo()?.verificationStatus;
switch (status) {
case 'verified': return 'Signature verified';
case 'failed': return 'Signature verification failed';
case 'pending': return 'Signature verification pending';
default: return 'Signed';
}
});
formatVerificationStatus(status: string): string {
const labels: Record<string, string> = {
verified: 'Verified',
failed: 'Failed',
pending: 'Pending',
unknown: 'Unknown',
};
return labels[status] || status;
}
truncateDigest(digest: string): string {
if (digest.length <= 16) return digest;
return `${digest.slice(0, 8)}...${digest.slice(-8)}`;
}
}

View File

@@ -0,0 +1,327 @@
/**
* Trace Export Actions Component Tests.
* Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui
* Task: FE-RISK-003
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { TraceExportActionsComponent, TraceExportFormat } from './trace-export-actions.component';
import { WITNESS_API, WitnessApi } from '../../../../core/api/witness.client';
import { DisplayPreferencesService } from '../../services/display-preferences.service';
describe('TraceExportActionsComponent', () => {
let component: TraceExportActionsComponent;
let fixture: ComponentFixture<TraceExportActionsComponent>;
let mockWitnessApi: jasmine.SpyObj<WitnessApi>;
let mockPrefsService: jasmine.SpyObj<DisplayPreferencesService>;
const mockWitnessListResponse = {
witnesses: [
{
witnessId: 'witness-001',
scanId: 'scan-001',
tenantId: 'tenant-001',
vulnId: 'vuln-001',
cveId: 'CVE-2024-12345',
packageName: 'test-lib',
packageVersion: '1.0.0',
purl: 'pkg:npm/test-lib@1.0.0',
confidenceTier: 'confirmed' as const,
confidenceScore: 0.95,
isReachable: true,
callPath: [
{ nodeId: 'n1', symbol: 'main', file: 'main.js', line: 10 },
{ nodeId: 'n2', symbol: 'process', file: 'handler.js', line: 25 },
],
entrypoint: { nodeId: 'e1', symbol: 'main', file: 'main.js', line: 10 },
sink: { nodeId: 's1', symbol: 'vulnerableCall', file: 'lib.js', line: 100, package: 'test-lib' },
gates: [],
evidence: { callGraphHash: 'blake3:abc', surfaceHash: 'sha256:def', analysisMethod: 'static' as const },
observedAt: '2026-01-16T10:00:00Z',
},
],
totalCount: 1,
page: 1,
pageSize: 1000,
hasMore: false,
};
beforeEach(async () => {
mockWitnessApi = jasmine.createSpyObj('WitnessApi', [
'listWitnesses',
'exportSarif',
]);
mockPrefsService = jasmine.createSpyObj('DisplayPreferencesService', [], {
preferences: jasmine.createSpy().and.returnValue({ enableTraceExport: true }),
});
mockWitnessApi.listWitnesses.and.returnValue(of(mockWitnessListResponse));
mockWitnessApi.exportSarif.and.returnValue(of(new Blob(['{}'], { type: 'application/json' })));
await TestBed.configureTestingModule({
imports: [TraceExportActionsComponent],
providers: [
{ provide: WITNESS_API, useValue: mockWitnessApi },
{ provide: DisplayPreferencesService, useValue: mockPrefsService },
],
}).compileComponents();
fixture = TestBed.createComponent(TraceExportActionsComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('when enabled', () => {
beforeEach(() => {
(mockPrefsService.preferences as jasmine.Spy).and.returnValue({ enableTraceExport: true });
fixture.detectChanges();
});
it('should show export buttons when scanId is provided', () => {
fixture.componentRef.setInput('scanId', 'scan-001');
fixture.detectChanges();
const buttons = fixture.nativeElement.querySelectorAll('.trace-export__btn');
expect(buttons.length).toBe(3); // JSON, GraphSON, SARIF
});
it('should disable buttons when no scanId', () => {
fixture.componentRef.setInput('scanId', null);
fixture.detectChanges();
const buttons = fixture.nativeElement.querySelectorAll('.trace-export__btn');
buttons.forEach((btn: HTMLButtonElement) => {
expect(btn.disabled).toBeTrue();
});
});
it('should show disabled notice when no scanId', () => {
fixture.componentRef.setInput('scanId', null);
fixture.detectChanges();
const notice = fixture.nativeElement.querySelector('.trace-export__disabled-notice');
expect(notice).toBeTruthy();
expect(notice.textContent).toContain('No scan selected');
});
});
describe('when disabled in preferences', () => {
beforeEach(() => {
(mockPrefsService.preferences as jasmine.Spy).and.returnValue({ enableTraceExport: false });
fixture.detectChanges();
});
it('should show disabled message', () => {
const disabledText = fixture.nativeElement.querySelector('.trace-export__disabled-text');
expect(disabledText).toBeTruthy();
expect(disabledText.textContent).toContain('disabled');
});
it('should not show export buttons', () => {
const buttons = fixture.nativeElement.querySelectorAll('.trace-export__btn');
expect(buttons.length).toBe(0);
});
});
describe('trace summary', () => {
beforeEach(() => {
(mockPrefsService.preferences as jasmine.Spy).and.returnValue({ enableTraceExport: true });
});
it('should show "No traces" when count is 0', () => {
fixture.componentRef.setInput('scanId', 'scan-001');
fixture.componentRef.setInput('traceCount', 0);
fixture.detectChanges();
expect(component.traceSummary()).toBe('No traces');
});
it('should show "1 trace" when count is 1', () => {
fixture.componentRef.setInput('scanId', 'scan-001');
fixture.componentRef.setInput('traceCount', 1);
fixture.detectChanges();
expect(component.traceSummary()).toBe('1 trace');
});
it('should show "N traces" when count > 1', () => {
fixture.componentRef.setInput('scanId', 'scan-001');
fixture.componentRef.setInput('traceCount', 5);
fixture.detectChanges();
expect(component.traceSummary()).toBe('5 traces');
});
});
describe('export functionality', () => {
beforeEach(() => {
(mockPrefsService.preferences as jasmine.Spy).and.returnValue({ enableTraceExport: true });
fixture.componentRef.setInput('scanId', 'scan-001');
fixture.detectChanges();
// Mock URL.createObjectURL and related
spyOn(URL, 'createObjectURL').and.returnValue('blob:test');
spyOn(URL, 'revokeObjectURL');
});
it('should emit exportStarted event on export', fakeAsync(() => {
const emitSpy = spyOn(component.exportStarted, 'emit');
component.exportAs('json');
tick();
expect(emitSpy).toHaveBeenCalledWith(jasmine.objectContaining({
scanId: 'scan-001',
format: 'json',
}));
}));
it('should emit exportCompleted with success on successful export', fakeAsync(() => {
const emitSpy = spyOn(component.exportCompleted, 'emit');
component.exportAs('sarif');
tick();
expect(emitSpy).toHaveBeenCalledWith(jasmine.objectContaining({
success: true,
}));
}));
it('should emit exportCompleted with error on failed export', fakeAsync(() => {
mockWitnessApi.exportSarif.and.returnValue(throwError(() => new Error('Network error')));
const emitSpy = spyOn(component.exportCompleted, 'emit');
component.exportAs('sarif');
tick();
expect(emitSpy).toHaveBeenCalledWith(jasmine.objectContaining({
success: false,
error: 'Network error',
}));
}));
it('should set isExporting during export', fakeAsync(() => {
expect(component.isExporting()).toBeFalse();
const exportPromise = component.exportAs('json');
expect(component.isExporting()).toBeTrue();
tick();
expect(component.isExporting()).toBeFalse();
}));
it('should track current format during export', fakeAsync(() => {
expect(component.currentFormat()).toBeNull();
component.exportAs('graphson');
expect(component.currentFormat()).toBe('graphson');
tick();
expect(component.currentFormat()).toBeNull();
}));
it('should call exportSarif for SARIF format', fakeAsync(() => {
component.exportAs('sarif');
tick();
expect(mockWitnessApi.exportSarif).toHaveBeenCalledWith('scan-001');
}));
it('should call listWitnesses for JSON format', fakeAsync(() => {
component.exportAs('json');
tick();
expect(mockWitnessApi.listWitnesses).toHaveBeenCalledWith('scan-001', { pageSize: 1000 });
}));
it('should call listWitnesses for GraphSON format', fakeAsync(() => {
component.exportAs('graphson');
tick();
expect(mockWitnessApi.listWitnesses).toHaveBeenCalledWith('scan-001', { pageSize: 1000 });
}));
});
describe('status messages', () => {
beforeEach(() => {
(mockPrefsService.preferences as jasmine.Spy).and.returnValue({ enableTraceExport: true });
fixture.componentRef.setInput('scanId', 'scan-001');
fixture.detectChanges();
});
it('should show exporting message during export', () => {
component.isExporting.set(true);
component.currentFormat.set('json');
expect(component.statusMessage()).toBe('Exporting as JSON...');
});
it('should show error message after failed export', () => {
component.lastError.set('Connection refused');
expect(component.statusMessage()).toContain('Export failed');
expect(component.statusMessage()).toContain('Connection refused');
});
it('should show success message after successful export', () => {
component.lastSuccess.set(true);
expect(component.statusMessage()).toContain('Export complete');
});
it('should return null when idle with no status', () => {
expect(component.statusMessage()).toBeNull();
});
});
describe('ASCII-only output', () => {
it('should use ASCII icons for buttons', () => {
(mockPrefsService.preferences as jasmine.Spy).and.returnValue({ enableTraceExport: true });
fixture.componentRef.setInput('scanId', 'scan-001');
fixture.detectChanges();
const icons = fixture.nativeElement.querySelectorAll('.trace-export__btn-icon');
const iconTexts = Array.from(icons).map((el: any) => el.textContent.trim());
// Should be ASCII-only: [J], [G], [S]
expect(iconTexts).toContain('[J]');
expect(iconTexts).toContain('[G]');
expect(iconTexts).toContain('[S]');
// Verify no non-ASCII characters
iconTexts.forEach((text: string) => {
expect(text).toMatch(/^[\x00-\x7F]+$/);
});
});
});
describe('deterministic output', () => {
beforeEach(() => {
(mockPrefsService.preferences as jasmine.Spy).and.returnValue({ enableTraceExport: true });
fixture.componentRef.setInput('scanId', 'scan-001');
fixture.detectChanges();
});
it('should produce consistent JSON export structure', fakeAsync(() => {
let capturedBlob: Blob | null = null;
// Intercept the download
spyOn(URL, 'createObjectURL').and.callFake((blob: Blob) => {
capturedBlob = blob;
return 'blob:test';
});
spyOn(URL, 'revokeObjectURL');
component.exportAs('json');
tick();
expect(capturedBlob).toBeTruthy();
}));
});
});

View File

@@ -0,0 +1,505 @@
/**
* Trace Export Actions Component.
* Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui
* Task: FE-RISK-003
*
* Provides UI actions for exporting reachability traces in various formats.
*/
import { Component, input, output, signal, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { firstValueFrom } from 'rxjs';
import { WITNESS_API, WitnessApi } from '../../../../core/api/witness.client';
import { DisplayPreferencesService } from '../../services/display-preferences.service';
/**
* Supported trace export formats.
*/
export type TraceExportFormat = 'json' | 'graphson' | 'sarif';
/**
* Export request details.
*/
export interface TraceExportRequest {
/** Scan ID to export traces from. */
scanId: string;
/** Format to export. */
format: TraceExportFormat;
/** Optional filename hint. */
filename?: string;
}
/**
* Export result.
*/
export interface TraceExportResult {
/** Whether the export succeeded. */
success: boolean;
/** Error message if failed. */
error?: string;
/** Download URL if created. */
downloadUrl?: string;
}
@Component({
selector: 'app-trace-export-actions',
standalone: true,
imports: [CommonModule],
template: `
<div class="trace-export" *ngIf="isEnabled()">
<div class="trace-export__header">
<span class="trace-export__label">Export Traces</span>
<span class="trace-export__hint" *ngIf="scanId()">{{ traceSummary() }}</span>
</div>
<div class="trace-export__actions">
<button
class="trace-export__btn"
[class.trace-export__btn--loading]="isExporting() && currentFormat() === 'json'"
[disabled]="!canExport() || isExporting()"
(click)="exportAs('json')"
title="Export as JSON"
>
<span class="trace-export__btn-icon">[J]</span>
<span class="trace-export__btn-text">JSON</span>
</button>
<button
class="trace-export__btn"
[class.trace-export__btn--loading]="isExporting() && currentFormat() === 'graphson'"
[disabled]="!canExport() || isExporting()"
(click)="exportAs('graphson')"
title="Export as GraphSON (Apache TinkerPop format)"
>
<span class="trace-export__btn-icon">[G]</span>
<span class="trace-export__btn-text">GraphSON</span>
</button>
<button
class="trace-export__btn"
[class.trace-export__btn--loading]="isExporting() && currentFormat() === 'sarif'"
[disabled]="!canExport() || isExporting()"
(click)="exportAs('sarif')"
title="Export as SARIF 2.1.0"
>
<span class="trace-export__btn-icon">[S]</span>
<span class="trace-export__btn-text">SARIF</span>
</button>
</div>
<div class="trace-export__status" *ngIf="statusMessage()">
<span
class="trace-export__status-text"
[class.trace-export__status-text--error]="lastError()"
[class.trace-export__status-text--success]="lastSuccess()"
>
{{ statusMessage() }}
</span>
</div>
<div class="trace-export__disabled-notice" *ngIf="!scanId()">
No scan selected for export.
</div>
</div>
<div class="trace-export trace-export--disabled" *ngIf="!isEnabled()">
<span class="trace-export__disabled-text">
Trace export is disabled in settings.
</span>
</div>
`,
styles: [`
.trace-export {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
background: var(--surface-secondary, #f8f9fa);
border-radius: 8px;
border: 1px solid var(--border-color, #dee2e6);
}
.trace-export--disabled {
opacity: 0.6;
background: var(--surface-tertiary, #e9ecef);
}
.trace-export__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.trace-export__label {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary, #212529);
}
.trace-export__hint {
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
}
.trace-export__actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.trace-export__btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary, #212529);
background: var(--surface-primary, #fff);
border: 1px solid var(--border-color, #dee2e6);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background: var(--surface-hover, #e9ecef);
border-color: var(--border-hover, #adb5bd);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.trace-export__btn--loading {
position: relative;
color: transparent;
&::after {
content: '...';
position: absolute;
left: 50%;
transform: translateX(-50%);
color: var(--text-secondary, #6c757d);
animation: pulse 1s ease-in-out infinite;
}
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.trace-export__btn-icon {
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
}
.trace-export__btn-text {
font-size: 0.8125rem;
}
.trace-export__status {
padding-top: 0.25rem;
}
.trace-export__status-text {
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
}
.trace-export__status-text--error {
color: var(--color-error, #dc3545);
}
.trace-export__status-text--success {
color: var(--color-success, #198754);
}
.trace-export__disabled-notice {
font-size: 0.8125rem;
color: var(--text-muted, #6c757d);
font-style: italic;
}
.trace-export__disabled-text {
font-size: 0.8125rem;
color: var(--text-muted, #6c757d);
}
`],
})
export class TraceExportActionsComponent {
private readonly witnessApi = inject<WitnessApi>(WITNESS_API);
private readonly prefs = inject(DisplayPreferencesService);
/** Scan ID to export traces from. */
scanId = input<string | null>(null);
/** Number of traces available for export. */
traceCount = input<number>(0);
/** Emitted when an export starts. */
exportStarted = output<TraceExportRequest>();
/** Emitted when an export completes. */
exportCompleted = output<TraceExportResult>();
// State
isExporting = signal(false);
currentFormat = signal<TraceExportFormat | null>(null);
lastError = signal<string | null>(null);
lastSuccess = signal(false);
/** Whether trace export is enabled in user preferences. */
isEnabled = computed(() => this.prefs.preferences().enableTraceExport);
/** Whether export is currently possible. */
canExport = computed(() => !!this.scanId() && !this.isExporting());
/** Summary text for header. */
traceSummary = computed(() => {
const count = this.traceCount();
if (count === 0) return 'No traces';
if (count === 1) return '1 trace';
return `${count} traces`;
});
/** Status message to display. */
statusMessage = computed(() => {
if (this.isExporting()) {
return `Exporting as ${this.currentFormat()?.toUpperCase()}...`;
}
if (this.lastError()) {
return `Export failed: ${this.lastError()}`;
}
if (this.lastSuccess()) {
return 'Export complete. Download started.';
}
return null;
});
/**
* Export traces in the specified format.
*/
async exportAs(format: TraceExportFormat): Promise<void> {
const scanId = this.scanId();
if (!scanId || this.isExporting()) return;
this.isExporting.set(true);
this.currentFormat.set(format);
this.lastError.set(null);
this.lastSuccess.set(false);
const request: TraceExportRequest = {
scanId,
format,
filename: `traces-${scanId}-${format}`,
};
this.exportStarted.emit(request);
try {
let blob: Blob | null = null;
let filename = request.filename!;
switch (format) {
case 'sarif':
blob = await firstValueFrom(this.witnessApi.exportSarif(scanId));
filename += '.sarif.json';
break;
case 'json':
// For JSON, we'd call a different endpoint or format the witness data
// Using exportSarif as fallback since structure is similar
blob = await this.exportAsJson(scanId);
filename += '.json';
break;
case 'graphson':
// GraphSON export would need a dedicated endpoint
blob = await this.exportAsGraphson(scanId);
filename += '.graphson.json';
break;
}
if (blob) {
this.downloadBlob(blob, filename);
this.lastSuccess.set(true);
this.exportCompleted.emit({ success: true });
} else {
throw new Error('Export returned empty data');
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.lastError.set(message);
this.exportCompleted.emit({ success: false, error: message });
} finally {
this.isExporting.set(false);
this.currentFormat.set(null);
// Clear success message after 3 seconds
if (this.lastSuccess()) {
setTimeout(() => this.lastSuccess.set(false), 3000);
}
}
}
/**
* Export as plain JSON format.
*/
private async exportAsJson(scanId: string): Promise<Blob> {
// Get witnesses and format as plain JSON
const response = await firstValueFrom(
this.witnessApi.listWitnesses(scanId, { pageSize: 1000 })
);
const exportData = {
exportedAt: new Date().toISOString(),
scanId,
format: 'stellaops.traces.v1',
traceCount: response.witnesses.length,
witnesses: response.witnesses.map(w => ({
witnessId: w.witnessId,
vulnId: w.vulnId,
cveId: w.cveId,
confidenceTier: w.confidenceTier,
isReachable: w.isReachable,
callPath: w.callPath,
entrypoint: w.entrypoint,
sink: w.sink,
evidence: w.evidence,
observedAt: w.observedAt,
})),
};
const json = JSON.stringify(exportData, null, 2);
return new Blob([json], { type: 'application/json' });
}
/**
* Export as GraphSON format (Apache TinkerPop).
*/
private async exportAsGraphson(scanId: string): Promise<Blob> {
const response = await firstValueFrom(
this.witnessApi.listWitnesses(scanId, { pageSize: 1000 })
);
// Build GraphSON 3.0 structure
const vertices: GraphsonVertex[] = [];
const edges: GraphsonEdge[] = [];
let edgeId = 1;
for (const witness of response.witnesses) {
// Add entry point vertex
if (witness.entrypoint) {
vertices.push({
'@type': 'g:Vertex',
'@value': {
id: { '@type': 'g:Int64', '@value': vertices.length + 1 },
label: 'entrypoint',
properties: {
symbol: [{ '@type': 'g:VertexProperty', '@value': { value: witness.entrypoint.symbol } }],
file: [{ '@type': 'g:VertexProperty', '@value': { value: witness.entrypoint.file } }],
line: [{ '@type': 'g:VertexProperty', '@value': { value: witness.entrypoint.line } }],
},
},
});
}
// Add call path nodes as vertices
for (const node of witness.callPath) {
vertices.push({
'@type': 'g:Vertex',
'@value': {
id: { '@type': 'g:Int64', '@value': vertices.length + 1 },
label: 'callnode',
properties: {
nodeId: [{ '@type': 'g:VertexProperty', '@value': { value: node.nodeId } }],
symbol: [{ '@type': 'g:VertexProperty', '@value': { value: node.symbol } }],
file: [{ '@type': 'g:VertexProperty', '@value': { value: node.file ?? '' } }],
line: [{ '@type': 'g:VertexProperty', '@value': { value: node.line ?? 0 } }],
},
},
});
// Add edge to previous node
if (vertices.length > 1) {
edges.push({
'@type': 'g:Edge',
'@value': {
id: { '@type': 'g:Int64', '@value': edgeId++ },
label: 'calls',
inV: { '@type': 'g:Int64', '@value': vertices.length },
outV: { '@type': 'g:Int64', '@value': vertices.length - 1 },
},
});
}
}
// Add sink vertex
if (witness.sink) {
vertices.push({
'@type': 'g:Vertex',
'@value': {
id: { '@type': 'g:Int64', '@value': vertices.length + 1 },
label: 'sink',
properties: {
symbol: [{ '@type': 'g:VertexProperty', '@value': { value: witness.sink.symbol } }],
file: [{ '@type': 'g:VertexProperty', '@value': { value: witness.sink.file ?? '' } }],
package: [{ '@type': 'g:VertexProperty', '@value': { value: witness.sink.package ?? '' } }],
},
},
});
}
}
const graphson = {
'@type': 'tinker:graph',
'@value': {
vertices,
edges,
},
};
const json = JSON.stringify(graphson, null, 2);
return new Blob([json], { type: 'application/json' });
}
/**
* Trigger browser download of a blob.
*/
private downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
// GraphSON type definitions
interface GraphsonVertex {
'@type': 'g:Vertex';
'@value': {
id: { '@type': 'g:Int64'; '@value': number };
label: string;
properties: Record<string, { '@type': 'g:VertexProperty'; '@value': { value: unknown } }[]>;
};
}
interface GraphsonEdge {
'@type': 'g:Edge';
'@value': {
id: { '@type': 'g:Int64'; '@value': number };
label: string;
inV: { '@type': 'g:Int64'; '@value': number };
outV: { '@type': 'g:Int64'; '@value': number };
};
}

View File

@@ -12,10 +12,47 @@ export interface EvidenceBundle {
callstack?: EvidenceSection;
provenance?: EvidenceSection;
vex?: VexEvidenceSection;
binaryDiff?: BinaryDiffEvidenceSection;
aiCodeGuard?: AiCodeGuardEvidenceSection;
hashes?: EvidenceHashes;
computedAt: string;
}
/**
* Binary diff evidence section.
* Sprint: SPRINT_20260112_010_FE_binary_diff_explain_panel
* Task: BINDIFF-FE-001
*/
export interface BinaryDiffEvidenceSection {
status: EvidenceStatus;
baseHash?: string;
headHash?: string;
totalSections?: number;
modifiedSections?: number;
addedSections?: number;
removedSections?: number;
symbolChanges?: number;
confidence?: number;
analysisTimestamp?: string;
}
/**
* AI Code Guard evidence section.
* Sprint: SPRINT_20260112_010_FE_ai_code_guard_console
* Task: FE-AIGUARD-001
*/
export interface AiCodeGuardEvidenceSection {
status: EvidenceStatus;
verdict?: 'pass' | 'pass_with_warnings' | 'fail' | 'error';
totalFindings?: number;
criticalCount?: number;
highCount?: number;
mediumCount?: number;
lowCount?: number;
aiGeneratedPercentage?: number;
scanTimestamp?: string;
}
/**
* Individual evidence section.
*/
@@ -63,6 +100,8 @@ export class EvidenceBitset {
private static readonly CALLSTACK = 1 << 1;
private static readonly PROVENANCE = 1 << 2;
private static readonly VEX = 1 << 3;
private static readonly BINARY_DIFF = 1 << 4;
private static readonly AI_CODE_GUARD = 1 << 5;
constructor(public value: number = 0) {}
@@ -82,8 +121,16 @@ export class EvidenceBitset {
return (this.value & EvidenceBitset.VEX) !== 0;
}
get hasBinaryDiff(): boolean {
return (this.value & EvidenceBitset.BINARY_DIFF) !== 0;
}
get hasAiCodeGuard(): boolean {
return (this.value & EvidenceBitset.AI_CODE_GUARD) !== 0;
}
/**
* Completeness score (0-4).
* Completeness score (0-6).
*/
get completenessScore(): number {
let score = 0;
@@ -91,6 +138,8 @@ export class EvidenceBitset {
if (this.hasCallstack) score++;
if (this.hasProvenance) score++;
if (this.hasVex) score++;
if (this.hasBinaryDiff) score++;
if (this.hasAiCodeGuard) score++;
return score;
}
@@ -99,12 +148,16 @@ export class EvidenceBitset {
callstack?: boolean;
provenance?: boolean;
vex?: boolean;
binaryDiff?: boolean;
aiCodeGuard?: boolean;
}): EvidenceBitset {
let value = 0;
if (evidence.reachability) value |= EvidenceBitset.REACHABILITY;
if (evidence.callstack) value |= EvidenceBitset.CALLSTACK;
if (evidence.provenance) value |= EvidenceBitset.PROVENANCE;
if (evidence.vex) value |= EvidenceBitset.VEX;
if (evidence.binaryDiff) value |= EvidenceBitset.BINARY_DIFF;
if (evidence.aiCodeGuard) value |= EvidenceBitset.AI_CODE_GUARD;
return new EvidenceBitset(value);
}
@@ -115,6 +168,8 @@ export class EvidenceBitset {
callstack: bundle.callstack?.status === 'available',
provenance: bundle.provenance?.status === 'available',
vex: bundle.vex?.status === 'available',
binaryDiff: bundle.binaryDiff?.status === 'available',
aiCodeGuard: bundle.aiCodeGuard?.status === 'available',
});
}
}

View File

@@ -143,6 +143,140 @@ export interface CallPathNode {
line?: number;
/** Node type */
type: 'entry' | 'intermediate' | 'vulnerable';
/** Whether this node was confirmed by runtime observation */
runtimeConfirmed?: boolean;
/** Timestamp when runtime confirmed (if applicable) */
runtimeConfirmedAt?: string;
}
/**
* Runtime confirmation status for a call graph edge.
* Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui
* Task: FE-RISK-002 — Runtime-confirmed edge highlighting
*/
export type EdgeRuntimeStatus = 'confirmed' | 'inferred' | 'unknown';
/**
* Extended call graph edge with runtime confirmation.
* Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui
* Task: FE-RISK-002 — Runtime-confirmed edge highlighting
*/
export interface RuntimeConfirmedEdge {
/** Source node ID */
from: string;
/** Target node ID */
to: string;
/** Call type */
callType: 'direct' | 'indirect' | 'virtual' | 'async';
/** Runtime confirmation status */
runtimeStatus: EdgeRuntimeStatus;
/** Whether edge was observed in runtime traces */
runtimeConfirmed: boolean;
/** Timestamp when first confirmed at runtime */
runtimeConfirmedAt?: string;
/** Number of times observed in runtime traces */
observationCount?: number;
/** Trace IDs where this edge was observed */
traceIds?: string[];
/** Confidence in this edge (0-1) */
confidence: number;
}
/**
* Runtime-enhanced call graph path.
* Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui
* Task: FE-RISK-002 — Runtime-confirmed edge highlighting
*/
export interface RuntimeEnhancedPath {
/** Path ID */
id: string;
/** Nodes in the path */
nodes: CallPathNode[];
/** Edges with runtime confirmation status */
edges: RuntimeConfirmedEdge[];
/** Overall path confidence */
confidence: number;
/** Whether any edge in path is runtime-confirmed */
hasRuntimeEvidence: boolean;
/** Percentage of edges that are runtime-confirmed */
runtimeCoveragePercent: number;
}
/**
* Legend entry for call graph visualization.
* Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui
* Task: FE-RISK-002 — Update legends and accessibility labels
*/
export interface CallGraphLegendEntry {
/** Legend key */
key: string;
/** Display label */
label: string;
/** Color code (CSS) */
color: string;
/** Icon (ASCII-only for accessibility) */
icon: string;
/** ARIA description for screen readers */
ariaDescription: string;
}
/**
* Legend configuration for runtime-confirmed call graphs.
*/
export const RUNTIME_CALL_GRAPH_LEGEND: CallGraphLegendEntry[] = [
{
key: 'runtime-confirmed',
label: 'Runtime Confirmed',
color: '#059669', // green-600
icon: '[+]',
ariaDescription: 'Edge was observed in runtime execution traces',
},
{
key: 'static-inferred',
label: 'Static Analysis',
color: '#6366f1', // indigo-500
icon: '[~]',
ariaDescription: 'Edge inferred from static code analysis',
},
{
key: 'unknown',
label: 'Unknown',
color: '#94a3b8', // slate-400
icon: '[?]',
ariaDescription: 'Edge status not determined',
},
{
key: 'entry-point',
label: 'Entry Point',
color: '#2563eb', // blue-600
icon: '[>]',
ariaDescription: 'Application entry point or public API',
},
{
key: 'vulnerable',
label: 'Vulnerable Code',
color: '#dc2626', // red-600
icon: '[!]',
ariaDescription: 'Location of vulnerable code or symbol',
},
];
/**
* Helper to compute runtime coverage for a path.
*/
export function computeRuntimeCoverage(edges: RuntimeConfirmedEdge[]): number {
if (edges.length === 0) return 0;
const confirmedCount = edges.filter(e => e.runtimeConfirmed).length;
return Math.round((confirmedCount / edges.length) * 100);
}
/**
* Helper to get edge ARIA label.
*/
export function getEdgeAriaLabel(edge: RuntimeConfirmedEdge): string {
const status = edge.runtimeConfirmed ? 'runtime confirmed' : 'inferred from static analysis';
const callType = edge.callType === 'direct' ? 'direct call' : `${edge.callType} call`;
return `${callType} from ${edge.from} to ${edge.to}, ${status}`;
}
/**

View File

@@ -0,0 +1,90 @@
// -----------------------------------------------------------------------------
// binary-diff-evidence.service.ts
// Sprint: SPRINT_20260112_010_FE_binary_diff_explain_panel
// Task: BINDIFF-FE-001 — Binary diff evidence service and API client
// -----------------------------------------------------------------------------
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { BinaryDiffSummary } from '../components/evidence-panel/binary-diff-tab.component';
/**
* API response for binary diff evidence.
*/
export interface BinaryDiffApiResponse {
success: boolean;
data?: BinaryDiffSummary;
error?: string;
}
/**
* Service for fetching binary diff evidence from the API.
*/
@Injectable({
providedIn: 'root',
})
export class BinaryDiffEvidenceService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/evidence/binary-diff';
/**
* Gets binary diff summary for an artifact.
* @param artifactId The artifact identifier.
* @returns Observable of BinaryDiffSummary.
*/
getBinaryDiffSummary(artifactId: string): Observable<BinaryDiffSummary> {
return this.http
.get<BinaryDiffApiResponse>(`${this.baseUrl}/${encodeURIComponent(artifactId)}`)
.pipe(
map((response) => {
if (!response.success || !response.data) {
throw new Error(response.error || 'No binary diff data available');
}
return response.data;
}),
catchError((err) => {
console.error('Binary diff fetch error:', err);
throw new Error(err?.error?.message || err?.message || 'Failed to fetch binary diff evidence');
})
);
}
/**
* Gets binary diff section details.
* @param artifactId The artifact identifier.
* @param sectionName The section name.
* @returns Observable of section byte-level diff.
*/
getSectionDiff(artifactId: string, sectionName: string): Observable<ArrayBuffer> {
return this.http
.get(`${this.baseUrl}/${encodeURIComponent(artifactId)}/sections/${encodeURIComponent(sectionName)}/diff`, {
responseType: 'arraybuffer',
})
.pipe(
catchError((err) => {
console.error('Section diff fetch error:', err);
throw new Error(err?.error?.message || err?.message || 'Failed to fetch section diff');
})
);
}
/**
* Exports binary diff evidence as JSON.
* @param artifactId The artifact identifier.
* @returns Observable of Blob.
*/
exportEvidence(artifactId: string): Observable<Blob> {
return this.http
.get(`${this.baseUrl}/${encodeURIComponent(artifactId)}/export`, {
responseType: 'blob',
})
.pipe(
catchError((err) => {
console.error('Export error:', err);
throw new Error(err?.error?.message || err?.message || 'Failed to export evidence');
})
);
}
}

View File

@@ -0,0 +1,194 @@
// -----------------------------------------------------------------------------
// display-preferences.service.spec.ts
// Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui
// Task: FE-RISK-006 — Unit tests for display preferences service
// -----------------------------------------------------------------------------
import { TestBed } from '@angular/core/testing';
import { DisplayPreferencesService, DisplayPreferences } from './display-preferences.service';
describe('DisplayPreferencesService', () => {
let service: DisplayPreferencesService;
let localStorageSpy: jasmine.SpyObj<Storage>;
const STORAGE_KEY = 'stellaops.display.preferences';
beforeEach(() => {
// Mock localStorage
localStorageSpy = jasmine.createSpyObj('localStorage', ['getItem', 'setItem']);
spyOn(window.localStorage, 'getItem').and.callFake(localStorageSpy.getItem);
spyOn(window.localStorage, 'setItem').and.callFake(localStorageSpy.setItem);
TestBed.configureTestingModule({
providers: [DisplayPreferencesService],
});
service = TestBed.inject(DisplayPreferencesService);
});
describe('default preferences', () => {
it('should have runtime overlays enabled by default', () => {
expect(service.showRuntimeOverlays()).toBe(true);
});
it('should have trace export enabled by default', () => {
expect(service.enableTraceExport()).toBe(true);
});
it('should have risk line shown by default', () => {
expect(service.showRiskLine()).toBe(true);
});
it('should have signed override indicators shown by default', () => {
expect(service.showSignedOverrideIndicators()).toBe(true);
});
it('should have runtime evidence collapsed by default', () => {
expect(service.expandRuntimeEvidence()).toBe(false);
});
it('should have graph maxNodes set to 50 by default', () => {
expect(service.graphMaxNodes()).toBe(50);
});
it('should have runtime highlight style set to both by default', () => {
expect(service.runtimeHighlightStyle()).toBe('both');
});
});
describe('setShowRuntimeOverlays', () => {
it('should update showRuntimeOverlays preference', () => {
service.setShowRuntimeOverlays(false);
expect(service.showRuntimeOverlays()).toBe(false);
service.setShowRuntimeOverlays(true);
expect(service.showRuntimeOverlays()).toBe(true);
});
});
describe('setEnableTraceExport', () => {
it('should update enableTraceExport preference', () => {
service.setEnableTraceExport(false);
expect(service.enableTraceExport()).toBe(false);
service.setEnableTraceExport(true);
expect(service.enableTraceExport()).toBe(true);
});
});
describe('setShowRiskLine', () => {
it('should update showRiskLine preference', () => {
service.setShowRiskLine(false);
expect(service.showRiskLine()).toBe(false);
service.setShowRiskLine(true);
expect(service.showRiskLine()).toBe(true);
});
});
describe('setShowSignedOverrideIndicators', () => {
it('should update showSignedOverrideIndicators preference', () => {
service.setShowSignedOverrideIndicators(false);
expect(service.showSignedOverrideIndicators()).toBe(false);
service.setShowSignedOverrideIndicators(true);
expect(service.showSignedOverrideIndicators()).toBe(true);
});
});
describe('setExpandRuntimeEvidence', () => {
it('should update expandRuntimeEvidence preference', () => {
service.setExpandRuntimeEvidence(true);
expect(service.expandRuntimeEvidence()).toBe(true);
service.setExpandRuntimeEvidence(false);
expect(service.expandRuntimeEvidence()).toBe(false);
});
});
describe('setGraphMaxNodes', () => {
it('should update graph maxNodes preference', () => {
service.setGraphMaxNodes(100);
expect(service.graphMaxNodes()).toBe(100);
});
it('should clamp value to minimum of 10', () => {
service.setGraphMaxNodes(5);
expect(service.graphMaxNodes()).toBe(10);
});
it('should clamp value to maximum of 200', () => {
service.setGraphMaxNodes(500);
expect(service.graphMaxNodes()).toBe(200);
});
});
describe('setRuntimeHighlightStyle', () => {
it('should update runtime highlight style to bold', () => {
service.setRuntimeHighlightStyle('bold');
expect(service.runtimeHighlightStyle()).toBe('bold');
});
it('should update runtime highlight style to color', () => {
service.setRuntimeHighlightStyle('color');
expect(service.runtimeHighlightStyle()).toBe('color');
});
it('should update runtime highlight style to both', () => {
service.setRuntimeHighlightStyle('both');
expect(service.runtimeHighlightStyle()).toBe('both');
});
});
describe('reset', () => {
it('should reset all preferences to defaults', () => {
// Change some preferences
service.setShowRuntimeOverlays(false);
service.setEnableTraceExport(false);
service.setGraphMaxNodes(150);
// Reset
service.reset();
// Verify defaults
expect(service.showRuntimeOverlays()).toBe(true);
expect(service.enableTraceExport()).toBe(true);
expect(service.showRiskLine()).toBe(true);
expect(service.graphMaxNodes()).toBe(50);
});
});
describe('preferences computed', () => {
it('should return full preferences object', () => {
const prefs = service.preferences();
expect(prefs.showRuntimeOverlays).toBe(true);
expect(prefs.enableTraceExport).toBe(true);
expect(prefs.showRiskLine).toBe(true);
expect(prefs.showSignedOverrideIndicators).toBe(true);
expect(prefs.expandRuntimeEvidence).toBe(false);
expect(prefs.graph.maxNodes).toBe(50);
expect(prefs.graph.runtimeHighlightStyle).toBe('both');
});
it('should reflect updates in preferences object', () => {
service.setShowRuntimeOverlays(false);
service.setGraphMaxNodes(75);
const prefs = service.preferences();
expect(prefs.showRuntimeOverlays).toBe(false);
expect(prefs.graph.maxNodes).toBe(75);
});
});
describe('deterministic behavior', () => {
it('should produce consistent output for same inputs', () => {
service.setShowRuntimeOverlays(true);
service.setEnableTraceExport(true);
service.setGraphMaxNodes(50);
const prefs1 = service.preferences();
const prefs2 = service.preferences();
expect(JSON.stringify(prefs1)).toBe(JSON.stringify(prefs2));
});
});
});

View File

@@ -0,0 +1,194 @@
// -----------------------------------------------------------------------------
// display-preferences.service.ts
// Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui
// Task: FE-RISK-006 — User setting toggle for runtime overlays and trace export
// -----------------------------------------------------------------------------
import { Injectable, signal, computed, effect } from '@angular/core';
/**
* Display preferences for triage and finding views.
* Controls visibility of runtime overlays, trace export actions, and risk line display.
*/
export interface DisplayPreferences {
/**
* Show runtime-confirmed edge overlays in reachability graphs.
* When enabled, edges observed in runtime traces are highlighted.
*/
showRuntimeOverlays: boolean;
/**
* Enable trace export actions in reachability panel.
* When enabled, users can export call graphs as GraphSON/JSON/SARIF.
*/
enableTraceExport: boolean;
/**
* Show the risk line summary bar in finding detail views.
* Displays reachability score, runtime badge, and evidence link.
*/
showRiskLine: boolean;
/**
* Show signed VEX override indicators (DSSE badge, Rekor link).
* When enabled, signed override metadata is displayed in VEX panels.
*/
showSignedOverrideIndicators: boolean;
/**
* Expand runtime evidence section by default.
*/
expandRuntimeEvidence: boolean;
/**
* Graph display preferences.
*/
graph: {
/** Maximum nodes to render in call graph visualizations. */
maxNodes: number;
/** Highlight style for runtime-confirmed edges. */
runtimeHighlightStyle: 'bold' | 'color' | 'both';
};
}
const STORAGE_KEY = 'stellaops.display.preferences';
const DEFAULT_PREFERENCES: DisplayPreferences = {
showRuntimeOverlays: true,
enableTraceExport: true,
showRiskLine: true,
showSignedOverrideIndicators: true,
expandRuntimeEvidence: false,
graph: {
maxNodes: 50,
runtimeHighlightStyle: 'both',
},
};
/**
* Service for managing display preferences in triage and finding views.
* Preferences are persisted to localStorage and automatically synced.
*/
@Injectable({
providedIn: 'root',
})
export class DisplayPreferencesService {
private readonly _preferences = signal<DisplayPreferences>(this.load());
// Expose individual preferences as computed signals
readonly showRuntimeOverlays = computed(() => this._preferences().showRuntimeOverlays);
readonly enableTraceExport = computed(() => this._preferences().enableTraceExport);
readonly showRiskLine = computed(() => this._preferences().showRiskLine);
readonly showSignedOverrideIndicators = computed(() => this._preferences().showSignedOverrideIndicators);
readonly expandRuntimeEvidence = computed(() => this._preferences().expandRuntimeEvidence);
readonly graphMaxNodes = computed(() => this._preferences().graph.maxNodes);
readonly runtimeHighlightStyle = computed(() => this._preferences().graph.runtimeHighlightStyle);
// Full preferences object (read-only)
readonly preferences = computed(() => this._preferences());
constructor() {
// Auto-persist on changes
effect(() => {
const prefs = this._preferences();
this.persist(prefs);
});
}
/**
* Set whether runtime-confirmed overlays are shown in graphs.
*/
setShowRuntimeOverlays(value: boolean): void {
this._preferences.update((p) => ({ ...p, showRuntimeOverlays: value }));
}
/**
* Set whether trace export actions are available.
*/
setEnableTraceExport(value: boolean): void {
this._preferences.update((p) => ({ ...p, enableTraceExport: value }));
}
/**
* Set whether the risk line summary bar is shown.
*/
setShowRiskLine(value: boolean): void {
this._preferences.update((p) => ({ ...p, showRiskLine: value }));
}
/**
* Set whether signed VEX override indicators are shown.
*/
setShowSignedOverrideIndicators(value: boolean): void {
this._preferences.update((p) => ({ ...p, showSignedOverrideIndicators: value }));
}
/**
* Set whether runtime evidence section is expanded by default.
*/
setExpandRuntimeEvidence(value: boolean): void {
this._preferences.update((p) => ({ ...p, expandRuntimeEvidence: value }));
}
/**
* Set maximum nodes to render in call graph visualizations.
*/
setGraphMaxNodes(value: number): void {
const clamped = Math.max(10, Math.min(200, value));
this._preferences.update((p) => ({
...p,
graph: { ...p.graph, maxNodes: clamped },
}));
}
/**
* Set highlight style for runtime-confirmed edges.
*/
setRuntimeHighlightStyle(value: 'bold' | 'color' | 'both'): void {
this._preferences.update((p) => ({
...p,
graph: { ...p.graph, runtimeHighlightStyle: value },
}));
}
/**
* Reset all preferences to defaults.
*/
reset(): void {
this._preferences.set({ ...DEFAULT_PREFERENCES, graph: { ...DEFAULT_PREFERENCES.graph } });
}
/**
* Load preferences from localStorage.
*/
private load(): DisplayPreferences {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return {
...DEFAULT_PREFERENCES,
...parsed,
graph: {
...DEFAULT_PREFERENCES.graph,
...(parsed.graph || {}),
},
};
}
} catch {
// Ignore parse errors, use defaults
}
return { ...DEFAULT_PREFERENCES, graph: { ...DEFAULT_PREFERENCES.graph } };
}
/**
* Persist preferences to localStorage.
*/
private persist(prefs: DisplayPreferences): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// Ignore storage errors (quota exceeded, private mode, etc.)
}
}
}

View File

@@ -3,6 +3,7 @@
// Sprint: SPRINT_20260107_006_001_FE_tabbed_evidence_panel
// Sprint: SPRINT_20260107_006_002_FE_diff_runtime_tabs
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Sprint: SPRINT_20260112_004_FE_risk_line_runtime_trace_ui
// Description: Barrel export file for triage feature services
// -----------------------------------------------------------------------------
@@ -18,3 +19,9 @@ export { RuntimeEvidenceService } from './runtime-evidence.service';
// Hybrid Reachability Services (Sprint 009_006)
export { ReachabilityService } from './reachability.service';
// Display Preferences (Sprint 004 FE Risk Line)
export { DisplayPreferencesService, type DisplayPreferences } from './display-preferences.service';
// Binary Diff Evidence Services (Sprint 010)
export { BinaryDiffEvidenceService } from './binary-diff-evidence.service';

View File

@@ -542,4 +542,149 @@ describe('AiRemediatePanelComponent', () => {
tick();
}));
});
/**
* PR Creation Tests
* Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring
* Task: REMPR-FE-005
*/
describe('PR Creation', () => {
beforeEach(() => {
// Add PR-related mock methods
mockAdvisoryAiApi.createRemediationPr = jasmine.createSpy('createRemediationPr');
mockAdvisoryAiApi.getScmConnections = jasmine.createSpy('getScmConnections');
mockAdvisoryAiApi.getScmConnections.and.returnValue(of([
{
id: 'conn-1',
provider: 'github',
name: 'GitHub - Main Org',
repositoryUrl: 'https://github.com/org/repo',
capabilities: { canCreatePr: true, canAddLabels: true, canAddAssignees: true, canAttachFiles: false, supportsEvidenceCards: true },
},
]));
mockAdvisoryAiApi.createRemediationPr.and.returnValue(of({
prId: 'pr-123',
prNumber: 42,
prUrl: 'https://github.com/org/repo/pull/42',
branch: 'fix/CVE-2024-12345',
status: 'open',
ciStatus: 'pending',
evidenceCardId: 'evcard-456',
}));
fixture.componentRef.setInput('visible', true);
component.remediation.set(mockRemediateResponse);
fixture.detectChanges();
});
it('should load SCM connections when panel opens', fakeAsync(() => {
// Trigger loadScmConnections if the component has this method
if (typeof (component as any).loadScmConnections === 'function') {
(component as any).loadScmConnections();
tick();
expect(mockAdvisoryAiApi.getScmConnections).toHaveBeenCalled();
}
}));
it('should create PR when createPr is called', fakeAsync(() => {
if (typeof (component as any).createPr === 'function') {
(component as any).selectedScmConnection?.set?.('conn-1');
(component as any).createPr();
tick();
expect(mockAdvisoryAiApi.createRemediationPr).toHaveBeenCalled();
}
}));
it('should handle PR creation errors gracefully', fakeAsync(() => {
if (typeof (component as any).createPr === 'function') {
mockAdvisoryAiApi.createRemediationPr.and.returnValue(
throwError(() => ({ code: 'BRANCH_EXISTS', message: 'Branch already exists' }))
);
(component as any).selectedScmConnection?.set?.('conn-1');
(component as any).createPr();
tick();
// Should set error state
if ((component as any).prError) {
expect((component as any).prError()).toBeTruthy();
}
}
}));
it('should show active PR when present in response', () => {
const responseWithPr = {
...mockRemediateResponse,
prCreationAvailable: true,
activePr: {
prId: 'pr-existing',
prNumber: 99,
prUrl: 'https://github.com/org/repo/pull/99',
branch: 'fix/CVE-2024-12345',
status: 'open',
ciStatus: 'success',
},
};
component.remediation.set(responseWithPr);
fixture.detectChanges();
// Check that active PR info would be accessible
const remediation = component.remediation();
expect((remediation as any).activePr?.prNumber).toBe(99);
});
it('should disable PR button when no SCM connection selected', () => {
if ((component as any).scmConnections) {
(component as any).scmConnections.set([]);
fixture.detectChanges();
// Button should be disabled or hidden
const prButton = fixture.debugElement.query(By.css('.btn-create-pr'));
if (prButton) {
expect(prButton.nativeElement.disabled).toBe(true);
}
}
});
it('should format PR status correctly', () => {
if (typeof (component as any).formatPrStatus === 'function') {
expect((component as any).formatPrStatus('open')).toBe('Open');
expect((component as any).formatPrStatus('merged')).toBe('Merged');
expect((component as any).formatPrStatus('closed')).toBe('Closed');
expect((component as any).formatPrStatus('draft')).toBe('Draft');
}
});
it('should format CI status correctly', () => {
if (typeof (component as any).formatCiStatus === 'function') {
expect((component as any).formatCiStatus('pending')).toBe('Pending');
expect((component as any).formatCiStatus('running')).toBe('Running');
expect((component as any).formatCiStatus('success')).toBe('Success');
expect((component as any).formatCiStatus('failure')).toBe('Failure');
}
});
it('should copy PR URL to clipboard', fakeAsync(() => {
if (typeof (component as any).copyPrUrl === 'function') {
const writeTextSpy = spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
(component as any).copyPrUrl('https://github.com/org/repo/pull/42');
tick();
expect(writeTextSpy).toHaveBeenCalledWith('https://github.com/org/repo/pull/42');
}
}));
it('should format PR error codes', () => {
if (typeof (component as any).formatPrErrorCode === 'function') {
expect((component as any).formatPrErrorCode('NO_SCM_CONNECTION')).toContain('SCM');
expect((component as any).formatPrErrorCode('BRANCH_EXISTS')).toContain('branch');
expect((component as any).formatPrErrorCode('RATE_LIMITED')).toContain('rate');
}
});
});
});

View File

@@ -1,6 +1,8 @@
/**
* AI Remediate Panel component.
* Implements VEX-AI-008: AI remediation guidance panel.
* Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring
* Tasks: REMPR-FE-002, REMPR-FE-003
*/
import { CommonModule } from '@angular/common';
@@ -17,7 +19,15 @@ import {
import { firstValueFrom } from 'rxjs';
import { ADVISORY_AI_API, AdvisoryAiApi } from '../../core/api/advisory-ai.client';
import { AiRemediateRequest, AiRemediateResponse, AiRemediationStep } from '../../core/api/advisory-ai.models';
import {
AiRemediateRequest,
AiRemediateResponse,
AiRemediationStep,
RemediationPrCreateRequest,
RemediationPrCreateResponse,
RemediationPrInfo,
ScmConnectionInfo,
} from '../../core/api/advisory-ai.models';
@Component({
selector: 'app-ai-remediate-panel',
@@ -171,6 +181,82 @@ import { AiRemediateRequest, AiRemediateResponse, AiRemediationStep } from '../.
Generated: {{ remediation()!.generatedAt | date:'medium' }}
</span>
</div>
<!-- PR Section - Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring -->
<section class="pr-section">
<h3>Create Pull Request</h3>
@if (remediation()!.activePr) {
<!-- Active PR display -->
<div class="active-pr-card">
<div class="pr-status-badge" [class]="'pr-status--' + remediation()!.activePr!.status">
{{ formatPrStatus(remediation()!.activePr!.status) }}
</div>
<div class="pr-info">
<a class="pr-link" [href]="remediation()!.activePr!.prUrl" target="_blank" rel="noopener">
PR #{{ remediation()!.activePr!.prNumber }}
</a>
<span class="pr-branch">{{ remediation()!.activePr!.branch }}</span>
</div>
@if (remediation()!.activePr!.ciStatus) {
<div class="ci-status" [class]="'ci-status--' + remediation()!.activePr!.ciStatus">
{{ formatCiStatus(remediation()!.activePr!.ciStatus) }}
</div>
}
<button class="btn-copy-link" (click)="copyPrUrl(remediation()!.activePr!.prUrl)" title="Copy PR URL">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/>
</svg>
</button>
</div>
} @else if (prCreationAvailable()) {
<!-- PR creation available -->
@if (prCreating()) {
<div class="pr-creating-state">
<div class="pr-spinner"></div>
<span>Creating pull request...</span>
</div>
} @else if (prError()) {
<div class="pr-error-state">
<span class="pr-error-icon">[!]</span>
<span class="pr-error-msg">{{ prError() }}</span>
<button class="btn-retry" (click)="createPr()">Retry</button>
</div>
} @else {
<!-- SCM connection selector -->
@if (scmConnections().length > 0) {
<div class="scm-selector">
<label class="scm-label">SCM Connection:</label>
<select class="scm-select" [value]="selectedScmConnection()" (change)="selectScmConnection($event)">
@for (conn of scmConnections(); track conn.id) {
<option [value]="conn.id">{{ conn.name }} ({{ conn.provider }})</option>
}
</select>
</div>
<button class="btn btn--primary btn--pr" (click)="createPr()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 4v16m8-8H4"/>
</svg>
Open PR
</button>
} @else {
<div class="no-scm-state">
<span class="no-scm-icon">[--]</span>
<span>No SCM connections configured.</span>
<a class="integrations-link" routerLink="/integrations" (click)="close()">
Configure in Integrations Hub
</a>
</div>
}
}
} @else {
<!-- PR creation not available -->
<div class="pr-unavailable-state">
<span class="pr-unavailable-icon">[--]</span>
<span>PR creation not available for this remediation.</span>
</div>
}
</section>
</div>
} @else if (error()) {
<div class="error-state">
@@ -741,6 +827,190 @@ import { AiRemediateRequest, AiRemediateResponse, AiRemediationStep } from '../.
.btn--ghost:hover {
color: #e2e8f0;
}
/* PR Section - Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring */
.pr-section {
margin-top: 1.5rem;
padding: 1rem;
background: #1e293b;
border-radius: 10px;
}
.pr-section h3 {
margin: 0 0 1rem;
font-size: 0.875rem;
font-weight: 600;
color: #94a3b8;
}
.active-pr-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: #0f172a;
border-radius: 8px;
}
.pr-status-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
}
.pr-status--open, .pr-status--review_requested { background: #14532d; color: #4ade80; }
.pr-status--draft { background: #1e293b; color: #94a3b8; }
.pr-status--approved { background: #1e3a5f; color: #60a5fa; }
.pr-status--changes_requested { background: #422006; color: #fbbf24; }
.pr-status--merged { background: #4c1d95; color: #c4b5fd; }
.pr-status--closed { background: #7f1d1d; color: #fca5a5; }
.pr-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.pr-link {
color: #60a5fa;
text-decoration: none;
font-weight: 500;
}
.pr-link:hover { text-decoration: underline; }
.pr-branch {
font-family: ui-monospace, monospace;
font-size: 0.75rem;
color: #64748b;
}
.ci-status {
font-size: 0.6875rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.ci-status--pending, .ci-status--running { background: #422006; color: #fbbf24; }
.ci-status--success { background: #14532d; color: #4ade80; }
.ci-status--failure { background: #7f1d1d; color: #fca5a5; }
.ci-status--skipped { background: #1e293b; color: #94a3b8; }
.btn-copy-link {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 6px;
color: #64748b;
cursor: pointer;
}
.btn-copy-link:hover { background: #334155; color: #e2e8f0; }
.btn-copy-link svg { width: 16px; height: 16px; }
.pr-creating-state {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
color: #94a3b8;
}
.pr-spinner {
width: 20px;
height: 20px;
border: 2px solid #334155;
border-top-color: #60a5fa;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.pr-error-state {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 6px;
}
.pr-error-icon { color: #ef4444; font-family: monospace; }
.pr-error-msg { flex: 1; color: #fca5a5; font-size: 0.8125rem; }
.btn-retry {
padding: 0.375rem 0.75rem;
background: #334155;
border: none;
border-radius: 4px;
color: #e2e8f0;
font-size: 0.75rem;
cursor: pointer;
}
.btn-retry:hover { background: #475569; }
.scm-selector {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.scm-label {
font-size: 0.8125rem;
color: #94a3b8;
}
.scm-select {
flex: 1;
padding: 0.5rem 0.75rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
color: #e2e8f0;
font-size: 0.8125rem;
}
.btn--pr {
width: 100%;
justify-content: center;
}
.no-scm-state, .pr-unavailable-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem;
color: #64748b;
text-align: center;
font-size: 0.8125rem;
}
.no-scm-icon, .pr-unavailable-icon {
font-family: monospace;
color: #475569;
}
.integrations-link {
color: #60a5fa;
text-decoration: none;
font-weight: 500;
}
.integrations-link:hover { text-decoration: underline; }
`],
})
export class AiRemediatePanelComponent implements OnChanges {
@@ -762,9 +1032,21 @@ export class AiRemediatePanelComponent implements OnChanges {
readonly remediation = signal<AiRemediateResponse | null>(null);
readonly expandedStep = signal<number | null>(0);
// PR creation state (Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring)
readonly prCreating = signal(false);
readonly prError = signal<string | null>(null);
readonly scmConnections = signal<ScmConnectionInfo[]>([]);
readonly selectedScmConnection = signal<string | null>(null);
readonly prCreationAvailable = () => {
const rem = this.remediation();
return rem?.prCreationAvailable ?? false;
};
ngOnChanges(changes: SimpleChanges): void {
if (changes['visible'] && this.visible() && this.cveId()) {
this.requestRemediation();
this.loadScmConnections();
}
}
@@ -865,4 +1147,107 @@ ${rem.migrationGuideUrl ? `## Migration Guide\n\n${rem.migrationGuideUrl}` : ''}
};
return labels[effort] || effort;
}
// PR creation methods (Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring)
async loadScmConnections(): Promise<void> {
try {
const connections = await firstValueFrom(this.advisoryAiApi.getScmConnections());
this.scmConnections.set(connections);
if (connections.length > 0 && !this.selectedScmConnection()) {
this.selectedScmConnection.set(connections[0].id);
}
} catch {
// SCM connections not available - will show "configure" message
this.scmConnections.set([]);
}
}
selectScmConnection(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedScmConnection.set(select.value);
}
async createPr(): Promise<void> {
const rem = this.remediation();
const scmId = this.selectedScmConnection();
if (!rem || !scmId) return;
this.prCreating.set(true);
this.prError.set(null);
const request: RemediationPrCreateRequest = {
remediationId: rem.remediationId || rem.cveId,
scmConnectionId: scmId,
repository: '', // Will be determined from context
attachEvidenceCard: true,
addPrComment: true,
};
try {
const response = await firstValueFrom(this.advisoryAiApi.createRemediationPr(request));
if (response.success && response.prInfo) {
// Update remediation with active PR
this.remediation.set({
...rem,
activePr: response.prInfo,
evidenceCardId: response.evidenceCardId,
});
} else {
this.prError.set(this.formatPrErrorCode(response.errorCode) || response.error || 'Failed to create PR');
}
} catch (err) {
this.prError.set(err instanceof Error ? err.message : 'Failed to create PR');
} finally {
this.prCreating.set(false);
}
}
async copyPrUrl(url: string): Promise<void> {
try {
await navigator.clipboard.writeText(url);
} catch {
// Clipboard API not available
}
}
formatPrStatus(status: string): string {
const labels: Record<string, string> = {
draft: 'Draft',
open: 'Open',
review_requested: 'Review Requested',
approved: 'Approved',
changes_requested: 'Changes Requested',
merged: 'Merged',
closed: 'Closed',
};
return labels[status] || status;
}
formatCiStatus(status: string): string {
const labels: Record<string, string> = {
pending: 'CI Pending',
running: 'CI Running',
success: 'CI Passed',
failure: 'CI Failed',
skipped: 'CI Skipped',
};
return labels[status] || status;
}
formatPrErrorCode(code?: string): string | null {
if (!code) return null;
const messages: Record<string, string> = {
no_scm_connection: 'No SCM connection available',
scm_auth_failed: 'SCM authentication failed',
repository_not_found: 'Repository not found',
branch_conflict: 'Branch already exists',
rate_limited: 'Rate limit exceeded, try again later',
remediation_expired: 'Remediation guidance expired',
pr_already_exists: 'PR already exists for this remediation',
insufficient_permissions: 'Insufficient permissions to create PR',
internal_error: 'Internal error occurred',
};
return messages[code] || null;
}
}

View File

@@ -32,7 +32,7 @@ import { ReachabilityWhyDrawerComponent } from '../reachability/reachability-why
import { WitnessModalComponent } from '../../shared/components/witness-modal.component';
import { ConfidenceTierBadgeComponent } from '../../shared/components/confidence-tier-badge.component';
import { ReachabilityWitness, ConfidenceTier } from '../../core/api/witness.models';
import { WitnessMockClient } from '../../core/api/witness.client';
import { WITNESS_API, WitnessApi } from '../../core/api/witness.client';
// UI Component Library imports
import {
@@ -117,7 +117,7 @@ const SEVERITY_ORDER: Record<VulnerabilitySeverity, number> = {
})
export class VulnerabilityExplorerComponent implements OnInit {
private readonly api = inject<VulnerabilityApi>(VULNERABILITY_API);
private readonly witnessClient = inject(WitnessMockClient);
private readonly witnessClient = inject<WitnessApi>(WITNESS_API);
// Template references for DataTable custom columns
@ViewChild('severityTpl') severityTpl!: TemplateRef<{ row: Vulnerability }>;

View File

@@ -58,6 +58,18 @@ $badge-speculative-bg: #F59E0B; // amber-500
$badge-speculative-text: #000000;
$badge-speculative-light: #FEF3C7; // amber-100
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-003)
// Anchored badge - Score has DSSE/Rekor attestation anchor
$badge-anchored-bg: #7C3AED; // violet-600
$badge-anchored-text: #FFFFFF;
$badge-anchored-light: #EDE9FE; // violet-100
// Hard-fail badge - Policy hard-fail triggered
$badge-hard-fail-bg: #DC2626; // red-600
$badge-hard-fail-text: #FFFFFF;
$badge-hard-fail-light: #FEE2E2; // red-100
$badge-hard-fail-border: #B91C1C; // red-700 (for emphasis)
// =============================================================================
// Dimension Bar Colors
// =============================================================================
@@ -128,6 +140,9 @@ $z-toast: 1200;
--ews-badge-proven-path: #{$badge-proven-path-bg};
--ews-badge-vendor-na: #{$badge-vendor-na-bg};
--ews-badge-speculative: #{$badge-speculative-bg};
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-003)
--ews-badge-anchored: #{$badge-anchored-bg};
--ews-badge-hard-fail: #{$badge-hard-fail-bg};
// Chart colors
--ews-chart-line: #{$chart-line};

View File

@@ -2,6 +2,8 @@
class="score-badge"
[class]="sizeClasses()"
[class.pulse]="shouldPulse()"
[class.alert]="shouldAlert()"
[class.anchored-glow]="shouldGlow()"
[class.icon-only]="!showLabel()"
[style.backgroundColor]="displayInfo().backgroundColor"
[style.color]="displayInfo().textColor"

View File

@@ -84,6 +84,26 @@
}
}
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-003)
// Alert animation for hard-fail badges
.alert {
animation: alert-pulse 1.5s ease-in-out infinite;
}
@keyframes alert-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.4);
}
50% {
box-shadow: 0 0 0 4px rgba(220, 38, 38, 0);
}
}
// Anchor indicator glow for anchored badges
.anchored-glow {
box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.3);
}
// High contrast mode
@media (prefers-contrast: high) {
.score-badge {

View File

@@ -202,4 +202,126 @@ describe('ScoreBadgeComponent', () => {
expect(icon.getAttribute('aria-hidden')).toBe('true');
});
});
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-005)
describe('anchored badge', () => {
beforeEach(() => {
fixture.componentRef.setInput('type', 'anchored' as ScoreFlag);
fixture.detectChanges();
});
it('should display Anchored label', () => {
const label = fixture.nativeElement.querySelector('.badge-label');
expect(label.textContent.trim()).toBe('Anchored');
});
it('should have violet background', () => {
expect(component.displayInfo().backgroundColor).toBe('#7C3AED');
});
it('should have white text', () => {
expect(component.displayInfo().textColor).toBe('#FFFFFF');
});
it('should not have pulse animation', () => {
expect(component.shouldPulse()).toBe(false);
});
it('should have glow effect', () => {
expect(component.shouldGlow()).toBe(true);
const badge = fixture.nativeElement.querySelector('.score-badge');
expect(badge.classList.contains('anchored-glow')).toBe(true);
});
it('should not have alert animation', () => {
expect(component.shouldAlert()).toBe(false);
});
it('should have anchor icon [A]', () => {
const icon = fixture.nativeElement.querySelector('.badge-icon');
expect(icon.textContent).toBe('[A]');
});
it('should have description about attestation', () => {
expect(component.displayInfo().description).toContain('DSSE');
});
});
describe('hard-fail badge', () => {
beforeEach(() => {
fixture.componentRef.setInput('type', 'hard-fail' as ScoreFlag);
fixture.detectChanges();
});
it('should display Hard Fail label', () => {
const label = fixture.nativeElement.querySelector('.badge-label');
expect(label.textContent.trim()).toBe('Hard Fail');
});
it('should have red background', () => {
expect(component.displayInfo().backgroundColor).toBe('#DC2626');
});
it('should have white text', () => {
expect(component.displayInfo().textColor).toBe('#FFFFFF');
});
it('should not have pulse animation', () => {
expect(component.shouldPulse()).toBe(false);
});
it('should have alert animation', () => {
expect(component.shouldAlert()).toBe(true);
const badge = fixture.nativeElement.querySelector('.score-badge');
expect(badge.classList.contains('alert')).toBe(true);
});
it('should not have glow effect', () => {
expect(component.shouldGlow()).toBe(false);
});
it('should have exclamation icon [!]', () => {
const icon = fixture.nativeElement.querySelector('.badge-icon');
expect(icon.textContent).toBe('[!]');
});
it('should have description about immediate remediation', () => {
expect(component.displayInfo().description).toContain('immediate');
});
});
describe('edge cases', () => {
it('should handle missing anchor gracefully', () => {
// Verify anchored badge works even when proofAnchor is undefined
fixture.componentRef.setInput('type', 'anchored' as ScoreFlag);
fixture.detectChanges();
expect(component.displayInfo()).toBeTruthy();
});
it('should handle short-circuit reason display', () => {
// hard-fail badge should still display correctly
fixture.componentRef.setInput('type', 'hard-fail' as ScoreFlag);
fixture.detectChanges();
expect(component.displayInfo().label).toBe('Hard Fail');
});
it('should handle all badge types without errors', () => {
const allTypes: ScoreFlag[] = [
'live-signal',
'proven-path',
'vendor-na',
'speculative',
'anchored',
'hard-fail',
];
for (const type of allTypes) {
fixture.componentRef.setInput('type', type);
fixture.detectChanges();
expect(component.displayInfo()).toBeTruthy();
expect(component.displayInfo().label).toBeTruthy();
expect(component.displayInfo().backgroundColor).toBeTruthy();
}
});
});
});

View File

@@ -20,9 +20,13 @@ export type ScoreBadgeSize = 'sm' | 'md';
* - **Proven Path** (blue with checkmark): Verified reachability path
* - **Vendor N/A** (gray with strikethrough): Vendor marked not affected
* - **Speculative** (orange with question): Unconfirmed evidence
* - **Anchored** (violet with anchor): Score has DSSE/Rekor attestation anchor
* - **Hard Fail** (red with alert): Policy hard-fail triggered
*
* @example
* <stella-score-badge type="live-signal" size="md" />
* <stella-score-badge type="anchored" size="sm" />
* <stella-score-badge type="hard-fail" size="md" />
*/
@Component({
selector: 'stella-score-badge',
@@ -69,4 +73,15 @@ export class ScoreBadgeComponent {
readonly shouldPulse = computed(() => {
return this.type() === 'live-signal';
});
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-003)
/** Whether this badge type should show alert animation (hard-fail) */
readonly shouldAlert = computed(() => {
return this.type() === 'hard-fail';
});
/** Whether this badge type should show anchored glow (anchored) */
readonly shouldGlow = computed(() => {
return this.type() === 'anchored';
});
}

View File

@@ -263,4 +263,248 @@ describe('ScoreBreakdownPopoverComponent', () => {
expect(component.getBarWidth(1)).toBe('100%');
});
});
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-005)
describe('reduction profile', () => {
it('should display reduction profile when present', () => {
fixture.componentRef.setInput('scoreResult', {
...mockScoreResult,
reductionProfile: {
mode: 'standard',
originalScore: 85,
reductionAmount: 7,
reductionFactor: 0.08,
contributingEvidence: ['vex', 'backport'],
cappedByPolicy: false,
},
});
fixture.detectChanges();
const reductionSection = fixture.nativeElement.querySelector('.reduction-section');
expect(reductionSection).toBeTruthy();
});
it('should show reduction mode label', () => {
fixture.componentRef.setInput('scoreResult', {
...mockScoreResult,
reductionProfile: {
mode: 'aggressive',
originalScore: 90,
reductionAmount: 12,
reductionFactor: 0.13,
contributingEvidence: ['vex'],
cappedByPolicy: true,
},
});
fixture.detectChanges();
const modeLabel = fixture.nativeElement.querySelector('.reduction-mode');
expect(modeLabel?.textContent).toContain('Aggressive');
});
it('should not display reduction section when profile is null', () => {
fixture.componentRef.setInput('scoreResult', {
...mockScoreResult,
reductionProfile: undefined,
});
fixture.detectChanges();
const reductionSection = fixture.nativeElement.querySelector('.reduction-section');
expect(reductionSection).toBeNull();
});
});
describe('hard-fail status', () => {
it('should display hard-fail warning when isHardFail is true', () => {
fixture.componentRef.setInput('scoreResult', {
...mockScoreResult,
isHardFail: true,
hardFailStatus: 'kev',
flags: ['hard-fail', ...mockScoreResult.flags],
});
fixture.detectChanges();
const hardFailSection = fixture.nativeElement.querySelector('.hard-fail-section');
expect(hardFailSection).toBeTruthy();
});
it('should show correct hard-fail reason', () => {
fixture.componentRef.setInput('scoreResult', {
...mockScoreResult,
isHardFail: true,
hardFailStatus: 'exploited',
flags: ['hard-fail'],
});
fixture.detectChanges();
const hardFailReason = fixture.nativeElement.querySelector('.hard-fail-reason');
expect(hardFailReason?.textContent).toBeTruthy();
});
it('should not display hard-fail section when isHardFail is false', () => {
fixture.componentRef.setInput('scoreResult', {
...mockScoreResult,
isHardFail: false,
});
fixture.detectChanges();
const hardFailSection = fixture.nativeElement.querySelector('.hard-fail-section');
expect(hardFailSection).toBeNull();
});
});
describe('short-circuit reason', () => {
it('should display short-circuit info when present', () => {
fixture.componentRef.setInput('scoreResult', {
...mockScoreResult,
shortCircuitReason: 'not_affected_vendor',
});
fixture.detectChanges();
const shortCircuitSection = fixture.nativeElement.querySelector('.short-circuit-section');
expect(shortCircuitSection).toBeTruthy();
});
it('should not display short-circuit section when reason is none', () => {
fixture.componentRef.setInput('scoreResult', {
...mockScoreResult,
shortCircuitReason: 'none',
});
fixture.detectChanges();
const shortCircuitSection = fixture.nativeElement.querySelector('.short-circuit-section');
expect(shortCircuitSection).toBeNull();
});
});
describe('proof anchor', () => {
it('should display anchor info when anchored', () => {
fixture.componentRef.setInput('scoreResult', {
...mockScoreResult,
proofAnchor: {
anchored: true,
dsseDigest: 'sha256:abcd1234',
rekorLogIndex: 12345,
rekorEntryId: 'abc123',
verificationStatus: 'verified',
},
flags: ['anchored', ...mockScoreResult.flags],
});
fixture.detectChanges();
const anchorSection = fixture.nativeElement.querySelector('.anchor-section');
expect(anchorSection).toBeTruthy();
});
it('should show DSSE digest when present', () => {
fixture.componentRef.setInput('scoreResult', {
...mockScoreResult,
proofAnchor: {
anchored: true,
dsseDigest: 'sha256:abcdef123456',
verificationStatus: 'verified',
},
flags: ['anchored'],
});
fixture.detectChanges();
const dsseDigest = fixture.nativeElement.querySelector('.dsse-digest');
expect(dsseDigest?.textContent).toContain('sha256:abcdef');
});
it('should show Rekor log index when present', () => {
fixture.componentRef.setInput('scoreResult', {
...mockScoreResult,
proofAnchor: {
anchored: true,
rekorLogIndex: 99999,
verificationStatus: 'verified',
},
flags: ['anchored'],
});
fixture.detectChanges();
const rekorIndex = fixture.nativeElement.querySelector('.rekor-index');
expect(rekorIndex?.textContent).toContain('99999');
});
it('should not display anchor section when not anchored', () => {
fixture.componentRef.setInput('scoreResult', {
...mockScoreResult,
proofAnchor: undefined,
});
fixture.detectChanges();
const anchorSection = fixture.nativeElement.querySelector('.anchor-section');
expect(anchorSection).toBeNull();
});
it('should show verification status', () => {
fixture.componentRef.setInput('scoreResult', {
...mockScoreResult,
proofAnchor: {
anchored: true,
verificationStatus: 'pending',
},
flags: ['anchored'],
});
fixture.detectChanges();
const verificationStatus = fixture.nativeElement.querySelector('.verification-status');
expect(verificationStatus?.textContent?.toLowerCase()).toContain('pending');
});
it('should handle missing anchors gracefully', () => {
fixture.componentRef.setInput('scoreResult', {
...mockScoreResult,
proofAnchor: {
anchored: false,
},
});
fixture.detectChanges();
expect(component).toBeTruthy();
});
});
describe('edge cases', () => {
it('should handle score with all new fields populated', () => {
fixture.componentRef.setInput('scoreResult', {
...mockScoreResult,
reductionProfile: {
mode: 'standard',
originalScore: 85,
reductionAmount: 7,
reductionFactor: 0.08,
contributingEvidence: ['vex'],
cappedByPolicy: false,
},
shortCircuitReason: 'anchor_verified',
isHardFail: false,
proofAnchor: {
anchored: true,
dsseDigest: 'sha256:test',
verificationStatus: 'verified',
},
flags: ['anchored', 'proven-path'],
});
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should handle score with no optional fields', () => {
fixture.componentRef.setInput('scoreResult', {
...mockScoreResult,
reductionProfile: undefined,
shortCircuitReason: undefined,
hardFailStatus: undefined,
isHardFail: undefined,
proofAnchor: undefined,
});
fixture.detectChanges();
expect(component).toBeTruthy();
});
});
});

View File

@@ -1,6 +1,7 @@
/**
* Witness Modal Component Tests.
* Sprint: SPRINT_3700_0005_0001_witness_ui_cli (TEST-001)
* Updated: SPRINT_20260112_013_FE_witness_ui_wiring (FE-WIT-001)
*
* Unit tests for the witness modal component.
*/
@@ -15,12 +16,12 @@ import {
ConfidenceTier,
WitnessVerificationResult,
} from '../../core/api/witness.models';
import { WitnessMockClient } from '../../core/api/witness.client';
import { WITNESS_API, WitnessApi } from '../../core/api/witness.client';
describe('WitnessModalComponent', () => {
let component: WitnessModalComponent;
let fixture: ComponentFixture<WitnessModalComponent>;
let mockWitnessClient: jasmine.SpyObj<WitnessMockClient>;
let mockWitnessClient: jasmine.SpyObj<WitnessApi>;
const mockWitness: ReachabilityWitness = {
witnessId: 'witness-001',
@@ -76,17 +77,18 @@ describe('WitnessModalComponent', () => {
};
beforeEach(async () => {
mockWitnessClient = jasmine.createSpyObj('WitnessMockClient', [
mockWitnessClient = jasmine.createSpyObj('WitnessApi', [
'verifyWitness',
'getWitness',
'getWitnessesForVuln',
'listWitnesses',
'downloadWitnessJson',
'exportSarif',
]);
await TestBed.configureTestingModule({
imports: [WitnessModalComponent],
providers: [{ provide: WitnessMockClient, useValue: mockWitnessClient }],
providers: [{ provide: WITNESS_API, useValue: mockWitnessClient }],
}).compileComponents();
fixture = TestBed.createComponent(WitnessModalComponent);

View File

@@ -1,6 +1,7 @@
/**
* Witness Modal Component.
* Sprint: SPRINT_3700_0005_0001_witness_ui_cli (UI-001)
* Updated: SPRINT_20260112_013_FE_witness_ui_wiring (FE-WIT-001)
*
* Modal dialog for viewing reachability witness details.
*/
@@ -10,7 +11,7 @@ import { CommonModule } from '@angular/common';
import { firstValueFrom } from 'rxjs';
import { ReachabilityWitness, WitnessVerificationResult } from '../../core/api/witness.models';
import { WitnessMockClient } from '../../core/api/witness.client';
import { WITNESS_API, WitnessApi } from '../../core/api/witness.client';
import { ConfidenceTierBadgeComponent } from './confidence-tier-badge.component';
import { PathVisualizationComponent, PathVisualizationData } from './path-visualization.component';
@@ -88,6 +89,20 @@ import { PathVisualizationComponent, PathVisualizationData } from './path-visual
<span class="witness-modal__evidence-label">Surface:</span>
<code class="witness-modal__evidence-value">{{ witness()!.evidence.surfaceHash }}</code>
</div>
<!-- Path Hash (FE-WIT-003) -->
<div class="witness-modal__evidence-row" *ngIf="witness()!.pathHash">
<span class="witness-modal__evidence-label">Path hash:</span>
<code class="witness-modal__evidence-value witness-modal__evidence-value--hash">{{ witness()!.pathHash }}</code>
</div>
<!-- Node Hashes (FE-WIT-003) -->
<div class="witness-modal__evidence-row witness-modal__evidence-row--column" *ngIf="witness()!.nodeHashes?.length">
<span class="witness-modal__evidence-label">Node hashes ({{ witness()!.nodeHashes!.length }}):</span>
<div class="witness-modal__evidence-hash-list">
<code class="witness-modal__evidence-hash" *ngFor="let hash of witness()!.nodeHashes!; let i = index">
{{ i + 1 }}. {{ hash }}
</code>
</div>
</div>
<div class="witness-modal__evidence-row">
<span class="witness-modal__evidence-label">Observed:</span>
<span class="witness-modal__evidence-value">{{ formatDate(witness()!.observedAt) }}</span>
@@ -96,9 +111,57 @@ import { PathVisualizationComponent, PathVisualizationData } from './path-visual
<span class="witness-modal__evidence-label">Signed by:</span>
<span class="witness-modal__evidence-value">{{ witness()!.signature!.keyId }}</span>
</div>
<!-- Evidence URIs (FE-WIT-003) -->
<div class="witness-modal__evidence-row" *ngIf="witness()!.evidence.dsseUri">
<span class="witness-modal__evidence-label">DSSE:</span>
<a class="witness-modal__evidence-link" [href]="witness()!.evidence.dsseUri" target="_blank" rel="noopener">
{{ truncateUri(witness()!.evidence.dsseUri!) }}
</a>
</div>
<div class="witness-modal__evidence-row" *ngIf="witness()!.evidence.rekorUri">
<span class="witness-modal__evidence-label">Rekor:</span>
<a class="witness-modal__evidence-link" [href]="witness()!.evidence.rekorUri" target="_blank" rel="noopener">
{{ truncateUri(witness()!.evidence.rekorUri!) }}
</a>
</div>
</div>
</section>
<!-- Runtime Evidence Section (FE-WIT-003) -->
<section class="witness-modal__section" *ngIf="witness()!.runtimeEvidence?.available">
<h3 class="witness-modal__section-title">
Runtime Evidence
<span class="witness-modal__badge witness-modal__badge--runtime">RUNTIME CONFIRMED</span>
</h3>
<div class="witness-modal__evidence">
<div class="witness-modal__evidence-row">
<span class="witness-modal__evidence-label">Source:</span>
<span class="witness-modal__evidence-value">{{ witness()!.runtimeEvidence!.source }}</span>
</div>
<div class="witness-modal__evidence-row" *ngIf="witness()!.runtimeEvidence!.lastObservedAt">
<span class="witness-modal__evidence-label">Last observed:</span>
<span class="witness-modal__evidence-value">{{ formatDate(witness()!.runtimeEvidence!.lastObservedAt!) }}</span>
</div>
<div class="witness-modal__evidence-row" *ngIf="witness()!.runtimeEvidence!.invocationCount">
<span class="witness-modal__evidence-label">Invocations:</span>
<span class="witness-modal__evidence-value">{{ witness()!.runtimeEvidence!.invocationCount }}</span>
</div>
<div class="witness-modal__evidence-row" *ngIf="witness()!.runtimeEvidence!.confirmsStatic !== undefined">
<span class="witness-modal__evidence-label">Confirms static:</span>
<span class="witness-modal__evidence-value" [class.witness-modal__evidence-value--confirmed]="witness()!.runtimeEvidence!.confirmsStatic">
{{ witness()!.runtimeEvidence!.confirmsStatic ? 'Yes' : 'No' }}
</span>
</div>
<div class="witness-modal__evidence-row" *ngIf="witness()!.runtimeEvidence!.traceUri">
<span class="witness-modal__evidence-label">Trace:</span>
<a class="witness-modal__evidence-link" [href]="witness()!.runtimeEvidence!.traceUri" target="_blank" rel="noopener">
View trace
</a>
</div>
</div>
</section>
</section>
<!-- Signature Section -->
<section class="witness-modal__section" *ngIf="witness()!.signature">
<h3 class="witness-modal__section-title">Signature</h3>
@@ -416,10 +479,72 @@ import { PathVisualizationComponent, PathVisualizationData } from './path-visual
background: var(--surface-secondary, #f8f9fa);
}
}
/* FE-WIT-003: Node hash and path hash styles */
.witness-modal__evidence-row--column {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.witness-modal__evidence-hash-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 150px;
overflow-y: auto;
width: 100%;
padding: 0.5rem;
background: var(--surface-secondary, #f8f9fa);
border-radius: 4px;
}
.witness-modal__evidence-hash {
font-size: 0.75rem;
font-family: var(--font-mono, 'Monaco', 'Consolas', monospace);
color: var(--text-secondary, #6c757d);
word-break: break-all;
}
.witness-modal__evidence-value--hash {
word-break: break-all;
}
.witness-modal__evidence-link {
color: var(--color-primary, #0066cc);
text-decoration: none;
font-size: 0.875rem;
&:hover {
text-decoration: underline;
}
}
.witness-modal__evidence-value--confirmed {
color: var(--color-success, #198754);
font-weight: 500;
}
.witness-modal__badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: 4px;
margin-left: 0.5rem;
}
.witness-modal__badge--runtime {
background: var(--color-success-bg, #d1e7dd);
color: var(--color-success, #198754);
}
`],
})
export class WitnessModalComponent {
private readonly witnessClient = inject(WitnessMockClient);
private readonly witnessClient = inject<WitnessApi>(WITNESS_API);
/** Whether the modal is open. */
isOpen = input<boolean>(false);
@@ -515,4 +640,26 @@ export class WitnessModalComponent {
formatDate(iso: string): string {
return new Date(iso).toLocaleString();
}
/**
* Truncates a URI for display, keeping the host and last path segment.
* FE-WIT-003
*/
truncateUri(uri: string): string {
if (!uri) return '';
try {
const url = new URL(uri);
const pathParts = url.pathname.split('/').filter(Boolean);
if (pathParts.length > 1) {
return `${url.host}/.../${pathParts[pathParts.length - 1]}`;
}
return `${url.host}${url.pathname}`;
} catch {
// Not a valid URL, truncate simply
if (uri.length > 50) {
return uri.slice(0, 25) + '...' + uri.slice(-22);
}
return uri;
}
}
}

View File

@@ -17,6 +17,8 @@ Each badge type represents a specific score characteristic:
- **Proven Path** (blue, checkmark): Verified reachability path to vulnerable code
- **Vendor N/A** (gray, strikethrough): Vendor has marked this vulnerability as not affected
- **Speculative** (orange, question mark): Evidence is speculative or unconfirmed
- **Anchored** (violet, anchor icon): Score is anchored with DSSE attestation and/or Rekor transparency log
- **Hard Fail** (red, exclamation): Policy hard-fail triggered - requires immediate remediation
Use these badges alongside score pills to provide additional context about evidence quality.
`,
@@ -26,7 +28,7 @@ Use these badges alongside score pills to provide additional context about evide
argTypes: {
type: {
control: { type: 'select' },
options: ['live-signal', 'proven-path', 'vendor-na', 'speculative'],
options: ['live-signal', 'proven-path', 'vendor-na', 'speculative', 'anchored', 'hard-fail'],
description: 'The flag type to display',
},
size: {
@@ -122,6 +124,38 @@ export const Speculative: Story = {
},
};
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-003)
// Anchored
export const Anchored: Story = {
args: {
type: 'anchored',
size: 'md',
},
parameters: {
docs: {
description: {
story: 'Indicates the score is anchored with DSSE attestation and/or Rekor transparency log entry. Provides cryptographic proof of score calculation.',
},
},
},
};
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-003)
// Hard Fail
export const HardFail: Story = {
args: {
type: 'hard-fail',
size: 'md',
},
parameters: {
docs: {
description: {
story: 'Indicates a policy hard-fail condition has been triggered. This finding requires immediate attention and remediation.',
},
},
},
};
// All types comparison
export const AllTypes: Story = {
render: () => ({
@@ -131,13 +165,15 @@ export const AllTypes: Story = {
<stella-score-badge type="proven-path" />
<stella-score-badge type="vendor-na" />
<stella-score-badge type="speculative" />
<stella-score-badge type="anchored" />
<stella-score-badge type="hard-fail" />
</div>
`,
}),
parameters: {
docs: {
description: {
story: 'All four badge types displayed together for comparison.',
story: 'All six badge types displayed together for comparison.',
},
},
},
@@ -154,6 +190,8 @@ export const SizeComparison: Story = {
<stella-score-badge type="proven-path" size="sm" />
<stella-score-badge type="vendor-na" size="sm" />
<stella-score-badge type="speculative" size="sm" />
<stella-score-badge type="anchored" size="sm" />
<stella-score-badge type="hard-fail" size="sm" />
</div>
<div style="display: flex; gap: 12px; align-items: center;">
<span style="width: 80px; font-size: 14px; color: #666;">Medium:</span>
@@ -161,6 +199,8 @@ export const SizeComparison: Story = {
<stella-score-badge type="proven-path" size="md" />
<stella-score-badge type="vendor-na" size="md" />
<stella-score-badge type="speculative" size="md" />
<stella-score-badge type="anchored" size="md" />
<stella-score-badge type="hard-fail" size="md" />
</div>
</div>
`,
@@ -183,6 +223,8 @@ export const IconOnly: Story = {
<stella-score-badge type="proven-path" [showLabel]="false" />
<stella-score-badge type="vendor-na" [showLabel]="false" />
<stella-score-badge type="speculative" [showLabel]="false" />
<stella-score-badge type="anchored" [showLabel]="false" />
<stella-score-badge type="hard-fail" [showLabel]="false" />
</div>
`,
}),
@@ -206,6 +248,8 @@ export const IconOnlySizes: Story = {
<stella-score-badge type="proven-path" size="sm" [showLabel]="false" />
<stella-score-badge type="vendor-na" size="sm" [showLabel]="false" />
<stella-score-badge type="speculative" size="sm" [showLabel]="false" />
<stella-score-badge type="anchored" size="sm" [showLabel]="false" />
<stella-score-badge type="hard-fail" size="sm" [showLabel]="false" />
</div>
<div style="display: flex; gap: 12px; align-items: center;">
<span style="width: 80px; font-size: 14px; color: #666;">Medium:</span>
@@ -213,6 +257,8 @@ export const IconOnlySizes: Story = {
<stella-score-badge type="proven-path" size="md" [showLabel]="false" />
<stella-score-badge type="vendor-na" size="md" [showLabel]="false" />
<stella-score-badge type="speculative" size="md" [showLabel]="false" />
<stella-score-badge type="anchored" size="md" [showLabel]="false" />
<stella-score-badge type="hard-fail" size="md" [showLabel]="false" />
</div>
</div>
`,
@@ -243,20 +289,22 @@ export const InTableContext: Story = {
<td style="padding: 12px;">CVE-2024-1234</td>
<td style="padding: 12px;">
<div style="display: flex; gap: 6px;">
<stella-score-badge type="hard-fail" size="sm" />
<stella-score-badge type="live-signal" size="sm" />
<stella-score-badge type="proven-path" size="sm" />
<stella-score-badge type="anchored" size="sm" />
</div>
</td>
<td style="padding: 12px;">Critical</td>
<td style="padding: 12px;">Critical - Immediate Action Required</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 12px;">CVE-2024-5678</td>
<td style="padding: 12px;">
<div style="display: flex; gap: 6px;">
<stella-score-badge type="proven-path" size="sm" />
<stella-score-badge type="anchored" size="sm" />
</div>
</td>
<td style="padding: 12px;">High</td>
<td style="padding: 12px;">High - Anchored Evidence</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 12px;">GHSA-abc123</td>
@@ -283,7 +331,7 @@ export const InTableContext: Story = {
parameters: {
docs: {
description: {
story: 'Score badges in a findings table context.',
story: 'Score badges in a findings table context, including anchored and hard-fail badges.',
},
},
},

View File

@@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2026 Stella Ops
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, Routes } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AiCodeGuardConsoleComponent } from './ai-code-guard-console/ai-code-guard-console.component';
import { AiCodeGuardBadgeComponent } from './ai-code-guard-badge/ai-code-guard-badge.component';
import { AiCodeGuardFindingDetailComponent } from './ai-code-guard-finding-detail/ai-code-guard-finding-detail.component';
const routes: Routes = [
{
path: '',
component: AiCodeGuardConsoleComponent
},
{
path: 'finding/:id',
component: AiCodeGuardFindingDetailComponent
}
];
@NgModule({
declarations: [
AiCodeGuardConsoleComponent,
AiCodeGuardBadgeComponent,
AiCodeGuardFindingDetailComponent
],
imports: [
CommonModule,
FormsModule,
RouterModule.forChild(routes)
],
exports: [
AiCodeGuardConsoleComponent,
AiCodeGuardBadgeComponent,
AiCodeGuardFindingDetailComponent
]
})
export class AiCodeGuardModule { }

View File

@@ -0,0 +1,251 @@
/**
* @file AI Code Guard Console Component
* @description Main console for reviewing AI-generated code findings
* @module Web/Features/AICodeGuard
* @license AGPL-3.0-or-later
*/
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';
import { AiCodeGuardService } from '../../services/ai-code-guard.service';
import { AiCodeGuardFinding, FindingSeverity, FindingStatus, FindingFilter } from '../../models/ai-code-guard.models';
@Component({
selector: 'app-ai-code-guard-console',
templateUrl: './ai-code-guard-console.component.html',
styleUrls: ['./ai-code-guard-console.component.scss']
})
export class AiCodeGuardConsoleComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
findings: AiCodeGuardFinding[] = [];
filteredFindings: AiCodeGuardFinding[] = [];
selectedFinding: AiCodeGuardFinding | null = null;
isLoading = false;
error: string | null = null;
// Filter state
filter: FindingFilter = {
severities: [],
statuses: [],
repositories: [],
searchTerm: ''
};
// Pagination
currentPage = 1;
pageSize = 25;
totalCount = 0;
// Statistics
stats = {
total: 0,
critical: 0,
high: 0,
medium: 0,
low: 0,
pending: 0,
approved: 0,
rejected: 0
};
// Filter options
readonly severityOptions = Object.values(FindingSeverity);
readonly statusOptions = Object.values(FindingStatus);
availableRepositories: string[] = [];
constructor(private aiCodeGuardService: AiCodeGuardService) {}
ngOnInit(): void {
this.loadFindings();
this.loadRepositories();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
loadFindings(): void {
this.isLoading = true;
this.error = null;
this.aiCodeGuardService.getFindings(this.filter, this.currentPage, this.pageSize)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response) => {
this.findings = response.items;
this.totalCount = response.totalCount;
this.applyFilters();
this.calculateStats();
this.isLoading = false;
},
error: (err) => {
this.error = err.message || 'Failed to load findings';
this.isLoading = false;
}
});
}
loadRepositories(): void {
this.aiCodeGuardService.getRepositories()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (repos) => {
this.availableRepositories = repos;
},
error: () => {
// Non-critical, ignore
}
});
}
applyFilters(): void {
let filtered = [...this.findings];
if (this.filter.severities && this.filter.severities.length > 0) {
filtered = filtered.filter(f => this.filter.severities!.includes(f.severity));
}
if (this.filter.statuses && this.filter.statuses.length > 0) {
filtered = filtered.filter(f => this.filter.statuses!.includes(f.status));
}
if (this.filter.repositories && this.filter.repositories.length > 0) {
filtered = filtered.filter(f => this.filter.repositories!.includes(f.repository));
}
if (this.filter.searchTerm) {
const term = this.filter.searchTerm.toLowerCase();
filtered = filtered.filter(f =>
f.filePath.toLowerCase().includes(term) ||
f.description.toLowerCase().includes(term) ||
f.ruleId.toLowerCase().includes(term)
);
}
this.filteredFindings = filtered;
}
calculateStats(): void {
this.stats = {
total: this.findings.length,
critical: this.findings.filter(f => f.severity === FindingSeverity.Critical).length,
high: this.findings.filter(f => f.severity === FindingSeverity.High).length,
medium: this.findings.filter(f => f.severity === FindingSeverity.Medium).length,
low: this.findings.filter(f => f.severity === FindingSeverity.Low).length,
pending: this.findings.filter(f => f.status === FindingStatus.Pending).length,
approved: this.findings.filter(f => f.status === FindingStatus.Approved).length,
rejected: this.findings.filter(f => f.status === FindingStatus.Rejected).length
};
}
onFilterChange(): void {
this.currentPage = 1;
this.loadFindings();
}
onSearchChange(searchTerm: string): void {
this.filter.searchTerm = searchTerm;
this.applyFilters();
}
onSeverityFilterChange(severities: FindingSeverity[]): void {
this.filter.severities = severities;
this.onFilterChange();
}
onStatusFilterChange(statuses: FindingStatus[]): void {
this.filter.statuses = statuses;
this.onFilterChange();
}
onRepositoryFilterChange(repositories: string[]): void {
this.filter.repositories = repositories;
this.onFilterChange();
}
onPageChange(page: number): void {
this.currentPage = page;
this.loadFindings();
}
selectFinding(finding: AiCodeGuardFinding): void {
this.selectedFinding = finding;
}
closeFindingDetail(): void {
this.selectedFinding = null;
}
approveFinding(finding: AiCodeGuardFinding): void {
this.aiCodeGuardService.updateFindingStatus(finding.id, FindingStatus.Approved)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
finding.status = FindingStatus.Approved;
this.calculateStats();
},
error: (err) => {
this.error = err.message || 'Failed to approve finding';
}
});
}
rejectFinding(finding: AiCodeGuardFinding): void {
this.aiCodeGuardService.updateFindingStatus(finding.id, FindingStatus.Rejected)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
finding.status = FindingStatus.Rejected;
this.calculateStats();
},
error: (err) => {
this.error = err.message || 'Failed to reject finding';
}
});
}
exportFindings(): void {
this.aiCodeGuardService.exportFindings(this.filter)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ai-code-guard-findings-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
},
error: (err) => {
this.error = err.message || 'Failed to export findings';
}
});
}
getSeverityClass(severity: FindingSeverity): string {
switch (severity) {
case FindingSeverity.Critical: return 'severity-critical';
case FindingSeverity.High: return 'severity-high';
case FindingSeverity.Medium: return 'severity-medium';
case FindingSeverity.Low: return 'severity-low';
default: return 'severity-info';
}
}
getStatusClass(status: FindingStatus): string {
switch (status) {
case FindingStatus.Pending: return 'status-pending';
case FindingStatus.Approved: return 'status-approved';
case FindingStatus.Rejected: return 'status-rejected';
case FindingStatus.Suppressed: return 'status-suppressed';
default: return '';
}
}
trackByFindingId(index: number, finding: AiCodeGuardFinding): string {
return finding.id;
}
}