sprints completion. new product advisories prepared
This commit is contained in:
@@ -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),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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()">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 { }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user