save checkpoint

This commit is contained in:
master
2026-02-11 01:32:14 +02:00
parent 5593212b41
commit cf5b72974f
2316 changed files with 68799 additions and 3808 deletions

View File

@@ -101,14 +101,13 @@
"setupFiles": ["src/test-setup.ts"],
"exclude": [
"**/*.e2e.spec.ts",
"src/app/core/api/vex-hub.client.spec.ts",
"src/app/core/services/*.spec.ts",
"src/app/features/**/*.spec.ts",
"src/app/shared/components/**/*.spec.ts",
"src/app/layout/**/*.spec.ts"
]
}
},
"src/app/core/api/vex-hub.client.spec.ts",
"src/app/core/services/*.spec.ts",
"src/app/features/**/*.spec.ts",
"src/app/shared/components/**/*.spec.ts"
]
}
},
"storybook": {
"builder": "@storybook/angular:start-storybook",
"options": {

View File

@@ -7,53 +7,57 @@
(dismissed)="onLegacyBannerDismissed()"
></app-legacy-url-banner>
}
<header class="app-header">
<a class="app-brand" routerLink="/">
<img class="app-brand__logo" src="assets/img/logo.png"
alt="Stella Ops" width="28" height="28" />
<span class="app-brand__text">Stella Ops</span>
</a>
@if (useShellLayout()) {
<app-shell></app-shell>
} @else {
<header class="app-header">
<a class="app-brand" routerLink="/">
<img class="app-brand__logo" src="assets/img/logo.png"
alt="Stella Ops" width="28" height="28" />
<span class="app-brand__text">Stella Ops</span>
</a>
<!-- Main Navigation (hidden on setup/auth pages) -->
@if (showNavigation()) {
<app-navigation-menu></app-navigation-menu>
}
<!-- Right side: Auth section -->
<div class="app-auth">
@if (isAuthenticated()) {
@if (freshAuthSummary(); as fresh) {
<span
class="app-fresh"
[class.app-fresh--active]="fresh.active"
[class.app-fresh--stale]="!fresh.active"
>
Fresh auth: {{ fresh.active ? 'Active' : 'Stale' }}
@if (fresh.expiresAt) {
(expires {{ fresh.expiresAt | date: 'shortTime' }})
}
</span>
}
@if (activeTenant(); as tenant) {
<span class="app-tenant">
{{ tenant }}
</span>
}
<app-user-menu></app-user-menu>
} @else if (showSignIn()) {
<button type="button" class="app-auth__signin" (click)="onSignIn()">Sign in</button>
<!-- Main Navigation (hidden on setup/auth pages) -->
@if (showNavigation()) {
<app-navigation-menu></app-navigation-menu>
}
</div>
</header>
<main class="app-content">
@if (showBreadcrumb()) {
<app-breadcrumb></app-breadcrumb>
}
<div class="page-container">
<router-outlet />
</div>
</main>
<!-- Right side: Auth section -->
<div class="app-auth">
@if (isAuthenticated()) {
@if (freshAuthSummary(); as fresh) {
<span
class="app-fresh"
[class.app-fresh--active]="fresh.active"
[class.app-fresh--stale]="!fresh.active"
>
Fresh auth: {{ fresh.active ? 'Active' : 'Stale' }}
@if (fresh.expiresAt) {
(expires {{ fresh.expiresAt | date: 'shortTime' }})
}
</span>
}
@if (activeTenant(); as tenant) {
<span class="app-tenant">
{{ tenant }}
</span>
}
<app-user-menu></app-user-menu>
} @else if (showSignIn()) {
<button type="button" class="app-auth__signin" (click)="onSignIn()">Sign in</button>
}
</div>
</header>
<main class="app-content">
@if (showBreadcrumb()) {
<app-breadcrumb></app-breadcrumb>
}
<div class="page-container">
<router-outlet />
</div>
</main>
}
</div>
<!-- Global Components -->

View File

@@ -5,12 +5,15 @@ import { of } from 'rxjs';
import { AppComponent } from './app.component';
import { AuthorityAuthService } from './core/auth/authority-auth.service';
import { AuthSession } from './core/auth/auth-session.model';
import { AuthSessionStore } from './core/auth/auth-session.store';
import { AUTH_SERVICE, AuthService } from './core/auth';
import { AUTH_SERVICE, MockAuthService } from './core/auth';
import { ConsoleSessionStore } from './core/console/console-session.store';
import { AppConfigService } from './core/config/app-config.service';
import { OfflineModeService } from './core/services/offline-mode.service';
import { PolicyPackStore } from './features/policy-studio/services/policy-pack.store';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { signal } from '@angular/core';
class AuthorityAuthServiceStub {
beginLogin = jasmine.createSpy('beginLogin');
@@ -18,6 +21,17 @@ class AuthorityAuthServiceStub {
trySilentRefresh = jasmine.createSpy('trySilentRefresh').and.returnValue(Promise.resolve(false));
}
class OfflineModeServiceStub {
readonly isOffline = signal(false);
readonly bundleFreshness = signal<{
status: 'fresh' | 'stale' | 'expired';
bundleCreatedAt: string;
ageInDays: number;
message: string;
} | null>(null);
readonly offlineBannerMessage = signal<string | null>(null);
}
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -26,7 +40,7 @@ describe('AppComponent', () => {
provideRouter([]),
AuthSessionStore,
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
{ provide: AUTH_SERVICE, useValue: { canViewPolicies: () => true, canAuthorPolicies: () => true, canSimulatePolicies: () => true, canReviewPolicies: () => true } as AuthService },
{ provide: AUTH_SERVICE, useClass: MockAuthService },
ConsoleSessionStore,
{ provide: AppConfigService, useValue: { config: { apiBaseUrls: { authority: '', policy: '' } }, configStatus: () => 'loaded', isConfigured: () => true } },
{
@@ -37,6 +51,7 @@ describe('AppComponent', () => {
]),
},
},
{ provide: OfflineModeService, useClass: OfflineModeServiceStub },
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
]
@@ -55,4 +70,52 @@ describe('AppComponent', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('router-outlet')).not.toBeNull();
});
it('renders app shell when session is authenticated', () => {
const sessionStore = TestBed.inject(AuthSessionStore);
sessionStore.setSession(buildSession());
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('app-shell')).not.toBeNull();
});
it('renders sidebar and context chips in authenticated shell', () => {
const sessionStore = TestBed.inject(AuthSessionStore);
sessionStore.setSession(buildSession());
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('app-sidebar')).not.toBeNull();
expect(compiled.querySelector('app-context-chips')).not.toBeNull();
});
});
function buildSession(): AuthSession {
return {
tokens: {
accessToken: 'token',
expiresAtEpochMs: Date.now() + 60_000,
tokenType: 'Bearer',
scope: 'ui.read orch:read',
},
identity: {
subject: 'user-1',
name: 'User One',
email: 'user.one@example.com',
roles: ['operator'],
},
dpopKeyThumbprint: 'thumbprint',
issuedAtEpochMs: Date.now(),
tenantId: 'tenant-1',
scopes: ['ui.read', 'orch:read'],
audiences: ['stellaops-web'],
authenticationTimeEpochMs: Date.now(),
freshAuthActive: true,
freshAuthExpiresAtEpochMs: Date.now() + 300_000,
};
}

View File

@@ -19,6 +19,7 @@ import { CommandPaletteComponent } from './shared/components/command-palette/com
import { ToastContainerComponent } from './shared/components/toast/toast-container.component';
import { BreadcrumbComponent } from './shared/components/breadcrumb/breadcrumb.component';
import { KeyboardShortcutsComponent } from './shared/components/keyboard-shortcuts/keyboard-shortcuts.component';
import { AppShellComponent } from './layout/app-shell/app-shell.component';
import { BrandingService } from './core/branding/branding.service';
import { LegacyRouteTelemetryService } from './core/guards/legacy-route-telemetry.service';
import { LegacyUrlBannerComponent } from './shared/ui/legacy-url-banner/legacy-url-banner.component';
@@ -31,6 +32,7 @@ import { LegacyUrlBannerComponent } from './shared/ui/legacy-url-banner/legacy-u
RouterLink,
NavigationMenuComponent,
UserMenuComponent,
AppShellComponent,
CommandPaletteComponent,
ToastContainerComponent,
BreadcrumbComponent,
@@ -42,6 +44,14 @@ import { LegacyUrlBannerComponent } from './shared/ui/legacy-url-banner/legacy-u
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
private static readonly SHELL_EXCLUDED_ROUTES = [
'/setup',
'/callback',
'/silent-refresh',
'/auth/callback',
'/auth/silent-refresh',
];
private readonly router = inject(Router);
private readonly auth = inject(AuthorityAuthService);
private readonly sessionStore = inject(AuthSessionStore);
@@ -106,9 +116,18 @@ export class AppComponent {
private readonly currentUrl = toSignal(this.currentUrl$, { initialValue: '/' });
readonly useShellLayout = computed(() => {
const url = this.currentUrl();
return this.isAuthenticated() && !this.isShellExcludedRoute(url);
});
readonly showBreadcrumb = computed(() => {
const url = this.currentUrl();
const hideRoutes = ['/', '/welcome', '/setup', '/callback', '/silent-refresh'];
const hideRoutes = [
'/',
'/welcome',
...AppComponent.SHELL_EXCLUDED_ROUTES,
];
if (hideRoutes.some(route => url === route || url.startsWith(route + '/'))) {
return false;
}
@@ -118,8 +137,7 @@ export class AppComponent {
/** Hide navigation on setup/auth pages and when not authenticated. */
readonly showNavigation = computed(() => {
const url = this.currentUrl();
const hideRoutes = ['/setup', '/callback', '/silent-refresh'];
if (hideRoutes.some(route => url === route || url.startsWith(route + '/'))) {
if (this.isShellExcludedRoute(url)) {
return false;
}
return this.isAuthenticated();
@@ -128,8 +146,7 @@ export class AppComponent {
/** Show sign-in only on pages where auth makes sense (not setup/callback). */
readonly showSignIn = computed(() => {
const url = this.currentUrl();
const hideRoutes = ['/setup', '/callback', '/silent-refresh'];
return !hideRoutes.some(route => url === route || url.startsWith(route + '/'));
return !this.isShellExcludedRoute(url);
});
onSignIn(): void {
@@ -140,4 +157,10 @@ export class AppComponent {
onLegacyBannerDismissed(): void {
this.legacyRouteTelemetry.clearCurrentLegacyRoute();
}
private isShellExcludedRoute(url: string): boolean {
return AppComponent.SHELL_EXCLUDED_ROUTES.some(
(route) => url === route || url.startsWith(route + '/')
);
}
}

View File

@@ -40,6 +40,7 @@ import { seedAuthSession, type StubAuthSession } from './testing';
import { CVSS_API_BASE_URL } from './core/api/cvss.client';
import { AUTH_SERVICE } from './core/auth';
import { AuthorityAuthService } from './core/auth/authority-auth.service';
import { AuthorityAuthAdapterService } from './core/auth/authority-auth-adapter.service';
import {
ADVISORY_AI_API,
ADVISORY_AI_API_BASE_URL,
@@ -221,7 +222,7 @@ export const appConfig: ApplicationConfig = {
},
{
provide: AUTH_SERVICE,
useExisting: AuthorityAuthService,
useExisting: AuthorityAuthAdapterService,
},
{
provide: CVSS_API_BASE_URL,

View File

@@ -122,6 +122,52 @@ export interface AiRemediationStep {
breakingChanges?: boolean;
}
// UI remediation plan models (used by advisory-ai plan preview components)
export type RemediationPlanStatus =
| 'draft'
| 'validated'
| 'approved'
| 'in_progress'
| 'completed'
| 'failed';
export interface RemediationPlanSummary {
line1: string;
line2: string;
line3: string;
}
export interface RemediationImpactEstimate {
breakingChanges: number;
filesAffected: number;
dependenciesAffected: number;
testCoverage: number;
riskScore: number;
}
export interface RemediationStep {
stepId: string;
type: 'upgrade' | 'patch' | 'config' | 'workaround' | 'vex_document' | string;
title: string;
description: string;
filePath?: string;
command?: string;
diff?: string;
riskLevel: 'low' | 'medium' | 'high';
breakingChange?: boolean;
}
export interface RemediationPlan {
planId: string;
strategy: string;
status: RemediationPlanStatus;
summary: RemediationPlanSummary;
estimatedImpact: RemediationImpactEstimate;
steps: RemediationStep[];
createdAt?: string;
updatedAt?: string;
}
// AI Justification Draft
export interface AiJustifyRequest {
cveId: string;

View File

@@ -0,0 +1,107 @@
import { TestBed } from '@angular/core/testing';
import { ConsoleSessionStore } from '../console/console-session.store';
import { AuthorityAuthService } from './authority-auth.service';
import { AuthSession } from './auth-session.model';
import { AuthSessionStore } from './auth-session.store';
import { AuthorityAuthAdapterService } from './authority-auth-adapter.service';
import { StellaOpsScopes } from './auth.service';
class AuthorityAuthServiceStub {
readonly logout = jasmine
.createSpy('logout')
.and.returnValue(Promise.resolve());
}
describe('AuthorityAuthAdapterService', () => {
let service: AuthorityAuthAdapterService;
let sessionStore: AuthSessionStore;
let consoleStore: ConsoleSessionStore;
let authorityAuth: AuthorityAuthServiceStub;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
AuthSessionStore,
ConsoleSessionStore,
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
],
});
service = TestBed.inject(AuthorityAuthAdapterService);
sessionStore = TestBed.inject(AuthSessionStore);
consoleStore = TestBed.inject(ConsoleSessionStore);
authorityAuth = TestBed.inject(
AuthorityAuthService
) as unknown as AuthorityAuthServiceStub;
});
it('maps session data to signal-based auth user contract', () => {
consoleStore.setTenants([
{
id: 'tenant-ops',
displayName: 'Ops Tenant',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['operator'],
},
]);
sessionStore.setSession(
buildSession({
tenantId: 'tenant-ops',
subject: 'subject-1',
name: 'Casey Operator',
email: 'casey@example.com',
roles: ['operator'],
scopes: [StellaOpsScopes.ORCH_READ, StellaOpsScopes.UI_READ],
})
);
TestBed.flushEffects();
const user = service.user();
expect(user).toBeTruthy();
expect(user?.id).toBe('subject-1');
expect(user?.tenantId).toBe('tenant-ops');
expect(user?.tenantName).toBe('Ops Tenant');
expect(service.isAuthenticated()).toBeTrue();
expect(service.hasScope(StellaOpsScopes.ORCH_READ)).toBeTrue();
});
it('delegates logout to AuthorityAuthService', () => {
service.logout();
expect(authorityAuth.logout).toHaveBeenCalled();
});
});
function buildSession(overrides: {
tenantId: string;
subject: string;
name: string;
email: string;
roles: string[];
scopes: string[];
}): AuthSession {
return {
tokens: {
accessToken: 'token',
expiresAtEpochMs: Date.now() + 60_000,
tokenType: 'Bearer',
scope: overrides.scopes.join(' '),
},
identity: {
subject: overrides.subject,
name: overrides.name,
email: overrides.email,
roles: overrides.roles,
},
dpopKeyThumbprint: 'thumbprint',
issuedAtEpochMs: Date.now(),
tenantId: overrides.tenantId,
scopes: overrides.scopes,
audiences: ['stellaops-web'],
authenticationTimeEpochMs: Date.now(),
freshAuthActive: true,
freshAuthExpiresAtEpochMs: Date.now() + 300_000,
};
}

View File

@@ -0,0 +1,178 @@
import {
Injectable,
computed,
effect,
signal,
} from '@angular/core';
import { ConsoleSessionStore } from '../console/console-session.store';
import { AuthorityAuthService } from './authority-auth.service';
import { AuthSessionStore } from './auth-session.store';
import {
AuthService,
AuthUser,
StellaOpsScope,
StellaOpsScopes,
} from './auth.service';
import {
hasAllScopes,
hasAnyScope,
hasScope,
} from './scopes';
const KNOWN_SCOPE_SET = new Set<StellaOpsScope>(
Object.values(StellaOpsScopes)
);
@Injectable({ providedIn: 'root' })
export class AuthorityAuthAdapterService implements AuthService {
readonly isAuthenticated = signal(false);
readonly user = signal<AuthUser | null>(null);
readonly scopes = computed(() => {
const currentUser = this.user();
return currentUser?.scopes ?? [];
});
constructor(
private readonly authorityAuth: AuthorityAuthService,
private readonly sessionStore: AuthSessionStore,
private readonly consoleSessionStore: ConsoleSessionStore
) {
effect(
() => {
this.isAuthenticated.set(this.sessionStore.isAuthenticated());
this.user.set(this.toAuthUser());
}
);
}
hasScope(scope: StellaOpsScope): boolean {
return hasScope(this.scopes(), scope);
}
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean {
return hasAllScopes(this.scopes(), scopes);
}
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean {
return hasAnyScope(this.scopes(), scopes);
}
canViewGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_READ);
}
canEditGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_WRITE);
}
canExportGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_EXPORT);
}
canSimulate(): boolean {
return this.hasAnyScope([
StellaOpsScopes.GRAPH_SIMULATE,
StellaOpsScopes.POLICY_SIMULATE,
]);
}
canViewOrchestrator(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_READ);
}
canOperateOrchestrator(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_OPERATE);
}
canManageOrchestratorQuotas(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_QUOTA);
}
canInitiateBackfill(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
}
canViewPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_READ);
}
canAuthorPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_AUTHOR);
}
canEditPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_EDIT);
}
canReviewPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_REVIEW);
}
canApprovePolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_APPROVE);
}
canOperatePolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_OPERATE);
}
canActivatePolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_ACTIVATE);
}
canSimulatePolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_SIMULATE);
}
canPublishPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_PUBLISH);
}
canAuditPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_AUDIT);
}
logout(): void {
void this.authorityAuth.logout();
}
private toAuthUser(): AuthUser | null {
const session = this.sessionStore.session();
if (!session) {
return null;
}
const tenantId =
this.consoleSessionStore.selectedTenantId() ??
session.tenantId ??
'unknown-tenant';
const tenantName =
this.consoleSessionStore.currentTenant()?.displayName ??
tenantId;
const identity = session.identity;
const id = identity.subject?.trim() || 'unknown-user';
const name = identity.name?.trim() || id;
const email = identity.email?.trim() || `${id}@unknown.local`;
const roles = identity.roles ?? [];
return {
id,
email,
name,
tenantId,
tenantName,
roles,
scopes: this.normalizeScopes(session.scopes),
};
}
private normalizeScopes(scopes: readonly string[]): readonly StellaOpsScope[] {
return scopes.filter((scope): scope is StellaOpsScope =>
KNOWN_SCOPE_SET.has(scope as StellaOpsScope)
);
}
}

View File

@@ -589,25 +589,25 @@ export class PrTrackerComponent {
readonly statusLabel = computed(() => {
if (!this.pullRequest) return '';
const labels: Record<PullRequestStatus, string> = {
const labels: Partial<Record<PullRequestStatus, string>> = {
draft: 'Draft',
open: 'Open',
merged: 'Merged',
closed: 'Closed',
};
return labels[this.pullRequest.status] || this.pullRequest.status;
return labels[this.pullRequest.status] ?? this.pullRequest.status;
});
readonly passedChecks = computed(() => {
if (!this.pullRequest) return 0;
return this.pullRequest.ciChecks.filter(c => c.status === 'passed').length;
const checks = this.getChecks();
return checks.filter((c) => this.isCheckPassed(c.status)).length;
});
readonly ciSummaryClass = computed(() => {
if (!this.pullRequest) return '';
const checks = this.pullRequest.ciChecks;
const passed = checks.filter(c => c.status === 'passed').length;
const failed = checks.filter(c => c.status === 'failed').length;
const checks = this.getChecks();
if (checks.length === 0) return 'in-progress';
const passed = checks.filter((c) => this.isCheckPassed(c.status)).length;
const failed = checks.filter((c) => this.isCheckFailed(c.status)).length;
if (failed > 0) return 'some-failed';
if (passed === checks.length) return 'all-passed';
@@ -616,21 +616,46 @@ export class PrTrackerComponent {
readonly reviewSummaryClass = computed(() => {
if (!this.pullRequest) return '';
const { approved, required } = this.pullRequest.reviewStatus;
const { approved, required } = this.getReviewStatus();
if (approved >= required) return 'approved';
return 'pending';
});
readonly canMerge = computed(() => {
if (!this.pullRequest) return false;
const allChecksPassed = this.pullRequest.ciChecks.every(
c => c.status === 'passed' || c.status === 'skipped'
const checks = this.getChecks();
const allChecksPassed = checks.every(
(c) => this.isCheckPassed(c.status) || c.status === 'skipped'
);
const hasEnoughApprovals =
this.pullRequest.reviewStatus.approved >= this.pullRequest.reviewStatus.required;
const reviewStatus = this.getReviewStatus();
const hasEnoughApprovals = reviewStatus.approved >= reviewStatus.required;
return allChecksPassed && hasEnoughApprovals;
});
private getChecks(): CiCheck[] {
return this.pullRequest?.ciChecks ?? [];
}
private getReviewStatus(): { approved: number; required: number } {
const reviewStatus = (this.pullRequest as any)?.reviewStatus;
if (!reviewStatus) {
return { approved: 0, required: 0 };
}
return {
approved: typeof reviewStatus.approved === 'number' ? reviewStatus.approved : 0,
required: typeof reviewStatus.required === 'number' ? reviewStatus.required : 0,
};
}
private isCheckPassed(status: CiCheckStatus | string): boolean {
return status === 'passed' || status === 'success';
}
private isCheckFailed(status: CiCheckStatus | string): boolean {
return status === 'failed' || status === 'failure';
}
formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('en-US', {

View File

@@ -78,14 +78,14 @@
<!-- Violation codes breakdown -->
<div class="code-breakdown">
@for (code of resultSummary()?.uniqueCodes || []; track code) {
<span class="code-chip">
{{ code }}
<span class="code-count">
{{ result()!.violations.filter(v => v.violationCode === code).length }}
</span>
</span>
}
</div>
<span class="code-chip">
{{ code }}
<span class="code-count">
{{ countViolationsByCode(code) }}
</span>
</span>
}
</div>
<!-- Sample violations -->
<ul class="violations-list">

View File

@@ -174,6 +174,12 @@ export class VerifyActionComponent {
this.selectViolation.emit(violation);
}
countViolationsByCode(code: string): number {
const result = this.result();
if (!result) return 0;
return result.violations.filter((violation) => violation.violationCode === code).length;
}
copyCommand(command: string): void {
navigator.clipboard.writeText(command);
}

View File

@@ -134,14 +134,14 @@
} @else {
<span class="no-provenance">No provenance</span>
}
</td>
<td class="actions-cell">
<button class="btn-icon" (click)="onViewRaw(v.documentId)" title="View raw">
{ }
</button>
</td>
</tr>
}
</td>
<td class="actions-cell">
<button class="btn-icon" (click)="onViewRaw(v.documentId)" title="View raw">
[RAW]
</button>
</td>
</tr>
}
</tbody>
</table>
</div>

View File

@@ -36,6 +36,21 @@ interface ReachabilityWitness {
};
}
type ApprovalDecisionStatus = 'pending' | 'approved' | 'rejected';
interface ApprovalDetailState {
releaseVersion: string;
bundleDigest: string;
fromEnv: string;
toEnv: string;
status: ApprovalDecisionStatus;
requestedBy: string;
requestedAt: string;
decidedBy: string;
decidedAt: string;
evidenceId: string;
}
@Component({
selector: 'app-approval-detail-page',
standalone: true,
@@ -725,12 +740,12 @@ export class ApprovalDetailPageComponent implements OnInit {
},
};
approval = signal({
approval = signal<ApprovalDetailState>({
releaseVersion: 'v1.2.5',
bundleDigest: 'sha256:7aa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9',
fromEnv: 'QA',
toEnv: 'Staging',
status: 'pending' as const,
status: 'pending',
requestedBy: 'user1',
requestedAt: '2h ago',
decidedBy: '',
@@ -749,7 +764,7 @@ export class ApprovalDetailPageComponent implements OnInit {
ngOnInit(): void {
this.route.params.subscribe(params => {
this.approvalId.set(params['approvalId'] || '');
this.approvalId.set(params['id'] || '');
});
}

View File

@@ -10,7 +10,7 @@ export const APPROVALS_ROUTES: Routes = [
{
path: ':id',
loadComponent: () =>
import('./approval-detail.component').then((m) => m.ApprovalDetailComponent),
import('./approval-detail-page.component').then((m) => m.ApprovalDetailPageComponent),
data: { breadcrumb: 'Approval Detail' },
},
];

View File

@@ -77,7 +77,9 @@ export class CompareViewComponent implements OnInit {
ngOnInit(): void {
// Load from route params
const currentId = this.route.snapshot.paramMap.get('current');
const currentId =
this.route.snapshot.paramMap.get('currentId') ??
this.route.snapshot.paramMap.get('current');
const baselineId = this.route.snapshot.queryParamMap.get('baseline');
if (currentId) {

View File

@@ -637,7 +637,8 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked {
getMatchCount(): number {
const query = this.logSearchQuery().toLowerCase();
if (!query) return 0;
return (this.fullLogs.toLowerCase().match(new RegExp(query, 'g')) || []).length;
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return (this.fullLogs.toLowerCase().match(new RegExp(escaped, 'g')) || []).length;
}
// Header actions

View File

@@ -8,6 +8,7 @@ import {
Input,
Output,
EventEmitter,
ChangeDetectorRef,
OnChanges,
SimpleChanges,
ElementRef,
@@ -52,6 +53,8 @@ export class CausalLanesComponent implements OnChanges, AfterViewInit {
private readonly eventWidth = 32;
private readonly minTimeWidth = 1000;
constructor(private readonly cdr: ChangeDetectorRef) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['events']) {
this.buildLanes();
@@ -59,7 +62,10 @@ export class CausalLanesComponent implements OnChanges, AfterViewInit {
}
ngAfterViewInit(): void {
this.updatePixelScale();
queueMicrotask(() => {
this.updatePixelScale();
this.cdr.markForCheck();
});
}
onEventClick(event: TimelineEvent): void {

View File

@@ -105,7 +105,7 @@ export class AdvisoryAiService {
return newSet;
});
return this.http.post<{ taskId: string }>(`${this.baseUrl}/plan`, { vulnId, ...context }).pipe(
return this.http.post<{ taskId: string }>(`${this.baseUrl}/plan`, { ...context }).pipe(
switchMap(({ taskId }) => this.pollTaskStatus(taskId)),
tap({
next: (task) => {

View File

@@ -4,7 +4,7 @@
// Task: FE-RISK-006 — User setting toggle for runtime overlays and trace export
// -----------------------------------------------------------------------------
import { Injectable, signal, computed, effect } from '@angular/core';
import { Injectable, signal, computed } from '@angular/core';
/**
* Display preferences for triage and finding views.
@@ -88,10 +88,19 @@ export class DisplayPreferencesService {
readonly preferences = computed(() => this._preferences());
constructor() {
// Auto-persist on changes
effect(() => {
const prefs = this._preferences();
this.persist(prefs);
this.persist(this._preferences());
}
/**
* Update preferences and persist synchronously for deterministic behavior.
*/
private updatePreferences(
updater: (prefs: DisplayPreferences) => DisplayPreferences
): void {
this._preferences.update((prefs) => {
const next = updater(prefs);
this.persist(next);
return next;
});
}
@@ -99,35 +108,35 @@ export class DisplayPreferencesService {
* Set whether runtime-confirmed overlays are shown in graphs.
*/
setShowRuntimeOverlays(value: boolean): void {
this._preferences.update((p) => ({ ...p, showRuntimeOverlays: value }));
this.updatePreferences((p) => ({ ...p, showRuntimeOverlays: value }));
}
/**
* Set whether trace export actions are available.
*/
setEnableTraceExport(value: boolean): void {
this._preferences.update((p) => ({ ...p, enableTraceExport: value }));
this.updatePreferences((p) => ({ ...p, enableTraceExport: value }));
}
/**
* Set whether the risk line summary bar is shown.
*/
setShowRiskLine(value: boolean): void {
this._preferences.update((p) => ({ ...p, showRiskLine: value }));
this.updatePreferences((p) => ({ ...p, showRiskLine: value }));
}
/**
* Set whether signed VEX override indicators are shown.
*/
setShowSignedOverrideIndicators(value: boolean): void {
this._preferences.update((p) => ({ ...p, showSignedOverrideIndicators: value }));
this.updatePreferences((p) => ({ ...p, showSignedOverrideIndicators: value }));
}
/**
* Set whether runtime evidence section is expanded by default.
*/
setExpandRuntimeEvidence(value: boolean): void {
this._preferences.update((p) => ({ ...p, expandRuntimeEvidence: value }));
this.updatePreferences((p) => ({ ...p, expandRuntimeEvidence: value }));
}
/**
@@ -135,7 +144,7 @@ export class DisplayPreferencesService {
*/
setGraphMaxNodes(value: number): void {
const clamped = Math.max(10, Math.min(200, value));
this._preferences.update((p) => ({
this.updatePreferences((p) => ({
...p,
graph: { ...p.graph, maxNodes: clamped },
}));
@@ -145,7 +154,7 @@ export class DisplayPreferencesService {
* Set highlight style for runtime-confirmed edges.
*/
setRuntimeHighlightStyle(value: 'bold' | 'color' | 'both'): void {
this._preferences.update((p) => ({
this.updatePreferences((p) => ({
...p,
graph: { ...p.graph, runtimeHighlightStyle: value },
}));
@@ -155,7 +164,10 @@ export class DisplayPreferencesService {
* Reset all preferences to defaults.
*/
reset(): void {
this._preferences.set({ ...DEFAULT_PREFERENCES, graph: { ...DEFAULT_PREFERENCES.graph } });
this.updatePreferences(() => ({
...DEFAULT_PREFERENCES,
graph: { ...DEFAULT_PREFERENCES.graph },
}));
}
/**

View File

@@ -166,20 +166,20 @@ export function getVerifyStepIcon(status: VerifyStepStatus): string {
* Sort findings by field.
*/
export function sortFindings(findings: Finding[], sort: FindingSort): Finding[] {
const multiplier = sort.direction === 'asc' ? 1 : -1;
const direction = sort.direction === 'asc' ? 1 : -1;
return [...findings].sort((a, b) => {
switch (sort.field) {
case 'exploitability':
return (b.exploitability - a.exploitability) * multiplier;
return (a.exploitability - b.exploitability) * direction;
case 'severity':
return (severityOrder(b.severity) - severityOrder(a.severity)) * multiplier;
return (severityOrder(a.severity) - severityOrder(b.severity)) * direction;
case 'reachability':
return (reachabilityOrder(b.reachability) - reachabilityOrder(a.reachability)) * multiplier;
return (reachabilityOrder(a.reachability) - reachabilityOrder(b.reachability)) * direction;
case 'runtime':
return (runtimeOrder(b.runtimePresence) - runtimeOrder(a.runtimePresence)) * multiplier;
return (runtimeOrder(a.runtimePresence) - runtimeOrder(b.runtimePresence)) * direction;
case 'published':
return ((a.publishedAt ?? '').localeCompare(b.publishedAt ?? '')) * multiplier;
return ((a.publishedAt ?? '').localeCompare(b.publishedAt ?? '')) * direction;
default:
return 0;
}

View File

@@ -6,7 +6,7 @@
import { Injectable, InjectionToken, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, delay, tap, catchError, finalize, interval, take, map, switchMap } from 'rxjs';
import { Observable, of, delay, tap, catchError, finalize, interval, take, map, switchMap, filter, defaultIfEmpty } from 'rxjs';
import {
Finding,
@@ -132,17 +132,14 @@ export class DeveloperWorkspaceService implements IDeveloperWorkspaceService {
tap((response) => {
this.verifySteps.set(response.steps);
}),
map((response) => {
if (response.result) {
return response.result;
}
throw new Error('pending');
}),
catchError((err) => {
if (err.message === 'pending') {
throw err; // Continue polling
}
throw err;
filter((response): response is { status: string; steps: VerifyStep[]; result: VerifyResult } => !!response.result),
map((response) => response.result),
take(1),
defaultIfEmpty({
success: false,
steps: this.verifySteps(),
errorMessage: 'Verification timed out',
completedAt: new Date().toISOString(),
})
);
}

View File

@@ -1,10 +1,43 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterModule } from '@angular/router';
import { provideRouter } from '@angular/router';
import { signal } from '@angular/core';
import { of } from 'rxjs';
import { AUTH_SERVICE, MockAuthService } from '../../core/auth';
import { OfflineModeService } from '../../core/services/offline-mode.service';
import { PolicyPackStore } from '../../features/policy-studio/services/policy-pack.store';
import { AppShellComponent } from './app-shell.component';
class OfflineModeServiceStub {
readonly isOffline = signal(false);
readonly bundleFreshness = signal<{
status: 'fresh' | 'stale' | 'expired';
bundleCreatedAt: string;
ageInDays: number;
message: string;
} | null>(null);
readonly offlineBannerMessage = signal<string | null>(null);
}
class PolicyPackStoreStub {
getPacks() {
return of([
{
id: 'pack-1',
name: 'Core Policy',
description: '',
version: 'v3.1',
status: 'active',
createdAt: '',
modifiedAt: '',
createdBy: '',
modifiedBy: '',
tags: [],
},
]);
}
}
describe('AppShellComponent', () => {
let component: AppShellComponent;
let fixture: ComponentFixture<AppShellComponent>;
@@ -15,6 +48,8 @@ describe('AppShellComponent', () => {
providers: [
provideRouter([]),
{ provide: AUTH_SERVICE, useClass: MockAuthService },
{ provide: OfflineModeService, useClass: OfflineModeServiceStub },
{ provide: PolicyPackStore, useClass: PolicyPackStoreStub },
],
}).compileComponents();

View File

@@ -1,7 +1,12 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { AppSidebarComponent } from './app-sidebar.component';
import { AUTH_SERVICE, MockAuthService, StellaOpsScopes } from '../../core/auth';
import {
AUTH_SERVICE,
MockAuthService,
StellaOpsScope,
StellaOpsScopes,
} from '../../core/auth';
describe('AppSidebarComponent', () => {
let authService: MockAuthService;
@@ -31,7 +36,7 @@ describe('AppSidebarComponent', () => {
expect(fixture.nativeElement.textContent).toContain('Analytics');
});
function setScopes(scopes: readonly string[]): void {
function setScopes(scopes: readonly StellaOpsScope[]): void {
const baseUser = authService.user();
if (!baseUser) {
throw new Error('Mock auth user is not initialized.');

View File

@@ -0,0 +1,79 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { AUTH_SERVICE, MockAuthService } from '../../core/auth';
import { OfflineModeService } from '../../core/services/offline-mode.service';
import { PolicyPackStore } from '../../features/policy-studio/services/policy-pack.store';
import { ContextChipsComponent } from './context-chips.component';
class OfflineModeServiceStub {
readonly isOffline = signal(false);
readonly bundleFreshness = signal<{
status: 'fresh' | 'stale' | 'expired';
bundleCreatedAt: string;
ageInDays: number;
message: string;
} | null>(null);
readonly offlineBannerMessage = signal<string | null>(null);
}
class PolicyPackStoreStub {
getPacks() {
return of([
{
id: 'pack-1',
name: 'Core Policy',
description: '',
version: 'v3.1',
status: 'active',
createdAt: '',
modifiedAt: '',
createdBy: '',
modifiedBy: '',
tags: [],
},
]);
}
}
describe('ContextChipsComponent', () => {
let offlineModeStub: OfflineModeServiceStub;
beforeEach(async () => {
offlineModeStub = new OfflineModeServiceStub();
await TestBed.configureTestingModule({
imports: [ContextChipsComponent],
providers: [
provideRouter([]),
{ provide: AUTH_SERVICE, useClass: MockAuthService },
{ provide: OfflineModeService, useValue: offlineModeStub },
{ provide: PolicyPackStore, useClass: PolicyPackStoreStub },
],
}).compileComponents();
});
it('renders all context chips', () => {
const fixture = TestBed.createComponent(ContextChipsComponent);
fixture.detectChanges();
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
expect(text).toContain('Offline:');
expect(text).toContain('Feed:');
expect(text).toContain('Policy:');
expect(text).toContain('Evidence:');
});
it('shows degraded offline status when offline mode is active', () => {
offlineModeStub.isOffline.set(true);
offlineModeStub.offlineBannerMessage.set('Offline mode enabled');
const fixture = TestBed.createComponent(ContextChipsComponent);
fixture.detectChanges();
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
expect(text).toContain('DEGRADED');
});
});

View File

@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { OfflineStatusChipComponent } from './offline-status-chip.component';

View File

@@ -1,6 +1,12 @@
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
computed,
inject,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { AUTH_SERVICE, AuthService, StellaOpsScopes } from '../../core/auth';
/**
* EvidenceModeChipComponent - Shows whether evidence signing is enabled.
@@ -75,8 +81,19 @@ import { RouterLink } from '@angular/router';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EvidenceModeChipComponent {
// TODO: Wire to actual evidence signing service
readonly isEnabled = signal(true);
private readonly authService = inject(AUTH_SERVICE) as AuthService;
readonly tooltip = signal('Evidence signing is enabled. All decisions are cryptographically attested.');
readonly isEnabled = computed(() =>
this.authService.hasAnyScope([
StellaOpsScopes.SIGNER_SIGN,
StellaOpsScopes.SIGNER_ADMIN,
StellaOpsScopes.ADMIN,
])
);
readonly tooltip = computed(() =>
this.isEnabled()
? 'Evidence signing scopes are active for this session.'
: 'Evidence signing scopes are not active for this session.'
);
}

View File

@@ -1,6 +1,12 @@
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
computed,
inject,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { OfflineModeService } from '../../core/services/offline-mode.service';
/**
* FeedSnapshotChipComponent - Shows the date of the current vulnerability feed snapshot.
@@ -84,9 +90,35 @@ import { RouterLink } from '@angular/router';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FeedSnapshotChipComponent {
// TODO: Wire to actual feed status service
readonly snapshotDate = signal('2026-01-15');
readonly isStale = signal(false);
private readonly offlineMode = inject(OfflineModeService);
readonly tooltip = signal('Vulnerability feed snapshot from 2026-01-15. Click to view feed status.');
readonly snapshotDate = computed(() => {
const freshness = this.offlineMode.bundleFreshness();
if (!freshness?.bundleCreatedAt) {
return 'Live';
}
const parsed = new Date(freshness.bundleCreatedAt);
if (Number.isNaN(parsed.getTime())) {
return freshness.bundleCreatedAt;
}
return parsed.toISOString().slice(0, 10);
});
readonly isStale = computed(() => {
const freshness = this.offlineMode.bundleFreshness();
if (!freshness) {
return false;
}
return freshness.status === 'stale' || freshness.status === 'expired';
});
readonly tooltip = computed(() => {
const freshness = this.offlineMode.bundleFreshness();
if (!freshness) {
return 'Using live feed connectivity; offline snapshot metadata is unavailable.';
}
return `${freshness.message} (snapshot ${freshness.bundleCreatedAt}).`;
});
}

View File

@@ -1,6 +1,7 @@
import { Component, ChangeDetectionStrategy, inject, signal } from '@angular/core';
import { Component, ChangeDetectionStrategy, computed, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { OfflineModeService } from '../../core/services/offline-mode.service';
/**
* OfflineStatusChipComponent - Shows connectivity/offline status.
@@ -80,8 +81,34 @@ import { RouterLink } from '@angular/router';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OfflineStatusChipComponent {
// TODO: Wire to actual offline status service
readonly status = signal<'ok' | 'degraded'>('ok');
private readonly offlineMode = inject(OfflineModeService);
readonly tooltip = signal('All offline capabilities are operational');
readonly status = computed<'ok' | 'degraded'>(() => {
if (this.offlineMode.isOffline()) {
return 'degraded';
}
const freshness = this.offlineMode.bundleFreshness();
if (freshness && freshness.status !== 'fresh') {
return 'degraded';
}
return 'ok';
});
readonly tooltip = computed(() => {
if (this.offlineMode.isOffline()) {
return (
this.offlineMode.offlineBannerMessage() ??
'Offline mode is active for this tenant context.'
);
}
const freshness = this.offlineMode.bundleFreshness();
if (freshness) {
return freshness.message;
}
return 'Online mode active with live backend connectivity.';
});
}

View File

@@ -1,6 +1,13 @@
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
computed,
inject,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
import { PolicyPackStore } from '../../features/policy-studio/services/policy-pack.store';
/**
* PolicyBaselineChipComponent - Shows the active policy baseline version.
@@ -58,8 +65,28 @@ import { RouterLink } from '@angular/router';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PolicyBaselineChipComponent {
// TODO: Wire to actual policy baseline service
readonly baselineName = signal('prod-baseline v3.1');
private readonly policyPackStore = inject(PolicyPackStore);
private readonly packs = toSignal(this.policyPackStore.getPacks(), {
initialValue: [],
});
readonly tooltip = signal('Active policy baseline: prod-baseline v3.1. Click to manage policies.');
readonly baselineName = computed(() => {
const packs = this.packs();
if (packs.length === 0) {
return 'No baseline';
}
const activePack = packs.find((pack) => pack.status === 'active') ?? packs[0];
return `${activePack.name} ${activePack.version}`.trim();
});
readonly tooltip = computed(() => {
const packs = this.packs();
if (packs.length === 0) {
return 'No policy baseline is currently available.';
}
const activePack = packs.find((pack) => pack.status === 'active') ?? packs[0];
return `Active policy baseline: ${activePack.name} ${activePack.version}. Click to manage policies.`;
});
}

View File

@@ -16,7 +16,7 @@ import { Component, input, output } from '@angular/core';
template: `
<button
type="button"
class="ask-stella-btn"
class="ask-stella-btn ask-stella-button"
[class.ask-stella-btn--compact]="compact()"
[disabled]="disabled()"
[attr.title]="'Ask Stella for help (Ctrl+K)'"

View File

@@ -10,7 +10,6 @@
import { Component, input, output, signal, computed } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AiChipComponent } from './ai-chip.component';
/**
* Context scope for the panel.
@@ -59,7 +58,7 @@ export interface AskStellaResult {
@Component({
selector: 'stella-ask-stella-panel',
standalone: true,
imports: [FormsModule, AiChipComponent],
imports: [FormsModule],
template: `
<div class="ask-stella-panel" [class.ask-stella-panel--loading]="isLoading()">
<header class="ask-stella-panel__header">
@@ -83,7 +82,7 @@ export interface AskStellaResult {
@for (prompt of suggestedPrompts(); track prompt.id) {
<button
type="button"
class="ask-stella-panel__suggestion"
class="ask-stella-panel__suggestion ask-stella-panel__prompt-chip"
[disabled]="isLoading()"
(click)="onSuggestionClick(prompt)"
>
@@ -116,8 +115,14 @@ export interface AskStellaResult {
</div>
</div>
@if (isLoading()) {
<div class="ask-stella-panel__loading" role="status">
Generating response...
</div>
}
@if (result()) {
<div class="ask-stella-panel__result">
<div class="ask-stella-panel__result ask-stella-panel__response">
<div class="ask-stella-panel__result-header">
<span class="ask-stella-panel__result-authority"
[class.ask-stella-panel__result-authority--evidence]="result()!.authority === 'evidence-backed'"
@@ -303,6 +308,16 @@ export interface AskStellaResult {
padding: 0.75rem;
}
.ask-stella-panel__loading {
margin-bottom: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.8125rem;
color: #6b7280;
background: rgba(245, 166, 35, 0.08);
border: 1px solid var(--color-brand-primary-10);
}
.ask-stella-panel__result-header {
margin-bottom: 0.5rem;
}

View File

@@ -112,7 +112,7 @@ export interface BinaryDiffData {
class="scope-btn"
[class.active]="currentScope() === 'file'"
(click)="setScope('file')"
aria-pressed="file === currentScope()"
[attr.aria-pressed]="currentScope() === 'file'"
>
📁 File
</button>
@@ -120,7 +120,7 @@ export interface BinaryDiffData {
class="scope-btn"
[class.active]="currentScope() === 'section'"
(click)="setScope('section')"
aria-pressed="section === currentScope()"
[attr.aria-pressed]="currentScope() === 'section'"
>
📦 Section
</button>
@@ -128,7 +128,7 @@ export interface BinaryDiffData {
class="scope-btn"
[class.active]="currentScope() === 'function'"
(click)="setScope('function')"
aria-pressed="function === currentScope()"
[attr.aria-pressed]="currentScope() === 'function'"
>
⚙️ Function
</button>

View File

@@ -38,7 +38,8 @@ import {
[matBadge]="activeCount"
[matBadgeColor]="badgeColor"
matBadgeSize="small"
[matBadgeHidden]="activeCount === 0">
[matBadgeHidden]="activeCount === 0"
aria-hidden="false">
{{ statusIcon }}
</mat-icon>

View File

@@ -22,6 +22,9 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EntropyPanelComponent {
// Expose Math for template trigonometric bindings used by donut path geometry.
readonly Math = Math;
/** Entropy analysis data */
readonly analysis = input.required<EntropyAnalysis>();

View File

@@ -58,8 +58,8 @@ interface DiffLine {
<div class="function-diff__name">
<span class="function-diff__icon" aria-hidden="true">{{ changeIcon() }}</span>
<span class="function-diff__fn-name">{{ functionName() }}</span>
@if (functionChange().address) {
<span class="function-diff__address">{{ functionChange().address | number:'16' }}</span>
@if (functionAddress() != null) {
<span class="function-diff__address">{{ formatAddress(functionAddress()!) }}</span>
}
</div>
@@ -106,8 +106,8 @@ interface DiffLine {
<div class="function-diff__pane function-diff__pane--before">
<div class="function-diff__pane-header">
<span>Before (Vulnerable)</span>
@if (functionChange().beforeHash) {
<code class="function-diff__hash">{{ truncateHash(functionChange().beforeHash!) }}</code>
@if (beforeHash()) {
<code class="function-diff__hash">{{ truncateHash(beforeHash()!) }}</code>
}
</div>
<pre class="function-diff__code"><code>{{ formatBeforeLines() }}</code></pre>
@@ -116,8 +116,8 @@ interface DiffLine {
<div class="function-diff__pane function-diff__pane--after">
<div class="function-diff__pane-header">
<span>After (Fixed)</span>
@if (functionChange().afterHash) {
<code class="function-diff__hash">{{ truncateHash(functionChange().afterHash!) }}</code>
@if (afterHash()) {
<code class="function-diff__hash">{{ truncateHash(afterHash()!) }}</code>
}
</div>
<pre class="function-diff__code"><code>{{ formatAfterLines() }}</code></pre>
@@ -143,14 +143,14 @@ interface DiffLine {
<dt>Change Type</dt>
<dd>{{ changeTypeLabel() }}</dd>
</div>
@if (functionChange().beforeHash && functionChange().afterHash) {
@if (beforeHash() && afterHash()) {
<div class="function-diff__summary-item">
<dt>Before Hash</dt>
<dd><code>{{ functionChange().beforeHash }}</code></dd>
<dd><code>{{ beforeHash() }}</code></dd>
</div>
<div class="function-diff__summary-item">
<dt>After Hash</dt>
<dd><code>{{ functionChange().afterHash }}</code></dd>
<dd><code>{{ afterHash() }}</code></dd>
</div>
}
@if (similarity() != null) {
@@ -159,10 +159,10 @@ interface DiffLine {
<dd>{{ similarity() }}%</dd>
</div>
}
@if (functionChange().patchId) {
@if (patchId()) {
<div class="function-diff__summary-item">
<dt>Patch ID</dt>
<dd>{{ functionChange().patchId }}</dd>
<dd>{{ patchId() }}</dd>
</div>
}
</dl>
@@ -436,20 +436,78 @@ export class FunctionDiffComponent {
/** Collapsed state */
collapsed = signal(false);
private change = computed(() => this.functionChange() as FunctionChangeInfo & {
functionName?: string;
address?: number;
beforeHash?: string;
afterHash?: string;
patchId?: string;
beforeDisasm?: string;
afterDisasm?: string;
patchCommit?: string;
});
/** Function address for display if available */
functionAddress = computed(() => {
const change = this.change();
return change.address ?? change.vulnerableOffset ?? change.patchedOffset ?? null;
});
/** Optional before hash from upstream payload */
beforeHash = computed(() => this.change().beforeHash ?? null);
/** Optional after hash from upstream payload */
afterHash = computed(() => this.change().afterHash ?? null);
/** Optional patch identifier */
patchId = computed(() => this.change().patchId ?? this.change().patchCommit ?? null);
/** Normalized before disassembly text */
beforeDisasmText = computed(() => {
const change = this.change();
if (change.beforeDisasm) return change.beforeDisasm;
if (change.vulnerableDisasm?.length) return change.vulnerableDisasm.join('\n');
return '';
});
/** Normalized after disassembly text */
afterDisasmText = computed(() => {
const change = this.change();
if (change.afterDisasm) return change.afterDisasm;
if (change.patchedDisasm?.length) return change.patchedDisasm.join('\n');
return '';
});
/** Normalized change type across legacy and current payload shapes */
normalizedChangeType = computed(() => {
const raw = (this.change().changeType ?? '').toString().toLowerCase();
switch (raw) {
case 'modified':
return 'modified';
case 'added':
return 'added';
case 'removed':
return 'removed';
case 'renamed':
case 'signaturechanged':
return 'renamed';
default:
return 'unknown';
}
});
/** Current view mode (respects initial input then internal state) */
currentViewMode = computed(() => this._currentViewMode());
/** Function name for display */
functionName = computed(() => {
const change = this.functionChange();
return change.functionName || '<anonymous>';
const change = this.change();
return change.functionName || change.name || '<anonymous>';
});
/** Icon for change type */
changeIcon = computed(() => {
const change = this.functionChange();
switch (change.changeType) {
switch (this.normalizedChangeType()) {
case 'modified':
return '📝';
case 'added':
@@ -465,9 +523,7 @@ export class FunctionDiffComponent {
/** Label for change type */
changeTypeLabel = computed(() => {
const change = this.functionChange();
switch (change.changeType) {
switch (this.normalizedChangeType()) {
case 'modified':
return 'Modified';
case 'added':
@@ -483,8 +539,9 @@ export class FunctionDiffComponent {
/** Similarity percentage (0-100) */
similarity = computed(() => {
const change = this.functionChange();
return change.similarity != null ? Math.round(change.similarity * 100) : null;
const similarity = this.change().similarity;
if (similarity == null) return null;
return similarity <= 1 ? Math.round(similarity * 100) : Math.round(similarity);
});
/** Label for current view mode button */
@@ -533,29 +590,31 @@ export class FunctionDiffComponent {
return hash.slice(0, 8) + '…' + hash.slice(-4);
}
/** Format numeric address as lowercase hexadecimal. */
formatAddress(address: number): string {
return Math.trunc(address).toString(16);
}
/** Format before lines for display */
formatBeforeLines(): string {
const change = this.functionChange();
if (!change.beforeDisasm) {
if (!this.beforeDisasmText()) {
return '// No disassembly available';
}
return change.beforeDisasm;
return this.beforeDisasmText();
}
/** Format after lines for display */
formatAfterLines(): string {
const change = this.functionChange();
if (!change.afterDisasm) {
if (!this.afterDisasmText()) {
return '// No disassembly available';
}
return change.afterDisasm;
return this.afterDisasmText();
}
/** Format unified diff with HTML highlighting */
formatUnifiedDiff(): string {
const change = this.functionChange();
const before = change.beforeDisasm?.split('\n') ?? [];
const after = change.afterDisasm?.split('\n') ?? [];
const before = this.beforeDisasmText().split('\n').filter((line) => line.length > 0);
const after = this.afterDisasmText().split('\n').filter((line) => line.length > 0);
const lines: string[] = [];

View File

@@ -15,7 +15,6 @@ import {
AfterViewInit,
ChangeDetectionStrategy,
signal,
computed,
} from '@angular/core';
@@ -109,6 +108,8 @@ import {
`],
})
export class MermaidRendererComponent implements OnChanges, AfterViewInit {
private static readonly MERMAID_ERROR = 'Failed to load diagram renderer';
/** Mermaid diagram syntax */
@Input({ required: true }) diagram = '';
@@ -124,7 +125,11 @@ export class MermaidRendererComponent implements OnChanges, AfterViewInit {
protected isLoading = signal(false);
protected error = signal<string | null>(null);
private mermaid: typeof import('mermaid') | null = null;
private mermaid: {
initialize: (config: Record<string, unknown>) => void;
parse: (diagram: string) => Promise<boolean>;
render: (id: string, diagram: string) => Promise<{ svg: string }>;
} | null = null;
private initialized = false;
private diagramId = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
@@ -145,7 +150,11 @@ export class MermaidRendererComponent implements OnChanges, AfterViewInit {
try {
// Dynamic import for tree-shaking
const mermaidModule = await import('mermaid');
this.mermaid = mermaidModule.default;
this.mermaid = mermaidModule.default as unknown as {
initialize: (config: Record<string, unknown>) => void;
parse: (diagram: string) => Promise<boolean>;
render: (id: string, diagram: string) => Promise<{ svg: string }>;
};
this.mermaid.initialize({
startOnLoad: false,
@@ -157,7 +166,7 @@ export class MermaidRendererComponent implements OnChanges, AfterViewInit {
this.initialized = true;
} catch (err) {
console.error('Failed to load Mermaid.js:', err);
this.error.set('Failed to load diagram renderer');
this.error.set(MermaidRendererComponent.MERMAID_ERROR);
}
}

View File

@@ -0,0 +1,63 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
AutofixButtonComponent,
GeneratePlanRequestEvent,
} from '../../app/features/advisory-ai/autofix-button.component';
describe('AutofixButtonComponent (advisory_ai_autofix)', () => {
let fixture: ComponentFixture<AutofixButtonComponent>;
let component: AutofixButtonComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AutofixButtonComponent],
}).compileComponents();
fixture = TestBed.createComponent(AutofixButtonComponent);
component = fixture.componentInstance;
component.findingId = 'finding-1';
component.vulnerabilityId = 'CVE-2026-1000';
component.componentPurl = 'pkg:npm/lodash@4.17.21';
component.artifactDigest = 'sha256:abc';
component.scopeId = 'svc-payments';
fixture.detectChanges();
});
it('emits generatePlan and toggles loading lifecycle callbacks', () => {
let payload: GeneratePlanRequestEvent | null = null;
component.generatePlan.subscribe((value) => (payload = value));
component.onButtonClick();
expect(payload).toBeTruthy();
expect(payload!.vulnerabilityId).toBe('CVE-2026-1000');
expect(payload!.preferredStrategy).toBeUndefined();
expect(component.loading()).toBeTrue();
payload!.onComplete();
expect(component.loading()).toBeFalse();
});
it('emits strategy-specific generatePlan payload from dropdown selection', () => {
let payload: GeneratePlanRequestEvent | null = null;
component.generatePlan.subscribe((value) => (payload = value));
component.selectStrategy('patch');
expect(payload).toBeTruthy();
expect(payload!.preferredStrategy).toBe('patch');
expect(component.dropdownOpen()).toBeFalse();
});
it('emits viewPlan when an existing plan is present', () => {
component.hasPlan = true;
fixture.detectChanges();
const emitSpy = spyOn(component.viewPlan, 'emit');
component.onButtonClick();
expect(emitSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,75 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PrTrackerComponent } from '../../app/features/advisory-ai/pr-tracker.component';
const mockPullRequest: any = {
prNumber: 142,
title: 'chore: remediate CVE-2026-1000',
status: 'open',
url: 'https://scm.local/org/repo/pull/142',
repository: 'org/repo',
sourceBranch: 'remediate/cve-2026-1000',
targetBranch: 'main',
scmProvider: 'github',
ciChecks: [
{ name: 'build', status: 'passed' },
{ name: 'unit-tests', status: 'passed' },
{ name: 'security-scan', status: 'skipped' },
],
reviewStatus: {
approved: 2,
required: 2,
reviewers: [
{ id: 'u1', name: 'alice', decision: 'approved' },
{ id: 'u2', name: 'bob', decision: 'approved' },
],
},
createdAt: '2026-02-10T20:00:00Z',
updatedAt: '2026-02-10T20:30:00Z',
};
describe('PrTrackerComponent (advisory_ai_autofix)', () => {
let fixture: ComponentFixture<PrTrackerComponent>;
let component: PrTrackerComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PrTrackerComponent],
}).compileComponents();
fixture = TestBed.createComponent(PrTrackerComponent);
component = fixture.componentInstance;
component.pullRequest = mockPullRequest;
fixture.detectChanges();
});
it('computes merge-ready state when checks and approvals satisfy policy', () => {
expect(component.statusLabel()).toBe('Open');
expect(component.passedChecks()).toBe(2);
expect(component.canMerge()).toBeTrue();
});
it('emits merge action when merge button is clicked', () => {
const emitSpy = spyOn(component.merge, 'emit');
const buttons = fixture.nativeElement.querySelectorAll('button') as NodeListOf<HTMLButtonElement>;
const mergeButton = (Array.from(buttons) as HTMLButtonElement[]).find((b) =>
(b.textContent ?? '').includes('Merge')
) as HTMLButtonElement | undefined;
expect(mergeButton).toBeTruthy();
mergeButton!.click();
expect(emitSpy).toHaveBeenCalled();
});
it('emits close action when close button is clicked', () => {
const emitSpy = spyOn(component.close, 'emit');
const buttons = fixture.nativeElement.querySelectorAll('button') as NodeListOf<HTMLButtonElement>;
const closeButton = (Array.from(buttons) as HTMLButtonElement[]).find((b) =>
(b.textContent ?? '').includes('Close')
) as HTMLButtonElement | undefined;
expect(closeButton).toBeTruthy();
closeButton!.click();
expect(emitSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,84 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RemediationPlanPreviewComponent } from '../../app/features/advisory-ai/remediation-plan-preview.component';
import { RemediationPlan } from '../../app/core/api/advisory-ai.models';
const mockPlan: RemediationPlan = {
planId: 'plan-1',
strategy: 'upgrade',
status: 'draft',
summary: {
line1: 'Upgrade lodash to a patched version.',
line2: 'Removes reachable high-severity vulnerability path.',
line3: 'Create PR and run integration checks.',
},
estimatedImpact: {
breakingChanges: 0,
filesAffected: 2,
dependenciesAffected: 1,
testCoverage: 84,
riskScore: 3,
},
steps: [
{
stepId: 's1',
type: 'upgrade',
title: 'Bump dependency',
description: 'Update package.json and lockfile.',
filePath: 'package.json',
command: 'npm install lodash@^4.17.22',
diff: '-lodash@4.17.21 +lodash@4.17.22',
riskLevel: 'low',
},
],
};
describe('RemediationPlanPreviewComponent (advisory_ai_autofix)', () => {
let fixture: ComponentFixture<RemediationPlanPreviewComponent>;
let component: RemediationPlanPreviewComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RemediationPlanPreviewComponent],
}).compileComponents();
fixture = TestBed.createComponent(RemediationPlanPreviewComponent);
component = fixture.componentInstance;
component.plan = mockPlan;
fixture.detectChanges();
});
it('renders three-line summary and impact section', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Remediation Plan');
expect(text).toContain('Upgrade lodash to a patched version.');
expect(text).toContain('Impact Assessment');
expect(text).toContain('Risk Score:');
});
it('toggles step expansion deterministically', () => {
expect(component.expandedSteps().has('s1')).toBeFalse();
component.toggleStep('s1');
expect(component.expandedSteps().has('s1')).toBeTrue();
component.toggleStep('s1');
expect(component.expandedSteps().has('s1')).toBeFalse();
});
it('emits createPr action from draft state footer', () => {
const emitSpy = spyOn(component.createPr, 'emit');
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Create Pull Request');
const buttons = fixture.nativeElement.querySelectorAll('button') as NodeListOf<HTMLButtonElement>;
const createPrButton = (Array.from(buttons) as HTMLButtonElement[]).find((b) =>
(b.textContent ?? '').includes('Create Pull Request')
) as HTMLButtonElement | undefined;
expect(createPrButton).toBeTruthy();
createPrButton!.click();
expect(emitSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,64 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { ChatMessageComponent } from '../../app/features/advisory-ai/chat/chat-message.component';
import { ConversationTurn } from '../../app/features/advisory-ai/chat/chat.models';
const assistantTurn: ConversationTurn = {
turnId: 'turn-2',
role: 'assistant',
content:
'Reachable through [reach:api-gateway:grpc.Server ↗] and runtime trace [runtime:api-gateway:trace-12 ↗].',
timestamp: '2026-02-10T21:00:00Z',
citations: [
{ type: 'reach', path: 'api-gateway:grpc.Server', verified: true },
{ type: 'runtime', path: 'api-gateway:trace-12', verified: false },
],
proposedActions: [
{ type: 'approve', label: 'Approve', enabled: true, requiredRole: 'approver' },
],
groundingScore: 0.88,
};
describe('ChatMessageComponent (advisory_ai_chat)', () => {
let fixture: ComponentFixture<ChatMessageComponent>;
let component: ChatMessageComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ChatMessageComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(ChatMessageComponent);
component = fixture.componentInstance;
component.turn = assistantTurn;
fixture.detectChanges();
});
it('renders assistant role and grounding score', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('AdvisoryAI');
expect(text).toContain('88%');
});
it('parses object links into link-chip components', () => {
const chips = fixture.nativeElement.querySelectorAll('stellaops-object-link-chip');
expect(chips.length).toBe(4);
});
it('renders proposed action buttons', () => {
const actions = fixture.nativeElement.querySelectorAll('stellaops-action-button');
expect(actions.length).toBe(1);
});
it('emits linkNavigate when a link is selected', () => {
const emitSpy = spyOn(component.linkNavigate, 'emit');
const firstLink = component.segments().find((s) => s.type === 'link');
expect(firstLink?.link).toBeDefined();
component.onLinkNavigate(firstLink!.link!);
expect(emitSpy).toHaveBeenCalledWith(firstLink!.link!);
});
});

View File

@@ -0,0 +1,118 @@
import { signal, WritableSignal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { AgentFleetDashboardComponent } from '../../app/features/agents/agent-fleet-dashboard.component';
import { AgentStore } from '../../app/features/agents/services/agent.store';
import { Agent } from '../../app/features/agents/models/agent.models';
type MockStore = jasmine.SpyObj<Partial<AgentStore>> & {
agents: WritableSignal<Agent[]>;
filteredAgents: WritableSignal<Agent[]>;
isLoading: WritableSignal<boolean>;
error: WritableSignal<string | null>;
summary: WritableSignal<{
totalAgents: number;
onlineAgents: number;
degradedAgents: number;
offlineAgents: number;
totalCapacityPercent: number;
totalActiveTasks: number;
certificatesExpiringSoon: number;
}>;
selectedAgentId: WritableSignal<string | null>;
lastRefresh: WritableSignal<string | null>;
uniqueEnvironments: WritableSignal<string[]>;
uniqueVersions: WritableSignal<string[]>;
isRealtimeConnected: WritableSignal<boolean>;
realtimeConnectionStatus: WritableSignal<string>;
};
function createMockStore(): MockStore {
return {
agents: signal([]),
filteredAgents: signal([]),
isLoading: signal(false),
error: signal(null),
summary: signal({
totalAgents: 2,
onlineAgents: 1,
degradedAgents: 1,
offlineAgents: 0,
totalCapacityPercent: 57,
totalActiveTasks: 4,
certificatesExpiringSoon: 0,
}),
selectedAgentId: signal(null),
lastRefresh: signal(null),
uniqueEnvironments: signal(['prod']),
uniqueVersions: signal(['2.5.0']),
isRealtimeConnected: signal(true),
realtimeConnectionStatus: signal('connected'),
fetchAgents: jasmine.createSpy('fetchAgents'),
fetchSummary: jasmine.createSpy('fetchSummary'),
enableRealtime: jasmine.createSpy('enableRealtime'),
disableRealtime: jasmine.createSpy('disableRealtime'),
reconnectRealtime: jasmine.createSpy('reconnectRealtime'),
startAutoRefresh: jasmine.createSpy('startAutoRefresh'),
stopAutoRefresh: jasmine.createSpy('stopAutoRefresh'),
setSearchFilter: jasmine.createSpy('setSearchFilter'),
setStatusFilter: jasmine.createSpy('setStatusFilter'),
setEnvironmentFilter: jasmine.createSpy('setEnvironmentFilter'),
setVersionFilter: jasmine.createSpy('setVersionFilter'),
clearFilters: jasmine.createSpy('clearFilters'),
} as unknown as MockStore;
}
describe('AgentFleetDashboardComponent (agent_fleet)', () => {
let fixture: ComponentFixture<AgentFleetDashboardComponent>;
let component: AgentFleetDashboardComponent;
let mockStore: MockStore;
beforeEach(async () => {
mockStore = createMockStore();
await TestBed.configureTestingModule({
imports: [AgentFleetDashboardComponent],
providers: [provideRouter([]), { provide: AgentStore, useValue: mockStore }],
}).compileComponents();
fixture = TestBed.createComponent(AgentFleetDashboardComponent);
component = fixture.componentInstance;
});
it('initializes dashboard by fetching fleet state', () => {
fixture.detectChanges();
expect(mockStore.fetchAgents).toHaveBeenCalled();
expect(mockStore.fetchSummary).toHaveBeenCalled();
expect(mockStore.enableRealtime).toHaveBeenCalled();
expect(mockStore.startAutoRefresh).toHaveBeenCalledWith(60000);
});
it('renders title and KPI strip', () => {
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Agent Fleet');
expect(text).toContain('Total Agents');
expect(text).toContain('Avg Capacity');
});
it('updates search filter through store', () => {
fixture.detectChanges();
component.onSearchInput({ target: { value: 'agent-prod-1' } } as unknown as Event);
expect(component.searchQuery()).toBe('agent-prod-1');
expect(mockStore.setSearchFilter).toHaveBeenCalledWith('agent-prod-1');
});
it('switches view mode deterministically', () => {
fixture.detectChanges();
expect(component.viewMode()).toBe('grid');
component.setViewMode('table');
expect(component.viewMode()).toBe('table');
});
});

View File

@@ -0,0 +1,54 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AiChipComponent } from '../../app/shared/components/ai/ai-chip.component';
describe('AiChipComponent (ai_chip_components)', () => {
let fixture: ComponentFixture<AiChipComponent>;
let component: AiChipComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AiChipComponent],
}).compileComponents();
fixture = TestBed.createComponent(AiChipComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('label', 'Explain');
fixture.detectChanges();
});
it('renders label and default action variant', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Explain');
expect(component.chipClass()).toContain('ai-chip--action');
});
it('applies warning variant and pressed state classes', () => {
fixture.componentRef.setInput('variant', 'warning');
fixture.componentRef.setInput('pressed', true);
fixture.detectChanges();
expect(component.chipClass()).toContain('ai-chip--warning');
expect(component.chipClass()).toContain('ai-chip--pressed');
});
it('emits click events when enabled', () => {
const emitSpy = spyOn(component.clicked, 'emit');
const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
button.click();
expect(emitSpy).toHaveBeenCalled();
});
it('does not emit click events when disabled', () => {
fixture.componentRef.setInput('disabled', true);
fixture.detectChanges();
const emitSpy = spyOn(component.clicked, 'emit');
component.handleClick(new MouseEvent('click'));
expect(emitSpy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,65 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
AiSummaryComponent,
AiSummaryExpanded,
} from '../../app/shared/components/ai/ai-summary.component';
describe('AiSummaryComponent (ai_chip_components)', () => {
let fixture: ComponentFixture<AiSummaryComponent>;
let component: AiSummaryComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AiSummaryComponent],
}).compileComponents();
fixture = TestBed.createComponent(AiSummaryComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('line1', 'What changed');
fixture.componentRef.setInput('line2', 'Why it matters');
fixture.componentRef.setInput('line3', 'Next action');
fixture.detectChanges();
});
it('renders the three-line summary content', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('What changed');
expect(text).toContain('Why it matters');
expect(text).toContain('Next action');
});
it('shows expandable affordance when more details are available', () => {
const expandedContent: AiSummaryExpanded = {
fullExplanation: 'Detailed explanation',
citations: [
{
claim: 'Reachability confirmed',
evidenceId: 'ev-1',
evidenceType: 'reachability',
verified: true,
},
],
};
fixture.componentRef.setInput('hasMore', true);
fixture.componentRef.setInput('expandedContent', expandedContent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Show details');
});
it('emits citationClick when citation is selected', () => {
const emitSpy = spyOn(component.citationClick, 'emit');
const citation = {
claim: 'Claim text',
evidenceId: 'ev-2',
evidenceType: 'runtime',
verified: false,
};
component.onCitationClick(citation);
expect(emitSpy).toHaveBeenCalledWith(citation);
});
});

View File

@@ -0,0 +1,96 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { AocClient } from '../../app/core/api/aoc.client';
import {
AocVerificationResult,
AocViolationDetail,
} from '../../app/core/api/aoc.models';
import { VerifyActionComponent } from '../../app/features/aoc/verify-action.component';
describe('VerifyActionComponent (aoc_verification)', () => {
let fixture: ComponentFixture<VerifyActionComponent>;
let component: VerifyActionComponent;
let mockClient: { verify: jasmine.Spy };
const mockViolation: AocViolationDetail = {
documentId: 'doc-1',
violationCode: 'AOC-PROV-001',
field: 'attestation.provenance',
expected: 'present',
actual: 'missing',
provenance: {
sourceId: 'source-registry-1',
ingestedAt: '2026-02-10T22:30:00Z',
digest: 'sha256:abc123',
},
};
const mockResult: AocVerificationResult = {
verificationId: 'verify-001',
status: 'failed',
checkedCount: 100,
passedCount: 98,
failedCount: 2,
violations: [mockViolation],
completedAt: '2026-02-10T22:31:00Z',
};
beforeEach(async () => {
mockClient = {
verify: jasmine.createSpy('verify').and.returnValue(of(mockResult)),
};
await TestBed.configureTestingModule({
imports: [VerifyActionComponent],
providers: [{ provide: AocClient, useValue: mockClient }],
}).compileComponents();
fixture = TestBed.createComponent(VerifyActionComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('tenantId', 'tenant-a');
fixture.componentRef.setInput('windowHours', 12);
fixture.detectChanges();
});
it('renders CLI parity command for configured tenant and window', () => {
expect(component.getCliCommand()).toBe(
'stella aoc verify --tenant tenant-a --since 12h'
);
component.toggleCliGuidance();
fixture.detectChanges();
expect(component.showCliGuidance()).toBeTrue();
expect((fixture.nativeElement.textContent as string)).toContain('CLI Parity');
});
it('runs verification and emits deterministic completion payload', async () => {
await component.runVerification();
fixture.detectChanges();
expect(mockClient.verify).toHaveBeenCalledTimes(1);
const recentCall = mockClient.verify.calls.mostRecent()!;
const request = recentCall.args[0] as {
tenantId: string;
limit: number;
since?: string;
};
expect(request.tenantId).toBe('tenant-a');
expect(request.limit).toBe(10000);
expect(typeof request.since).toBe('string');
expect(component.state()).toBe('completed');
expect(component.progress()).toBe(100);
expect(component.result()?.verificationId).toBe('verify-001');
});
it('emits selected violation from result preview interactions', () => {
let selected: AocViolationDetail | null = null;
component.selectViolation.subscribe((value) => (selected = value));
component.onSelectViolation(mockViolation);
expect(selected).toBe(mockViolation);
});
});

View File

@@ -0,0 +1,119 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
AocDocumentView,
AocViolationGroup,
} from '../../app/core/api/aoc.models';
import { ViolationDrilldownComponent } from '../../app/features/aoc/violation-drilldown.component';
describe('ViolationDrilldownComponent (aoc_verification)', () => {
let fixture: ComponentFixture<ViolationDrilldownComponent>;
let component: ViolationDrilldownComponent;
const violationGroups: AocViolationGroup[] = [
{
code: 'AOC-PROV-001',
description: 'Missing provenance',
severity: 'high',
affectedDocuments: 1,
remediation: 'Attach provenance',
violations: [
{
documentId: 'doc-1',
violationCode: 'AOC-PROV-001',
field: 'attestation.provenance',
expected: 'present',
actual: 'missing',
provenance: {
sourceId: 'src-1',
ingestedAt: '2026-02-10T22:35:00Z',
digest: 'sha256:abc',
sourceType: 'registry',
},
},
],
},
{
code: 'AOC-SCHEMA-002',
description: 'Schema mismatch',
severity: 'medium',
affectedDocuments: 1,
violations: [
{
documentId: 'doc-2',
violationCode: 'AOC-SCHEMA-002',
field: 'metadata.version',
expected: '1.0',
actual: '0.9',
},
],
},
];
const documentViews: AocDocumentView[] = [
{
documentId: 'doc-1',
documentType: 'sbom',
violations: violationGroups[0].violations,
provenance: {
sourceId: 'src-1',
ingestedAt: '2026-02-10T22:35:00Z',
digest: 'sha256:abc',
sourceType: 'registry',
},
rawContent: {
attestation: {
provenance: null,
},
},
highlightedFields: ['attestation.provenance'],
},
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ViolationDrilldownComponent],
}).compileComponents();
fixture = TestBed.createComponent(ViolationDrilldownComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('violationGroups', violationGroups);
fixture.componentRef.setInput('documentViews', documentViews);
fixture.detectChanges();
});
it('initializes in by-violation mode and computes summary counts', () => {
expect(component.viewMode()).toBe('by-violation');
expect(component.totalViolations()).toBe(2);
expect(component.totalDocuments()).toBe(2);
expect(component.severityCounts().high).toBe(1);
expect(component.severityCounts().medium).toBe(1);
});
it('supports toggling to by-document mode and filtering', () => {
component.setViewMode('by-document');
expect(component.viewMode()).toBe('by-document');
component.searchFilter.set('doc-1');
expect(component.filteredDocuments().length).toBe(1);
expect(component.filteredDocuments()[0].documentId).toBe('doc-1');
component.searchFilter.set('schema');
expect(component.filteredGroups().length).toBe(1);
expect(component.filteredGroups()[0].code).toBe('AOC-SCHEMA-002');
});
it('emits raw-document requests and resolves nested field values', () => {
let rawDocId: string | null = null;
component.viewRawDocument.subscribe((value) => (rawDocId = value));
component.onViewRaw('doc-1');
expect(rawDocId).toBe('doc-1');
const fieldValue = component.getFieldValue(
documentViews[0].rawContent,
'attestation.provenance'
);
expect(fieldValue).toBe('null');
});
});

View File

@@ -0,0 +1,65 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { ApprovalDetailPageComponent } from '../../app/features/approvals/approval-detail-page.component';
describe('ApprovalDetailPageComponent (approvals)', () => {
let fixture: ComponentFixture<ApprovalDetailPageComponent>;
let component: ApprovalDetailPageComponent;
let params$: BehaviorSubject<Record<string, string>>;
beforeEach(async () => {
params$ = new BehaviorSubject<Record<string, string>>({ id: 'APPR-2026-045' });
await TestBed.configureTestingModule({
imports: [ApprovalDetailPageComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: { params: params$.asObservable() },
},
],
}).compileComponents();
fixture = TestBed.createComponent(ApprovalDetailPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('binds route id and opens witness details from security diff rows', () => {
expect(component.approvalId()).toBe('APPR-2026-045');
component.openWitness('CVE-2026-1234');
const witness = component.selectedWitness();
expect(witness).toBeTruthy();
expect(witness!.findingId).toBe('CVE-2026-1234');
expect(witness!.state).toBe('reachable');
});
it('supports witness close and approval decision lifecycle actions', () => {
component.openWitness('CVE-2026-5678');
expect(component.selectedWitness()).toBeTruthy();
component.closeWitness();
expect(component.selectedWitness()).toBeNull();
component.approve();
expect(component.approval().status).toBe('approved');
expect(component.approval().decidedBy).toBe('Current User');
component.reject();
expect(component.approval().status).toBe('rejected');
});
it('adds comments and clears input', () => {
component.newComment = 'Reviewed witness details and accepted risk.';
component.addComment();
expect(component.comments.length).toBe(1);
expect(component.comments[0].body).toContain('Reviewed witness details');
expect(component.newComment).toBe('');
});
});

View File

@@ -0,0 +1,47 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { ApprovalsInboxComponent } from '../../app/features/approvals/approvals-inbox.component';
describe('ApprovalsInboxComponent (approvals)', () => {
let fixture: ComponentFixture<ApprovalsInboxComponent>;
let component: ApprovalsInboxComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ApprovalsInboxComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(ApprovalsInboxComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('renders pending approvals with diff-first change summaries', () => {
const text = fixture.nativeElement.textContent as string;
expect(component.pendingApprovals.length).toBe(3);
expect(text).toContain('Pending (3)');
expect(text).toContain('WHAT CHANGED');
expect(text).toContain('v1.2.5');
});
it('shows gate states and detail actions for each approval card', () => {
const cardElements = fixture.nativeElement.querySelectorAll('.approval-card');
const detailLinks = fixture.nativeElement.querySelectorAll('a.btn.btn--secondary');
const text = fixture.nativeElement.textContent as string;
expect(cardElements.length).toBe(3);
expect(detailLinks.length).toBeGreaterThanOrEqual(3);
expect(text).toContain('PASS');
expect(text).toContain('WARN');
expect(text).toContain('BLOCK');
expect(text).toContain('View Details');
});
it('contains evidence action links for triage follow-up', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Open Evidence');
});
});

View File

@@ -0,0 +1,50 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScoreFlag } from '../../app/core/api/scoring.models';
import { ScoreBadgeComponent } from '../../app/shared/components/score/score-badge.component';
describe('ScoreBadgeComponent (attested_score)', () => {
let fixture: ComponentFixture<ScoreBadgeComponent>;
let component: ScoreBadgeComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScoreBadgeComponent],
}).compileComponents();
fixture = TestBed.createComponent(ScoreBadgeComponent);
component = fixture.componentInstance;
});
it('renders anchored badge style and metadata', () => {
fixture.componentRef.setInput('type', 'anchored' as ScoreFlag);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.score-badge') as HTMLElement;
expect(component.shouldGlow()).toBeTrue();
expect(component.shouldAlert()).toBeFalse();
expect(badge.classList.contains('anchored-glow')).toBeTrue();
expect((fixture.nativeElement.textContent as string)).toContain('Anchored');
});
it('renders hard-fail alert style and label', () => {
fixture.componentRef.setInput('type', 'hard-fail' as ScoreFlag);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.score-badge') as HTMLElement;
expect(component.shouldAlert()).toBeTrue();
expect(component.shouldGlow()).toBeFalse();
expect(badge.classList.contains('alert')).toBeTrue();
expect((fixture.nativeElement.textContent as string)).toContain('Hard Fail');
});
it('provides descriptive aria label for assistive technologies', () => {
fixture.componentRef.setInput('type', 'anchored' as ScoreFlag);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.score-badge') as HTMLElement;
expect(badge.getAttribute('role')).toBe('status');
expect(badge.getAttribute('aria-label')).toContain('Anchored');
expect(badge.getAttribute('aria-label')).toContain('attestation');
});
});

View File

@@ -0,0 +1,107 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EvidenceWeightedScoreResult } from '../../app/core/api/scoring.models';
import { ScoreBreakdownPopoverComponent } from '../../app/shared/components/score/score-breakdown-popover.component';
describe('ScoreBreakdownPopoverComponent (attested_score)', () => {
let fixture: ComponentFixture<ScoreBreakdownPopoverComponent>;
let component: ScoreBreakdownPopoverComponent;
const baseScoreResult: EvidenceWeightedScoreResult = {
findingId: 'CVE-2026-1234@pkg:npm/example@1.0.0',
score: 88,
bucket: 'ScheduleNext',
inputs: {
rch: 0.8,
rts: 0.6,
bkp: 0.3,
xpl: 0.5,
src: 0.9,
mit: 0.2,
},
weights: {
rch: 0.3,
rts: 0.25,
bkp: 0.15,
xpl: 0.15,
src: 0.1,
mit: 0.1,
},
flags: ['anchored', 'hard-fail'],
explanations: ['Reachable call path observed', 'KEV signal is active'],
caps: {
speculativeCap: false,
notAffectedCap: false,
runtimeFloor: true,
},
policyDigest: 'sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
calculatedAt: '2026-02-10T22:45:00Z',
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScoreBreakdownPopoverComponent],
}).compileComponents();
fixture = TestBed.createComponent(ScoreBreakdownPopoverComponent);
component = fixture.componentInstance;
});
it('renders reduction, hard-fail, and proof-anchor sections when provided', () => {
fixture.componentRef.setInput('scoreResult', {
...baseScoreResult,
reductionProfile: {
mode: 'aggressive',
originalScore: 95,
reductionAmount: 7,
reductionFactor: 0.0736,
contributingEvidence: ['vex', 'backport'],
cappedByPolicy: false,
},
isHardFail: true,
hardFailStatus: 'kev',
shortCircuitReason: 'hard_fail_kev',
proofAnchor: {
anchored: true,
dsseDigest: 'sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
rekorLogIndex: 4242,
rekorEntryId: 'entry-1',
attestationUri: 'https://example.local/attestations/entry-1',
verificationStatus: 'verified',
},
});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.reduction-section')).toBeTruthy();
expect(fixture.nativeElement.querySelector('.hard-fail-section')).toBeTruthy();
expect(fixture.nativeElement.querySelector('.anchor-section')).toBeTruthy();
expect((fixture.nativeElement.textContent as string)).toContain('Rekor Log Index');
expect((fixture.nativeElement.textContent as string)).toContain('Reduction Profile');
});
it('omits optional attested sections when no related metadata is present', () => {
fixture.componentRef.setInput('scoreResult', {
...baseScoreResult,
flags: ['live-signal'],
isHardFail: false,
hardFailStatus: 'none',
reductionProfile: undefined,
proofAnchor: { anchored: false },
});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.reduction-section')).toBeNull();
expect(fixture.nativeElement.querySelector('.hard-fail-section')).toBeNull();
expect(fixture.nativeElement.querySelector('.anchor-section')).toBeNull();
});
it('emits close when escape handler is triggered', () => {
fixture.componentRef.setInput('scoreResult', baseScoreResult);
fixture.detectChanges();
const closeSpy = spyOn(component.close, 'emit');
component.onEscapeKey();
expect(closeSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,107 @@
import {
ComponentFixture,
TestBed,
} from '@angular/core/testing';
import {
ActivatedRoute,
convertToParamMap,
provideRouter,
} from '@angular/router';
import { of } from 'rxjs';
import {
AUDIT_BUNDLES_API,
} from '../../app/core/api/audit-bundles.client';
import { TriageAuditBundleNewComponent } from '../../app/features/triage/triage-audit-bundle-new.component';
describe('TriageAuditBundleNewComponent (audit_bundle)', () => {
let fixture: ComponentFixture<TriageAuditBundleNewComponent>;
let component: TriageAuditBundleNewComponent;
let api: {
createBundle: jasmine.Spy;
getBundle: jasmine.Spy;
downloadBundle: jasmine.Spy;
listBundles: jasmine.Spy;
};
beforeEach(async () => {
api = {
createBundle: jasmine.createSpy('createBundle'),
getBundle: jasmine.createSpy('getBundle'),
downloadBundle: jasmine.createSpy('downloadBundle'),
listBundles: jasmine.createSpy('listBundles'),
};
api.createBundle.and.returnValue(
of({
bundleId: 'bndl-0001',
status: 'queued',
createdAt: '2026-02-10T22:20:00Z',
subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'abc' } },
})
);
api.getBundle.and.returnValue(
of({
bundleId: 'bndl-0001',
status: 'completed',
createdAt: '2026-02-10T22:20:00Z',
subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'abc' } },
sha256: 'sha256:bundle',
})
);
api.downloadBundle.and.returnValue(
of(new Blob(['{}'], { type: 'application/json' }))
);
await TestBed.configureTestingModule({
imports: [TriageAuditBundleNewComponent],
providers: [
provideRouter([]),
{ provide: AUDIT_BUNDLES_API, useValue: api },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({ artifactId: 'asset-web-prod' }),
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(TriageAuditBundleNewComponent);
component = fixture.componentInstance;
});
it('prefills subject fields from artifact query parameter', () => {
fixture.detectChanges();
expect(component.subjectName()).toBe('asset-web-prod');
expect(component.subjectDigest()).toBe('asset-web-prod');
});
it('advances and rewinds wizard steps deterministically', () => {
fixture.detectChanges();
expect(component.step()).toBe('subject');
component.next();
expect(component.step()).toBe('contents');
component.next();
expect(component.step()).toBe('review');
component.back();
expect(component.step()).toBe('contents');
});
it('submits create request and transitions to progress tracking', async () => {
fixture.detectChanges();
component.subjectName.set('asset-web-prod');
component.subjectDigest.set('sha256:abc');
await component.create();
await fixture.whenStable();
expect(api.createBundle).toHaveBeenCalledTimes(1);
expect(component.step()).toBe('progress');
expect(component.job()?.bundleId).toBe('bndl-0001');
});
});

View File

@@ -0,0 +1,77 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import {
AUDIT_BUNDLES_API,
} from '../../app/core/api/audit-bundles.client';
import { AuditBundleJobResponse } from '../../app/core/api/audit-bundles.models';
import { TriageAuditBundlesComponent } from '../../app/features/triage/triage-audit-bundles.component';
describe('TriageAuditBundlesComponent (audit_bundle)', () => {
let fixture: ComponentFixture<TriageAuditBundlesComponent>;
let component: TriageAuditBundlesComponent;
let api: {
listBundles: jasmine.Spy;
downloadBundle: jasmine.Spy;
createBundle: jasmine.Spy;
getBundle: jasmine.Spy;
};
const bundle: AuditBundleJobResponse = {
bundleId: 'bndl-1234',
status: 'completed',
createdAt: '2026-02-10T22:24:00Z',
subject: {
type: 'IMAGE',
name: 'asset-web-prod',
digest: { sha256: 'abc' },
},
sha256: 'sha256:bundle',
};
beforeEach(async () => {
api = {
listBundles: jasmine.createSpy('listBundles'),
downloadBundle: jasmine.createSpy('downloadBundle'),
createBundle: jasmine.createSpy('createBundle'),
getBundle: jasmine.createSpy('getBundle'),
};
api.listBundles.and.returnValue(of({ items: [bundle], count: 1 }));
api.downloadBundle.and.returnValue(
of(new Blob(['bundle-json'], { type: 'application/json' }))
);
await TestBed.configureTestingModule({
imports: [TriageAuditBundlesComponent],
providers: [provideRouter([]), { provide: AUDIT_BUNDLES_API, useValue: api }],
}).compileComponents();
fixture = TestBed.createComponent(TriageAuditBundlesComponent);
component = fixture.componentInstance;
});
it('loads audit bundles during initialization', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(api.listBundles).toHaveBeenCalledTimes(1);
expect(component.completedBundles().length).toBe(1);
expect(component.completedBundles()[0].bundleId).toBe('bndl-1234');
});
it('downloads selected bundle via API and browser object URL', async () => {
fixture.detectChanges();
const createUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:mock');
const revokeSpy = spyOn(URL, 'revokeObjectURL');
const clickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.stub();
await component.download(bundle);
expect(api.downloadBundle).toHaveBeenCalledWith('bndl-1234');
expect(createUrlSpy).toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalled();
expect(revokeSpy).toHaveBeenCalledWith('blob:mock');
});
});

View File

@@ -0,0 +1,144 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { EvidenceRibbonService } from '../../app/features/evidence-ribbon/services/evidence-ribbon.service';
import {
AuditActionResult,
ExportOptions,
ExportResult,
QuietTriageItem,
ReviewRibbonSummary,
} from '../../app/features/workspaces/auditor/models/auditor-workspace.models';
import { AuditorWorkspaceService } from '../../app/features/workspaces/auditor/services/auditor-workspace.service';
import { AuditorWorkspaceComponent } from '../../app/features/workspaces/auditor/components/auditor-workspace/auditor-workspace.component';
describe('AuditorWorkspaceComponent (auditor_workspace)', () => {
let fixture: ComponentFixture<AuditorWorkspaceComponent>;
let component: AuditorWorkspaceComponent;
let workspaceService: {
loading: ReturnType<typeof signal<boolean>>;
error: ReturnType<typeof signal<string | null>>;
reviewSummary: ReturnType<typeof signal<ReviewRibbonSummary | null>>;
quietTriageItems: ReturnType<typeof signal<QuietTriageItem[]>>;
exportStatus: ReturnType<typeof signal<'idle' | 'preparing' | 'exporting' | 'complete' | 'error'>>;
exportResult: ReturnType<typeof signal<ExportResult | null>>;
loadWorkspace: jasmine.Spy;
exportAuditPack: jasmine.Spy;
performAuditAction: jasmine.Spy;
clear: jasmine.Spy;
};
const mockSummary: ReviewRibbonSummary = {
policyVerdict: 'pass',
policyPackName: 'production-security',
policyVersion: '2.1.0',
attestationStatus: 'verified',
coverageScore: 94,
openExceptionsCount: 1,
evaluatedAt: '2026-02-10T22:28:00Z',
};
const mockItem: QuietTriageItem = {
id: 'qt-1',
findingId: 'finding-1',
cveId: 'CVE-2026-1000',
title: 'Potential parser weakness',
severity: 'low',
confidence: 'low',
componentName: 'parser-lib',
componentVersion: '1.2.3',
addedAt: '2026-02-10T22:00:00Z',
reason: 'Low confidence from static analysis',
};
beforeEach(async () => {
workspaceService = {
loading: signal(false),
error: signal<string | null>(null),
reviewSummary: signal<ReviewRibbonSummary | null>(mockSummary),
quietTriageItems: signal<QuietTriageItem[]>([mockItem]),
exportStatus: signal<'idle' | 'preparing' | 'exporting' | 'complete' | 'error'>('idle'),
exportResult: signal<ExportResult | null>(null),
loadWorkspace: jasmine.createSpy('loadWorkspace').and.returnValue(of(void 0)),
exportAuditPack: jasmine
.createSpy('exportAuditPack')
.and.returnValue(
of({
success: true,
filename: 'audit-pack.zip',
checksumAlgorithm: 'SHA-256',
checksum: 'sha256:abc',
completedAt: '2026-02-10T22:29:00Z',
} as ExportResult)
),
performAuditAction: jasmine
.createSpy('performAuditAction')
.and.returnValue(
of({
success: true,
actionType: 'recheck',
itemId: 'qt-1',
timestamp: '2026-02-10T22:29:30Z',
} as AuditActionResult)
),
clear: jasmine.createSpy('clear'),
};
const evidenceRibbonService = {
loading: signal(false),
dsseStatus: signal(null),
rekorStatus: signal(null),
sbomStatus: signal(null),
vexStatus: signal(null),
policyStatus: signal(null),
loadEvidenceStatus: jasmine.createSpy('loadEvidenceStatus').and.returnValue(of(undefined)),
clear: jasmine.createSpy('clear'),
};
await TestBed.configureTestingModule({
imports: [AuditorWorkspaceComponent],
providers: [
provideRouter([]),
{ provide: AuditorWorkspaceService, useValue: workspaceService },
{ provide: EvidenceRibbonService, useValue: evidenceRibbonService },
],
}).compileComponents();
fixture = TestBed.createComponent(AuditorWorkspaceComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('artifactDigest', 'sha256:abc123def456');
fixture.detectChanges();
});
it('loads workspace data for the selected artifact digest', () => {
expect(workspaceService.loadWorkspace).toHaveBeenCalledWith(
'sha256:abc123def456'
);
});
it('updates export options and triggers audit-pack export', () => {
const checkboxEvent = {
target: { checked: true },
} as unknown as Event;
component.updateExportOption('includePqc', checkboxEvent);
expect(component.exportOptions().includePqc).toBeTrue();
component.startExport();
expect(workspaceService.exportAuditPack).toHaveBeenCalledWith(
'sha256:abc123def456',
jasmine.any(Object)
);
});
it('dispatches signed quiet-triage action requests', () => {
component.onAuditAction(mockItem, 'recheck');
expect(workspaceService.performAuditAction).toHaveBeenCalledWith(
'qt-1',
'recheck'
);
});
});

View File

@@ -0,0 +1,76 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FunctionChangeInfo } from '../../app/core/api/binary-resolution.models';
import { FunctionDiffComponent } from '../../app/shared/components/function-diff/function-diff.component';
describe('FunctionDiffComponent (backport_resolution)', () => {
let fixture: ComponentFixture<FunctionDiffComponent>;
let component: FunctionDiffComponent;
const baseChange: FunctionChangeInfo = {
name: 'parse_input',
changeType: 'Modified',
similarity: 0.92,
vulnerableOffset: 4096,
patchedOffset: 4112,
vulnerableDisasm: ['mov eax, 1', 'ret'],
patchedDisasm: ['mov eax, 2', 'ret'],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FunctionDiffComponent],
}).compileComponents();
fixture = TestBed.createComponent(FunctionDiffComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput(
'functionChange',
{
...baseChange,
beforeHash: 'sha256:before-1234',
afterHash: 'sha256:after-5678',
patchCommit: 'patch-commit-001',
} as unknown as FunctionChangeInfo
);
fixture.detectChanges();
});
it('renders function name and disassembly from typed FunctionChangeInfo payload', () => {
const text = fixture.nativeElement.textContent as string;
expect(component.functionName()).toBe('parse_input');
expect(component.formatBeforeLines()).toContain('mov eax, 1');
expect(component.formatAfterLines()).toContain('mov eax, 2');
expect(text).toContain('parse_input');
expect(text).toContain('92% similar');
});
it('cycles view modes deterministically and shows summary metadata', () => {
expect(component.currentViewMode()).toBe('side-by-side');
component.cycleViewMode();
fixture.detectChanges();
expect(component.currentViewMode()).toBe('unified');
component.cycleViewMode();
fixture.detectChanges();
expect(component.currentViewMode()).toBe('summary');
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Change Type');
expect(text).toContain('Patch ID');
});
it('collapses and expands content body from the header toggle', () => {
expect(fixture.nativeElement.querySelector('.function-diff__body')).toBeTruthy();
const toggle = fixture.nativeElement.querySelector('.function-diff__collapse-toggle') as HTMLButtonElement;
toggle.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.function-diff__body')).toBeFalsy();
toggle.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.function-diff__body')).toBeTruthy();
});
});

View File

@@ -0,0 +1,112 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
BinaryDiffData,
BinaryDiffPanelComponent,
} from '../../app/shared/components/binary-diff/binary-diff-panel.component';
describe('BinaryDiffPanelComponent (binary_diff)', () => {
let fixture: ComponentFixture<BinaryDiffPanelComponent>;
let component: BinaryDiffPanelComponent;
const data: BinaryDiffData = {
baseDigest: 'sha256:base-image-1234567890',
baseName: 'nginx:1.24.0',
candidateDigest: 'sha256:candidate-image-1234567890',
candidateName: 'nginx:1.24.1',
entries: [
{
id: 'file-1',
name: '/usr/lib/libssl.so',
type: 'file',
changeType: 'modified',
baseHash: 'basehash1',
candidateHash: 'candhash1',
children: [
{
id: 'fn-1',
name: 'parse_input',
type: 'function',
changeType: 'modified',
baseHash: 'basefn1',
candidateHash: 'candfn1',
},
],
},
{
id: 'file-2',
name: '/usr/lib/libz.so',
type: 'file',
changeType: 'unchanged',
},
],
diffLines: [
{ lineNumber: 1, type: 'context', baseContent: 'mov eax, 1', candidateContent: 'mov eax, 1' },
{ lineNumber: 2, type: 'modified', baseContent: 'cmp ebx, 1', candidateContent: 'cmp ebx, 2' },
{ lineNumber: 3, type: 'added', candidateContent: 'call mitigation' },
],
stats: {
added: 1,
removed: 0,
modified: 1,
unchanged: 1,
},
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BinaryDiffPanelComponent],
}).compileComponents();
fixture = TestBed.createComponent(BinaryDiffPanelComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('data', data);
fixture.detectChanges();
});
it('switches scope and emits scope change events', () => {
spyOn(component.scopeChange, 'emit');
const scopeButtons = fixture.nativeElement.querySelectorAll('.scope-btn') as NodeListOf<HTMLButtonElement>;
scopeButtons[1].click();
fixture.detectChanges();
expect(component.scopeChange.emit).toHaveBeenCalledWith({
scope: 'section',
entry: undefined,
});
});
it('selects tree entry and exposes selected hashes in footer', () => {
spyOn(component.scopeChange, 'emit');
const firstEntry = fixture.nativeElement.querySelector('.tree-item') as HTMLElement;
firstEntry.click();
fixture.detectChanges();
expect(component.scopeChange.emit).toHaveBeenCalledWith(
jasmine.objectContaining({ scope: 'file' })
);
const footerText = fixture.nativeElement.querySelector('.diff-footer')?.textContent as string;
expect(footerText).toContain('Base Hash');
expect(footerText).toContain('basehash1');
});
it('filters context lines when show-only-changed is enabled and emits export payload', () => {
const checkbox = fixture.nativeElement.querySelector('.toggle-option input') as HTMLInputElement;
checkbox.click();
fixture.detectChanges();
const lines = fixture.nativeElement.querySelectorAll('.diff-line');
expect(lines.length).toBe(2);
spyOn(component.exportDiff, 'emit');
const exportButton = fixture.nativeElement.querySelector('.export-btn') as HTMLButtonElement;
exportButton.click();
expect(component.exportDiff.emit).toHaveBeenCalledWith({
format: 'dsse',
data,
});
});
});

View File

@@ -0,0 +1,217 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import {
BinaryFingerprintExport,
BinaryIndexBenchResponse,
BinaryIndexEffectiveConfig,
BinaryIndexFunctionCacheStats,
BinaryIndexOpsClient,
BinaryIndexOpsHealthResponse,
FingerprintExportEntry,
} from '../../app/core/api/binary-index-ops.client';
import { BinaryIndexOpsComponent } from '../../app/features/binary-index/binary-index-ops.component';
describe('BinaryIndexOpsComponent (binary_index)', () => {
let fixture: ComponentFixture<BinaryIndexOpsComponent>;
let component: BinaryIndexOpsComponent;
let client: {
getHealth: jasmine.Spy;
runBench: jasmine.Spy;
getCacheStats: jasmine.Spy;
getEffectiveConfig: jasmine.Spy;
exportFingerprint: jasmine.Spy;
listFingerprintExports: jasmine.Spy;
getFingerprintDownloadUrl: jasmine.Spy;
};
const mockHealth: BinaryIndexOpsHealthResponse = {
status: 'healthy',
timestamp: '2026-02-10T22:35:00Z',
components: [
{
name: 'b2r2-lifter-pool',
status: 'healthy',
message: 'Warm preload complete',
lastCheckAt: '2026-02-10T22:34:55Z',
},
],
lifterWarmness: [
{
isa: 'x86_64',
warm: true,
poolSize: 4,
availableCount: 3,
lastUsedAt: '2026-02-10T22:34:00Z',
},
],
cacheStatus: {
connected: true,
backend: 'valkey',
},
};
const mockBench: BinaryIndexBenchResponse = {
timestamp: '2026-02-10T22:35:30Z',
sampleSize: 10,
latencySummary: {
min: 4.2,
max: 19.1,
mean: 9.4,
p50: 8.8,
p95: 17.2,
p99: 18.7,
},
operations: [
{ operation: 'lift-function', latencyMs: 8.2, success: true },
{ operation: 'cache-write', latencyMs: 5.4, success: true },
],
};
const mockCache: BinaryIndexFunctionCacheStats = {
enabled: true,
backend: 'valkey',
hits: 120,
misses: 30,
evictions: 2,
hitRate: 0.8,
keyPrefix: 'binidx:',
cacheTtlSeconds: 3600,
estimatedEntries: 300,
estimatedMemoryBytes: 123456,
};
const mockConfig: BinaryIndexEffectiveConfig = {
b2r2Pool: {
maxPoolSizePerIsa: 4,
warmPreload: true,
acquireTimeoutMs: 500,
enableMetrics: true,
},
semanticLifting: {
b2r2Version: '0.7.0',
normalizationRecipeVersion: 'v2',
maxInstructionsPerFunction: 10000,
maxFunctionsPerBinary: 5000,
functionLiftTimeoutMs: 1000,
enableDeduplication: true,
},
functionCache: {
enabled: true,
backend: 'valkey',
keyPrefix: 'binidx:',
cacheTtlSeconds: 3600,
maxTtlSeconds: 86400,
earlyExpiryPercent: 10,
maxEntrySizeBytes: 1048576,
},
persistence: {
schema: 'binary_index',
minPoolSize: 2,
maxPoolSize: 20,
commandTimeoutSeconds: 30,
retryOnFailure: true,
batchSize: 500,
},
versions: {
binaryIndex: '1.0.0',
b2r2: '0.7.0',
valkey: '8.0',
postgresql: '16',
},
};
const mockFingerprint: BinaryFingerprintExport = {
digest: 'sha256:abc123',
format: 'json',
architecture: 'x86_64',
endianness: 'little',
exportedAt: '2026-02-10T22:36:00Z',
functions: [],
sections: [],
symbols: [],
metadata: {
totalFunctions: 0,
totalSections: 0,
totalSymbols: 0,
binarySize: 128000,
normalizationRecipe: 'v2',
},
};
const mockExports: readonly FingerprintExportEntry[] = [
{
id: 'exp-1',
digest: 'sha256:abc123',
exportedAt: '2026-02-10T22:36:00Z',
format: 'json',
size: 2048,
downloadUrl: 'https://example.local/export/exp-1',
},
];
beforeEach(async () => {
client = {
getHealth: jasmine.createSpy('getHealth'),
runBench: jasmine.createSpy('runBench'),
getCacheStats: jasmine.createSpy('getCacheStats'),
getEffectiveConfig: jasmine.createSpy('getEffectiveConfig'),
exportFingerprint: jasmine.createSpy('exportFingerprint'),
listFingerprintExports: jasmine.createSpy('listFingerprintExports'),
getFingerprintDownloadUrl: jasmine.createSpy('getFingerprintDownloadUrl'),
};
client.getHealth.and.returnValue(of(mockHealth));
client.runBench.and.returnValue(of(mockBench));
client.getCacheStats.and.returnValue(of(mockCache));
client.getEffectiveConfig.and.returnValue(of(mockConfig));
client.exportFingerprint.and.returnValue(of(mockFingerprint));
client.listFingerprintExports.and.returnValue(of(mockExports));
client.getFingerprintDownloadUrl.and.returnValue(
of({ url: 'https://example.local/export/exp-1' })
);
await TestBed.configureTestingModule({
imports: [BinaryIndexOpsComponent],
providers: [{ provide: BinaryIndexOpsClient, useValue: client as unknown as BinaryIndexOpsClient }],
}).compileComponents();
fixture = TestBed.createComponent(BinaryIndexOpsComponent);
component = fixture.componentInstance;
});
afterEach(() => {
fixture.destroy();
});
it('loads binary-index health data on initialization', () => {
fixture.detectChanges();
expect(client.getHealth).toHaveBeenCalledTimes(1);
expect(component.health()?.status).toBe('healthy');
expect(component.overallStatus()).toBe('healthy');
});
it('loads fingerprint export list when switching to fingerprint tab', () => {
fixture.detectChanges();
component.setTab('fingerprint');
expect(client.listFingerprintExports).toHaveBeenCalled();
expect(component.fingerprintExports().length).toBe(1);
});
it('exports fingerprint for provided digest and stores current result', () => {
fixture.detectChanges();
component.fingerprintDigest.set('sha256:abc123');
component.fingerprintFormat.set('json');
component.exportFingerprint();
expect(client.exportFingerprint).toHaveBeenCalledWith({
digest: 'sha256:abc123',
format: 'json',
});
expect(component.currentFingerprint()?.digest).toBe('sha256:abc123');
expect(component.fingerprintExporting()).toBeFalse();
});
});

View File

@@ -0,0 +1,138 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import {
PATCH_COVERAGE_API,
PatchCoverageApi,
} from '../../app/core/api/patch-coverage.client';
import {
PatchCoverageDetails,
PatchCoverageResult,
PatchMatchPage,
} from '../../app/core/api/patch-coverage.models';
import { PatchMapComponent } from '../../app/features/binary-index/patch-map.component';
describe('PatchMapComponent (binary_index)', () => {
let fixture: ComponentFixture<PatchMapComponent>;
let component: PatchMapComponent;
let api: {
getCoverage: jasmine.Spy;
getCoverageDetails: jasmine.Spy;
getMatchingImages: jasmine.Spy;
};
const coverage: PatchCoverageResult = {
entries: [
{
cveId: 'CVE-2026-1000',
packageName: 'openssl',
vulnerableCount: 3,
patchedCount: 7,
unknownCount: 1,
symbolCount: 4,
coveragePercent: 70,
lastUpdatedAt: '2026-02-10T22:40:00Z',
},
],
totalCount: 1,
offset: 0,
limit: 50,
};
const details: PatchCoverageDetails = {
cveId: 'CVE-2026-1000',
packageName: 'openssl',
functions: [
{
symbolName: 'parse_input',
vulnerableCount: 2,
patchedCount: 5,
unknownCount: 0,
hasDelta: true,
},
],
summary: {
totalImages: 11,
vulnerableImages: 3,
patchedImages: 7,
unknownImages: 1,
overallCoverage: 70,
symbolCount: 4,
deltaPairCount: 2,
},
};
const matches: PatchMatchPage = {
matches: [
{
matchId: 'match-1',
binaryKey: 'sha256:binary-1',
symbolName: 'parse_input',
matchState: 'patched',
confidence: 0.92,
scannedAt: '2026-02-10T22:40:30Z',
},
],
totalCount: 1,
offset: 0,
limit: 20,
};
beforeEach(async () => {
api = {
getCoverage: jasmine.createSpy('getCoverage'),
getCoverageDetails: jasmine.createSpy('getCoverageDetails'),
getMatchingImages: jasmine.createSpy('getMatchingImages'),
};
api.getCoverage.and.returnValue(of(coverage));
api.getCoverageDetails.and.returnValue(of(details));
api.getMatchingImages.and.returnValue(of(matches));
TestBed.overrideComponent(PatchMapComponent, {
set: {
providers: [{ provide: PATCH_COVERAGE_API, useValue: api as unknown as PatchCoverageApi }],
},
});
await TestBed.configureTestingModule({
imports: [PatchMapComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(PatchMapComponent);
component = fixture.componentInstance;
});
it('loads coverage heatmap data on init', () => {
fixture.detectChanges();
expect(api.getCoverage).toHaveBeenCalledWith({
package: undefined,
limit: 50,
offset: 0,
});
expect(component.heatmapCells().length).toBe(1);
expect(component.heatmapCells()[0].cveId).toBe('CVE-2026-1000');
});
it('loads details and matching images for selected CVE and symbol', () => {
fixture.detectChanges();
component.selectCve('CVE-2026-1000');
expect(api.getCoverageDetails).toHaveBeenCalledWith('CVE-2026-1000');
expect(component.viewMode()).toBe('details');
expect(component.coverageDetails()?.summary.patchedImages).toBe(7);
component.viewMatches('parse_input');
expect(api.getMatchingImages).toHaveBeenCalledWith({
cveId: 'CVE-2026-1000',
symbol: 'parse_input',
state: undefined,
limit: 20,
offset: 0,
});
expect(component.viewMode()).toBe('matches');
expect(component.matchPage()?.matches.length).toBe(1);
});
});

View File

@@ -0,0 +1,89 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import {
CaseHeaderComponent,
CaseHeaderData,
} from '../../app/features/triage/components/case-header/case-header.component';
describe('CaseHeaderComponent (can_i_ship)', () => {
let fixture: ComponentFixture<CaseHeaderComponent>;
let component: CaseHeaderComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CaseHeaderComponent, NoopAnimationsModule],
}).compileComponents();
fixture = TestBed.createComponent(CaseHeaderComponent);
component = fixture.componentInstance;
});
it('renders verdict label and actionable counts', () => {
fixture.componentRef.setInput('data', createData({ verdict: 'ship' }));
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('CAN SHIP');
expect(text).toContain('2 Critical');
expect(text).toContain('4 High');
expect(text).toContain('7 need attention');
});
it('renders baseline delta and flags new blockers', () => {
fixture.componentRef.setInput(
'data',
createData({
verdict: 'block',
deltaFromBaseline: {
newBlockers: 3,
resolvedBlockers: 1,
newFindings: 2,
resolvedFindings: 0,
baselineName: 'last-green',
},
})
);
fixture.detectChanges();
const delta = fixture.nativeElement.querySelector('.delta-section span') as HTMLElement;
expect(delta.textContent ?? '').toContain('+3 blockers');
expect(delta.textContent ?? '').toContain('last-green');
expect(delta.classList.contains('has-blockers')).toBeTrue();
});
it('emits attestation and snapshot click events', () => {
fixture.componentRef.setInput(
'data',
createData({
verdict: 'exception',
attestationId: 'att-001',
snapshotId: 'ksm:sha256:abcdef1234567890',
})
);
fixture.detectChanges();
spyOn(component.attestationClick, 'emit');
spyOn(component.snapshotClick, 'emit');
const signed = fixture.nativeElement.querySelector('.signed-badge') as HTMLButtonElement;
const snapshot = fixture.nativeElement.querySelector('.snapshot-badge') as HTMLButtonElement;
signed.click();
snapshot.click();
expect(component.attestationClick.emit).toHaveBeenCalledWith('att-001');
expect(component.snapshotClick.emit).toHaveBeenCalledWith('ksm:sha256:abcdef1234567890');
});
function createData(overrides: Partial<CaseHeaderData>): CaseHeaderData {
return {
verdict: 'ship',
findingCount: 10,
criticalCount: 2,
highCount: 4,
actionableCount: 7,
evaluatedAt: new Date('2026-02-10T22:30:00Z'),
...overrides,
};
}
});

View File

@@ -0,0 +1,52 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BadgeComponent } from '../../app/shared/components/badge/badge.component';
describe('BadgeComponent (cgs_badge)', () => {
let fixture: ComponentFixture<BadgeComponent>;
let component: BadgeComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BadgeComponent],
}).compileComponents();
fixture = TestBed.createComponent(BadgeComponent);
component = fixture.componentInstance;
});
it('applies variant and size class combinations', () => {
component.variant = 'critical';
component.size = 'lg';
component.pill = true;
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.badge') as HTMLElement;
expect(component.getBadgeClasses()).toContain('badge--critical');
expect(component.getBadgeClasses()).toContain('badge--lg');
expect(badge.classList.contains('badge--pill')).toBeTrue();
});
it('renders dot/icon/removable controls when enabled', () => {
component.dot = true;
component.icon = '<svg></svg>';
component.removable = true;
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.badge__dot')).toBeTruthy();
expect(fixture.nativeElement.querySelector('.badge__icon')).toBeTruthy();
expect(fixture.nativeElement.querySelector('.badge__remove')).toBeTruthy();
});
it('emits remove event and stops propagation when remove button is clicked', () => {
component.removable = true;
fixture.detectChanges();
spyOn(component.removed, 'emit');
const stopPropagation = jasmine.createSpy('stopPropagation');
component.onRemove({ stopPropagation } as unknown as MouseEvent);
expect(stopPropagation).toHaveBeenCalled();
expect(component.removed.emit).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,167 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Clipboard } from '@angular/cdk/clipboard';
import { MatSnackBar } from '@angular/material/snack-bar';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { CompareViewComponent } from '../../app/features/compare/components/compare-view/compare-view.component';
import {
BaselineRationale,
CompareService,
CompareTarget,
DeltaResult,
} from '../../app/features/compare/services/compare.service';
import { CompareExportService } from '../../app/features/compare/services/compare-export.service';
describe('CompareViewComponent (compare)', () => {
let fixture: ComponentFixture<CompareViewComponent>;
let component: CompareViewComponent;
let compareSpy: jasmine.SpyObj<CompareService>;
let exportSpy: jasmine.SpyObj<CompareExportService>;
const currentTarget: CompareTarget = {
id: 'cur-1',
digest: 'sha256:cur-1',
imageRef: 'registry/app:cur',
scanDate: '2026-02-10T00:00:00Z',
label: 'Current',
};
const baselineTarget: CompareTarget = {
id: 'base-1',
digest: 'sha256:base-1',
imageRef: 'registry/app:base',
scanDate: '2026-02-09T00:00:00Z',
label: 'Last Green Build',
};
const rationale: BaselineRationale = {
selectedDigest: 'sha256:base-1',
selectionReason: 'Selected last green build.',
alternatives: [],
autoSelectEnabled: true,
};
const delta: DeltaResult = {
categories: [
{ id: 'added', name: 'Added', icon: 'add', added: 1, removed: 0, changed: 0 },
{ id: 'changed', name: 'Changed', icon: 'swap_horiz', added: 0, removed: 0, changed: 1 },
],
items: [
{
id: 'item-1',
category: 'added',
component: 'openssl',
changeType: 'added',
title: 'CVE-2026-0001',
severity: 'high',
description: 'new finding',
},
{
id: 'item-2',
category: 'changed',
component: 'curl',
changeType: 'changed',
title: 'CVE-2026-0002',
severity: 'medium',
description: 'severity changed',
},
],
};
beforeEach(async () => {
compareSpy = jasmine.createSpyObj('CompareService', [
'getTarget',
'getBaselineRationale',
'computeDelta',
'getItemEvidence',
]) as jasmine.SpyObj<CompareService>;
compareSpy.getTarget.and.callFake((id: string) =>
of(id === 'cur-1' ? currentTarget : baselineTarget)
);
compareSpy.getBaselineRationale.and.returnValue(of(rationale));
compareSpy.computeDelta.and.returnValue(of(delta));
compareSpy.getItemEvidence.and.returnValue(
of([
{
digest: 'sha256:evidence',
data: {},
loading: false,
title: 'Evidence diff',
beforeEvidence: { status: 'old' },
afterEvidence: { status: 'new' },
},
])
);
exportSpy = jasmine.createSpyObj('CompareExportService', [
'exportJson',
]) as jasmine.SpyObj<CompareExportService>;
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, CompareViewComponent],
providers: [
{ provide: CompareService, useValue: compareSpy },
{ provide: CompareExportService, useValue: exportSpy },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ currentId: 'cur-1' }),
queryParamMap: convertToParamMap({ baseline: 'base-1' }),
},
},
},
{
provide: MatSnackBar,
useValue: jasmine.createSpyObj('MatSnackBar', ['open']),
},
{
provide: Clipboard,
useValue: jasmine.createSpyObj('Clipboard', ['copy']),
},
],
}).compileComponents();
fixture = TestBed.createComponent(CompareViewComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('loads current and baseline targets from route params', () => {
expect(compareSpy.getTarget).toHaveBeenCalledWith('cur-1');
expect(compareSpy.getTarget).toHaveBeenCalledWith('base-1');
expect(component.currentTarget()?.id).toBe('cur-1');
expect(component.baselineTarget()?.id).toBe('base-1');
});
it('renders delta summary chips for compare verdict view', () => {
expect(component.deltaSummary().totalAdded).toBe(1);
expect(component.deltaSummary().totalChanged).toBe(1);
const addedChip = fixture.nativeElement.querySelector(
'.summary-chip.added'
) as HTMLElement;
expect(addedChip.textContent).toContain('+1 added');
});
it('filters items by category and loads evidence for selected item', () => {
component.selectCategory('added');
expect(component.filteredItems().length).toBe(1);
expect(component.filteredItems()[0].id).toBe('item-1');
component.selectItem(delta.items[0]);
expect(compareSpy.getItemEvidence).toHaveBeenCalledWith('item-1');
expect(component.evidence()?.title).toBe('Evidence diff');
});
it('toggles view mode and exports compare report', () => {
expect(component.viewMode()).toBe('side-by-side');
component.toggleViewMode();
expect(component.viewMode()).toBe('unified');
component.exportReport();
expect(exportSpy.exportJson).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,60 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DeltaSummaryStripComponent } from '../../app/features/compare/components/delta-summary-strip.component';
import { DeltaSummary } from '../../app/features/compare/services/delta-compute.service';
describe('DeltaSummaryStripComponent (compare)', () => {
let fixture: ComponentFixture<DeltaSummaryStripComponent>;
let component: DeltaSummaryStripComponent;
const summary: DeltaSummary = {
added: 3,
removed: 1,
changed: 2,
unchanged: 4,
byCategory: {
sbom: { added: 1, removed: 0, changed: 1 },
reachability: { added: 1, removed: 1, changed: 0 },
vex: { added: 1, removed: 0, changed: 1 },
policy: { added: 0, removed: 0, changed: 0 },
unknowns: { added: 0, removed: 0, changed: 0 },
},
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DeltaSummaryStripComponent],
}).compileComponents();
fixture = TestBed.createComponent(DeltaSummaryStripComponent);
component = fixture.componentInstance;
});
it('renders added/removed/changed counts', () => {
fixture.componentRef.setInput('summary', summary);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('3');
expect(text).toContain('1');
expect(text).toContain('2');
});
it('computes total count without unchanged by default', () => {
fixture.componentRef.setInput('summary', summary);
fixture.detectChanges();
expect(component.totalCount()).toBe(6);
});
it('includes unchanged count when enabled', () => {
fixture.componentRef.setInput('summary', summary);
fixture.componentRef.setInput('showUnchanged', true);
fixture.detectChanges();
expect(component.totalCount()).toBe(10);
expect(
fixture.nativeElement.querySelector('.delta-badge--unchanged')
).toBeTruthy();
});
});

View File

@@ -0,0 +1,60 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GraphvizRendererComponent } from '../../app/shared/components/visualization/graphviz-renderer.component';
describe('GraphvizRendererComponent (confidence_breakdown)', () => {
let fixture: ComponentFixture<GraphvizRendererComponent>;
let component: GraphvizRendererComponent;
beforeEach(async () => {
spyOn(
GraphvizRendererComponent.prototype as unknown as { initViz: () => Promise<void> },
'initViz'
).and.callFake(async function (this: any) {
if (!this.viz) {
this.viz = {
renderSVGElement: () => {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('data-test', 'graphviz');
return svg;
},
};
}
this.initialized = true;
});
await TestBed.configureTestingModule({
imports: [GraphvizRendererComponent],
}).compileComponents();
fixture = TestBed.createComponent(GraphvizRendererComponent);
component = fixture.componentInstance;
});
it('renders svg output when dot input is provided', async () => {
fixture.componentRef.setInput('dot', 'digraph { A -> B; }');
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const svg = fixture.nativeElement.querySelector('svg[data-test="graphviz"]');
expect(svg).toBeTruthy();
});
it('surfaces error state when render throws', async () => {
(component as any).viz = {
renderSVGElement: () => {
throw new Error('Render failed');
},
};
fixture.componentRef.setInput('dot', 'digraph { A -> B; }');
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.graphviz-error')).toBeTruthy();
});
});

View File

@@ -0,0 +1,57 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MermaidRendererComponent } from '../../app/shared/components/visualization/mermaid-renderer.component';
describe('MermaidRendererComponent (confidence_breakdown)', () => {
let fixture: ComponentFixture<MermaidRendererComponent>;
let component: MermaidRendererComponent;
beforeEach(async () => {
spyOn(
MermaidRendererComponent.prototype as unknown as { initMermaid: () => Promise<void> },
'initMermaid'
).and.callFake(async function (this: any) {
if (!this.mermaid) {
this.mermaid = {
initialize: () => undefined,
parse: async () => true,
render: async () => ({ svg: '<svg data-test=\"mermaid\"></svg>' }),
};
}
this.initialized = true;
});
await TestBed.configureTestingModule({
imports: [MermaidRendererComponent],
}).compileComponents();
fixture = TestBed.createComponent(MermaidRendererComponent);
component = fixture.componentInstance;
});
it('renders svg output when diagram input is provided', async () => {
fixture.componentRef.setInput('diagram', 'graph TD; A-->B;');
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('svg[data-test="mermaid"]')).toBeTruthy();
});
it('shows error state when diagram parse fails', async () => {
(component as any).mermaid = {
initialize: () => undefined,
parse: async () => false,
render: async () => ({ svg: '<svg></svg>' }),
};
fixture.componentRef.setInput('diagram', 'invalid');
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.mermaid-error')).toBeTruthy();
});
});

View File

@@ -0,0 +1,115 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { of } from 'rxjs';
import { ConfigurationPaneComponent } from '../../app/features/configuration-pane/components/configuration-pane.component';
import { ConfigurationPaneApiService } from '../../app/features/configuration-pane/services/configuration-pane-api.service';
import { ConfigurationPaneStateService } from '../../app/features/configuration-pane/services/configuration-pane-state.service';
import {
ConfiguredIntegration,
ConfigurationCheck,
} from '../../app/features/configuration-pane/models/configuration-pane.models';
describe('ConfigurationPaneComponent (configuration_pane)', () => {
let fixture: ComponentFixture<ConfigurationPaneComponent>;
let component: ConfigurationPaneComponent;
let api: {
getIntegrations: jasmine.Spy;
getChecks: jasmine.Spy;
testConnection: jasmine.Spy;
refreshStatus: jasmine.Spy;
updateConfiguration: jasmine.Spy;
removeIntegration: jasmine.Spy;
runChecksForIntegration: jasmine.Spy;
exportConfiguration: jasmine.Spy;
};
let router: {
navigate: jasmine.Spy;
};
const integrations: ConfiguredIntegration[] = [
{
id: 'db-primary',
type: 'database',
name: 'Primary Database',
provider: 'postgresql',
status: 'connected',
healthStatus: 'healthy',
configuredAt: '2026-02-10T00:00:00Z',
configValues: { 'database.host': 'localhost' },
isPrimary: true,
},
];
const checks: ConfigurationCheck[] = [
{
checkId: 'check.database.connectivity',
integrationId: 'db-primary',
name: 'Database Connectivity',
status: 'passed',
severity: 'critical',
message: 'Connection established',
},
];
beforeEach(async () => {
api = {
getIntegrations: jasmine.createSpy('getIntegrations'),
getChecks: jasmine.createSpy('getChecks'),
testConnection: jasmine.createSpy('testConnection'),
refreshStatus: jasmine.createSpy('refreshStatus'),
updateConfiguration: jasmine.createSpy('updateConfiguration'),
removeIntegration: jasmine.createSpy('removeIntegration'),
runChecksForIntegration: jasmine.createSpy('runChecksForIntegration'),
exportConfiguration: jasmine.createSpy('exportConfiguration'),
};
router = {
navigate: jasmine.createSpy('navigate'),
};
api.getIntegrations.and.returnValue(of(integrations));
api.getChecks.and.returnValue(of(checks));
api.testConnection.and.returnValue(of({ success: true, message: 'Connected', latencyMs: 20 }));
await TestBed.configureTestingModule({
imports: [ConfigurationPaneComponent],
providers: [
ConfigurationPaneStateService,
{ provide: ConfigurationPaneApiService, useValue: api as unknown as ConfigurationPaneApiService },
{ provide: Router, useValue: router as unknown as Router },
],
}).compileComponents();
fixture = TestBed.createComponent(ConfigurationPaneComponent);
component = fixture.componentInstance;
});
it('loads integrations and checks on initialization', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(api.getIntegrations).toHaveBeenCalledTimes(1);
expect(api.getChecks).toHaveBeenCalledTimes(1);
expect(component.state.summary().totalIntegrations).toBe(1);
expect(component.state.summary().healthyIntegrations).toBe(1);
});
it('navigates to setup wizard from action handler', () => {
component.navigateToSetupWizard();
expect(router.navigate).toHaveBeenCalledWith(['/setup']);
});
it('runs connection test and surfaces success message', async () => {
fixture.detectChanges();
await fixture.whenStable();
component.onTestConnection(integrations[0]);
expect(api.testConnection).toHaveBeenCalledWith({
integrationType: 'database',
provider: 'postgresql',
configValues: { 'database.host': 'localhost' },
});
expect(component.state.successMessage()).toContain('Connection successful');
});
});

View File

@@ -0,0 +1,38 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AskStellaButtonComponent } from '../../app/shared/components/ai/ask-stella-button.component';
describe('AskStellaButtonComponent (contextual_command_bar)', () => {
let fixture: ComponentFixture<AskStellaButtonComponent>;
let component: AskStellaButtonComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AskStellaButtonComponent],
}).compileComponents();
fixture = TestBed.createComponent(AskStellaButtonComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('renders button label in default mode and emits click', () => {
spyOn(component.clicked, 'emit');
const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
button.click();
expect(button.classList.contains('ask-stella-button')).toBeTrue();
expect(button.textContent).toContain('Ask Stella');
expect(component.clicked.emit).toHaveBeenCalled();
});
it('renders compact icon-only mode', () => {
fixture.componentRef.setInput('compact', true);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
expect(button.classList.contains('ask-stella-btn--compact')).toBeTrue();
expect(fixture.nativeElement.querySelector('.ask-stella-btn__label')).toBeNull();
});
});

View File

@@ -0,0 +1,87 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
AskStellaPanelComponent,
AskStellaResult,
SuggestedPrompt,
} from '../../app/shared/components/ai/ask-stella-panel.component';
describe('AskStellaPanelComponent (contextual_command_bar)', () => {
let fixture: ComponentFixture<AskStellaPanelComponent>;
let component: AskStellaPanelComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AskStellaPanelComponent],
}).compileComponents();
fixture = TestBed.createComponent(AskStellaPanelComponent);
component = fixture.componentInstance;
});
it('renders contextual chips from provided context', () => {
fixture.componentRef.setInput('context', {
vulnerabilityId: 'CVE-2026-0001',
serviceName: 'Policy',
environment: 'prod',
});
fixture.detectChanges();
const chips = Array.from(
fixture.nativeElement.querySelectorAll('.ask-stella-panel__context-chip')
).map((chip) => (chip as HTMLElement).textContent?.trim() ?? '');
expect(chips).toEqual(['CVE-2026-0001', 'Policy', 'prod']);
});
it('emits query from suggested prompt click with active context', () => {
spyOn(component.query, 'emit');
const prompt: SuggestedPrompt = {
id: 'custom',
label: 'Explain blast radius',
prompt: 'Explain blast radius for this finding',
};
fixture.componentRef.setInput('context', { serviceName: 'Scanner' });
fixture.componentRef.setInput('suggestedPrompts', [prompt]);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector(
'.ask-stella-panel__prompt-chip'
) as HTMLButtonElement;
button.click();
expect(component.query.emit).toHaveBeenCalledWith({
prompt: 'Explain blast radius for this finding',
context: { serviceName: 'Scanner' },
});
});
it('submits freeform input and surfaces loading/response classes', () => {
spyOn(component.query, 'emit');
component.freeformInput.set(' Why is this blocked? ');
component.onSubmitFreeform();
expect(component.query.emit).toHaveBeenCalledWith({
prompt: 'Why is this blocked?',
context: {},
});
component.isLoading.set(true);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.ask-stella-panel__loading')
).toBeTruthy();
const result: AskStellaResult = {
response: 'Blocked due to policy gate.',
authority: 'evidence-backed',
};
component.isLoading.set(false);
component.result.set(result);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.ask-stella-panel__response')
).toBeTruthy();
});
});

View File

@@ -0,0 +1,136 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { ControlPlaneDashboardComponent } from '../../app/features/control-plane/control-plane-dashboard.component';
import {
RELEASE_DASHBOARD_API,
ReleaseDashboardApi,
} from '../../app/core/api/release-dashboard.client';
import { DashboardData } from '../../app/core/api/release-dashboard.models';
describe('ControlPlaneDashboardComponent (control_plane)', () => {
let fixture: ComponentFixture<ControlPlaneDashboardComponent>;
let apiSpy: jasmine.SpyObj<ReleaseDashboardApi>;
const createData = (): DashboardData => ({
pipelineData: {
environments: [
{
id: 'prod',
name: 'prod',
displayName: 'Production',
order: 3,
releaseCount: 1,
pendingCount: 0,
healthStatus: 'healthy',
},
{
id: 'dev',
name: 'dev',
displayName: 'Development',
order: 1,
releaseCount: 5,
pendingCount: 1,
healthStatus: 'healthy',
},
{
id: 'stage',
name: 'stage',
displayName: 'Staging',
order: 2,
releaseCount: 2,
pendingCount: 0,
healthStatus: 'degraded',
},
],
connections: [],
},
pendingApprovals: [
{
id: 'apr-1',
releaseId: 'rel-1',
releaseName: 'api',
releaseVersion: '1.2.3',
sourceEnvironment: 'Development',
targetEnvironment: 'Staging',
requestedBy: 'qa',
requestedAt: '2026-02-10T00:00:00Z',
urgency: 'normal',
},
],
activeDeployments: [],
recentReleases: [
{
id: 'rel-1',
name: 'api',
version: '1.2.3',
status: 'ready',
currentEnvironment: 'Development',
createdAt: '2026-02-10T00:00:00Z',
createdBy: 'qa',
componentCount: 3,
},
],
});
beforeEach(async () => {
apiSpy = jasmine.createSpyObj('ReleaseDashboardApi', [
'getDashboardData',
'approvePromotion',
'rejectPromotion',
]) as jasmine.SpyObj<ReleaseDashboardApi>;
apiSpy.getDashboardData.and.returnValue(of(createData()));
await TestBed.configureTestingModule({
imports: [ControlPlaneDashboardComponent],
providers: [
provideRouter([]),
{ provide: RELEASE_DASHBOARD_API, useValue: apiSpy },
],
}).compileComponents();
});
it('sorts environments by order and renders pipeline stages', () => {
fixture = TestBed.createComponent(ControlPlaneDashboardComponent);
fixture.detectChanges();
const stageNames = Array.from(
fixture.nativeElement.querySelectorAll('.pipeline__stage-name')
).map((el) => (el as HTMLElement).textContent?.trim() ?? '');
expect(stageNames).toEqual(['Development', 'Staging', 'Production']);
});
it('renders empty states when dashboard arrays are empty', () => {
apiSpy.getDashboardData.and.returnValue(
of({
pipelineData: { environments: [], connections: [] },
pendingApprovals: [],
activeDeployments: [],
recentReleases: [],
})
);
fixture = TestBed.createComponent(ControlPlaneDashboardComponent);
fixture.detectChanges();
const emptyText = fixture.nativeElement.textContent as string;
expect(emptyText).toContain('No environments configured yet.');
expect(emptyText).toContain('No pending approvals.');
expect(emptyText).toContain('No releases found.');
});
it('shows error state when dashboard API fails', () => {
apiSpy.getDashboardData.and.returnValue(
throwError(() => new Error('dashboard unavailable'))
);
fixture = TestBed.createComponent(ControlPlaneDashboardComponent);
fixture.detectChanges();
const errorMessage = fixture.nativeElement.querySelector(
'.dashboard__error-message'
) as HTMLElement;
expect(errorMessage.textContent).toContain('dashboard unavailable');
});
});

View File

@@ -0,0 +1,61 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CdxEvidencePanelComponent } from '../../app/features/sbom/components/cdx-evidence-panel/cdx-evidence-panel.component';
import {
ComponentEvidence,
OccurrenceEvidence,
} from '../../app/features/sbom/models/cyclonedx-evidence.models';
describe('CdxEvidencePanelComponent (cyclonedx_evidence)', () => {
let fixture: ComponentFixture<CdxEvidencePanelComponent>;
let component: CdxEvidencePanelComponent;
const occurrences: OccurrenceEvidence[] = [
{ location: '/app/vendor/liba.so', line: 42 },
{ location: '/app/vendor/liba.map' },
];
const evidence: ComponentEvidence = {
identity: { field: 'purl', confidence: 0.92 },
occurrences,
licenses: [],
copyright: [],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CdxEvidencePanelComponent],
}).compileComponents();
fixture = TestBed.createComponent(CdxEvidencePanelComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('purl', 'pkg:generic/liba@1.0.0');
fixture.componentRef.setInput('evidence', evidence);
fixture.detectChanges();
});
it('renders occurrence section count when expanded', () => {
component.toggleSection('occurrences');
fixture.detectChanges();
const header = fixture.nativeElement.querySelector(
'[aria-controls="occurrences-content"]'
) as HTMLElement;
const items = fixture.nativeElement.querySelectorAll('.occurrence-item');
expect(header.textContent).toContain('Occurrences (2)');
expect(items.length).toBe(2);
});
it('emits selected occurrence on View click', () => {
spyOn(component.viewOccurrence, 'emit');
component.toggleSection('occurrences');
fixture.detectChanges();
const viewButton = fixture.nativeElement.querySelector(
'.occurrence-item__view-btn'
) as HTMLButtonElement;
viewButton.click();
expect(component.viewOccurrence.emit).toHaveBeenCalledWith(occurrences[0]);
});
});

View File

@@ -0,0 +1,61 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PedigreeTimelineComponent } from '../../app/features/sbom/components/pedigree-timeline/pedigree-timeline.component';
import { ComponentPedigree } from '../../app/features/sbom/models/cyclonedx-evidence.models';
describe('PedigreeTimelineComponent (cyclonedx_evidence)', () => {
let fixture: ComponentFixture<PedigreeTimelineComponent>;
let component: PedigreeTimelineComponent;
const pedigree: ComponentPedigree = {
ancestors: [
{
type: 'library',
name: 'openssl',
version: '1.1.1n',
purl: 'pkg:generic/openssl@1.1.1n',
},
],
variants: [
{
type: 'library',
name: 'openssl',
version: '1.1.1n-0+deb11u5',
purl: 'pkg:deb/debian/openssl@1.1.1n-0+deb11u5',
},
],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PedigreeTimelineComponent],
}).compileComponents();
fixture = TestBed.createComponent(PedigreeTimelineComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput(
'currentPurl',
'pkg:deb/debian/openssl@1.1.1n-0+deb11u5'
);
fixture.componentRef.setInput('currentName', 'openssl');
fixture.componentRef.setInput('pedigree', pedigree);
fixture.detectChanges();
});
it('renders ancestor/variant/current nodes', () => {
expect(fixture.nativeElement.querySelector('.timeline-node--ancestor')).toBeTruthy();
expect(fixture.nativeElement.querySelector('.timeline-node--variant')).toBeTruthy();
expect(fixture.nativeElement.querySelector('.timeline-node--current')).toBeTruthy();
});
it('emits node click for timeline interaction', () => {
spyOn(component.nodeClick, 'emit');
const firstNode = fixture.nativeElement.querySelector(
'.timeline-node'
) as HTMLButtonElement;
firstNode.click();
expect(component.nodeClick.emit).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,96 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { provideRouter } from '@angular/router';
import { DeadLetterDashboardComponent } from '../../app/features/deadletter/deadletter-dashboard.component';
import { DeadLetterClient } from '../../app/core/api/deadletter.client';
import {
DeadLetterEntrySummary,
DeadLetterStatsSummary,
} from '../../app/core/api/deadletter.models';
describe('DeadLetterDashboardComponent (deadletter)', () => {
let fixture: ComponentFixture<DeadLetterDashboardComponent>;
let component: DeadLetterDashboardComponent;
let clientSpy: jasmine.SpyObj<DeadLetterClient>;
const entry: DeadLetterEntrySummary = {
id: 'dlq-1',
jobId: 'job-1',
jobType: 'scan',
tenantId: 'tenant-1',
tenantName: 'Tenant One',
state: 'pending',
errorCode: 'DLQ_TIMEOUT',
errorMessage: 'timed out',
retryCount: 1,
maxRetries: 3,
age: 120,
createdAt: '2026-02-10T00:00:00Z',
};
const statsSummary: DeadLetterStatsSummary = {
stats: {
total: 1,
pending: 1,
retrying: 0,
resolved: 0,
replayed: 0,
failed: 0,
olderThan24h: 0,
retryable: 1,
},
byErrorType: [{ errorCode: 'DLQ_TIMEOUT', count: 1, percentage: 100 }],
byTenant: [{ tenantId: 'tenant-1', tenantName: 'Tenant One', count: 1, percentage: 100 }],
trend: [],
};
beforeEach(async () => {
clientSpy = jasmine.createSpyObj('DeadLetterClient', [
'getStats',
'list',
'replay',
'resolve',
'batchReplay',
'replayAllPending',
'getBatchProgress',
'export',
]) as jasmine.SpyObj<DeadLetterClient>;
clientSpy.getStats.and.returnValue(of(statsSummary));
clientSpy.list.and.returnValue(of({ items: [entry], total: 1 }));
clientSpy.export.and.returnValue(of(new Blob(['x'], { type: 'text/csv' })));
await TestBed.configureTestingModule({
imports: [DeadLetterDashboardComponent],
providers: [
provideRouter([]),
{ provide: DeadLetterClient, useValue: clientSpy },
],
}).compileComponents();
fixture = TestBed.createComponent(DeadLetterDashboardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('loads stats and entry list on init', () => {
expect(clientSpy.getStats).toHaveBeenCalled();
expect(clientSpy.list).toHaveBeenCalled();
expect(component.stats()?.stats.total).toBe(1);
expect(component.entries().length).toBe(1);
});
it('toggles all selections from queue rows', () => {
component.toggleSelectAll();
expect(component.selectedEntries()).toEqual(['dlq-1']);
component.toggleSelectAll();
expect(component.selectedEntries()).toEqual([]);
});
it('allows replay/resolve only for pending or failed entries', () => {
expect(component.canReplay({ ...entry, state: 'pending' })).toBeTrue();
expect(component.canResolve({ ...entry, state: 'failed' })).toBeTrue();
expect(component.canReplay({ ...entry, state: 'resolved' })).toBeFalse();
});
});

View File

@@ -0,0 +1,102 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { convertToParamMap, provideRouter, ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { DeadLetterEntryDetailComponent } from '../../app/features/deadletter/deadletter-entry-detail.component';
import { DeadLetterClient } from '../../app/core/api/deadletter.client';
import {
DeadLetterAuditEvent,
DeadLetterEntry,
ResolutionReason,
} from '../../app/core/api/deadletter.models';
describe('DeadLetterEntryDetailComponent (deadletter)', () => {
let fixture: ComponentFixture<DeadLetterEntryDetailComponent>;
let component: DeadLetterEntryDetailComponent;
let clientSpy: jasmine.SpyObj<DeadLetterClient>;
const entry: DeadLetterEntry = {
id: 'dlq-1',
jobId: 'job-1',
jobType: 'scan',
tenantId: 'tenant-1',
tenantName: 'Tenant One',
state: 'pending',
errorCode: 'DLQ_TIMEOUT',
errorMessage: 'timeout',
errorCategory: 'transient',
payload: { artifact: 'sha256:abc' },
retryCount: 1,
maxRetries: 3,
createdAt: '2026-02-10T00:00:00Z',
updatedAt: '2026-02-10T00:10:00Z',
};
const audit: DeadLetterAuditEvent[] = [
{
id: 'evt-1',
entryId: 'dlq-1',
action: 'created',
timestamp: '2026-02-10T00:00:00Z',
actor: 'system',
},
];
beforeEach(async () => {
clientSpy = jasmine.createSpyObj('DeadLetterClient', [
'getEntry',
'getAuditHistory',
'replay',
'resolve',
]) as jasmine.SpyObj<DeadLetterClient>;
clientSpy.getEntry.and.returnValue(of(entry));
clientSpy.getAuditHistory.and.returnValue(of(audit));
clientSpy.replay.and.returnValue(of({ success: true, newJobId: 'job-2' }));
clientSpy.resolve.and.returnValue(
of({ ...entry, state: 'resolved', resolutionReason: 'manual_fix' })
);
await TestBed.configureTestingModule({
imports: [DeadLetterEntryDetailComponent],
providers: [
provideRouter([]),
{ provide: DeadLetterClient, useValue: clientSpy },
{
provide: ActivatedRoute,
useValue: {
paramMap: of(convertToParamMap({ entryId: 'dlq-1' })),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(DeadLetterEntryDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('loads entry and audit history from route entryId', () => {
expect(clientSpy.getEntry).toHaveBeenCalledWith('dlq-1');
expect(clientSpy.getAuditHistory).toHaveBeenCalledWith('dlq-1');
expect(component.entry()?.id).toBe('dlq-1');
expect(component.auditEvents().length).toBe(1);
expect(component.errorRef()).not.toBeNull();
});
it('allows replay/resolve only for pending or failed entry states', () => {
component.entry.set({ ...entry, state: 'pending' });
expect(component.canReplay()).toBeTrue();
expect(component.canResolve()).toBeTrue();
component.entry.set({ ...entry, state: 'resolved' });
expect(component.canReplay()).toBeFalse();
expect(component.canResolve()).toBeFalse();
});
it('maps resolution reason labels for UI display', () => {
expect(
component.formatResolutionReason('manual_fix' as ResolutionReason)
).toBe('Manual Fix - Processed manually outside system');
expect(component.formatResolutionReason(undefined)).toBe('-');
});
});

View File

@@ -0,0 +1,91 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { provideRouter } from '@angular/router';
import { DeadLetterQueueComponent } from '../../app/features/deadletter/deadletter-queue.component';
import { DeadLetterClient } from '../../app/core/api/deadletter.client';
import { DeadLetterEntrySummary } from '../../app/core/api/deadletter.models';
describe('DeadLetterQueueComponent (deadletter)', () => {
let fixture: ComponentFixture<DeadLetterQueueComponent>;
let component: DeadLetterQueueComponent;
let clientSpy: jasmine.SpyObj<DeadLetterClient>;
const entries: DeadLetterEntrySummary[] = [
{
id: 'dlq-1',
jobId: 'job-1',
jobType: 'scan',
tenantId: 'tenant-1',
tenantName: 'Tenant One',
state: 'pending',
errorCode: 'DLQ_TIMEOUT',
errorMessage: 'timeout',
retryCount: 0,
maxRetries: 3,
age: 300,
createdAt: '2026-02-10T00:00:00Z',
},
{
id: 'dlq-2',
jobId: 'job-2',
jobType: 'sbom',
tenantId: 'tenant-2',
tenantName: 'Tenant Two',
state: 'failed',
errorCode: 'DLQ_DEPENDENCY',
errorMessage: 'dependency down',
retryCount: 3,
maxRetries: 3,
age: 900,
createdAt: '2026-02-10T00:10:00Z',
},
];
beforeEach(async () => {
clientSpy = jasmine.createSpyObj('DeadLetterClient', [
'list',
'export',
]) as jasmine.SpyObj<DeadLetterClient>;
clientSpy.list.and.returnValue(of({ items: entries, total: 2, cursor: 'next-1' }));
clientSpy.export.and.returnValue(of(new Blob(['x'], { type: 'text/csv' })));
await TestBed.configureTestingModule({
imports: [DeadLetterQueueComponent],
providers: [
provideRouter([]),
{ provide: DeadLetterClient, useValue: clientSpy },
],
}).compileComponents();
fixture = TestBed.createComponent(DeadLetterQueueComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('loads queue entries and total count on init', () => {
expect(clientSpy.list).toHaveBeenCalled();
expect(component.entries().length).toBe(2);
expect(component.totalEntries()).toBe(2);
});
it('toggles sort direction for active column', () => {
expect(component.sortField).toBe('age');
expect(component.sortDir).toBe('desc');
component.sortBy('age');
expect(component.sortDir).toBe('asc');
component.sortBy('jobId');
expect(component.sortField).toBe('jobId');
expect(component.sortDir).toBe('desc');
});
it('supports select-all and clear-selection flows', () => {
component.toggleSelectAll();
expect(component.selectedIds()).toEqual(['dlq-1', 'dlq-2']);
component.clearSelection();
expect(component.selectedIds()).toEqual([]);
});
});

View File

@@ -0,0 +1,70 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DecisionDrawerComponent } from '../../app/features/triage/components/decision-drawer/decision-drawer.component';
describe('DecisionDrawerComponent (decision_drawer)', () => {
let fixture: ComponentFixture<DecisionDrawerComponent>;
let component: DecisionDrawerComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DecisionDrawerComponent],
}).compileComponents();
fixture = TestBed.createComponent(DecisionDrawerComponent);
component = fixture.componentInstance;
component.isOpen = true;
fixture.detectChanges();
});
it('keeps submit disabled until reason is selected', () => {
const submit = fixture.nativeElement.querySelector(
'button.btn-primary'
) as HTMLButtonElement;
expect(component.isValid()).toBeFalse();
expect(submit.disabled).toBeTrue();
component.setReasonCode('component_not_present');
fixture.detectChanges();
expect(component.isValid()).toBeTrue();
expect(submit.disabled).toBeFalse();
});
it('applies keyboard shortcuts for status changes while drawer is open', () => {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'a', bubbles: true, cancelable: true })
);
expect(component.formData().status).toBe('affected');
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'n', bubbles: true, cancelable: true })
);
expect(component.formData().status).toBe('not_affected');
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'u', bubbles: true, cancelable: true })
);
expect(component.formData().status).toBe('under_investigation');
});
it('emits decision payload when submit is clicked', () => {
spyOn(component.decisionSubmit, 'emit');
component.setStatus('affected');
component.setReasonCode('vulnerable_code_reachable');
component.setReasonText('reachable via runtime path');
fixture.detectChanges();
const submit = fixture.nativeElement.querySelector(
'button.btn-primary'
) as HTMLButtonElement;
submit.click();
expect(component.decisionSubmit.emit).toHaveBeenCalledWith({
status: 'affected',
reasonCode: 'vulnerable_code_reachable',
reasonText: 'reachable via runtime path',
});
});
});

View File

@@ -0,0 +1,122 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { DeployDiffPanelComponent } from '../../app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component';
import { DeployDiffService } from '../../app/features/deploy-diff/services/deploy-diff.service';
import { SbomDiffResult } from '../../app/features/deploy-diff/models/deploy-diff.models';
const mockDiff: SbomDiffResult = {
added: [
{
id: 'comp-1',
changeType: 'added',
name: 'openssl',
fromVersion: null,
toVersion: '3.0.13',
licenseChanged: false,
},
],
removed: [],
changed: [],
unchanged: 24,
policyHits: [
{
id: 'hit-1',
gate: 'critical-cve',
severity: 'high',
result: 'fail',
message: 'Critical CVE remains reachable',
componentIds: ['comp-1'],
},
],
policyResult: {
allowed: false,
overrideAvailable: true,
failCount: 1,
warnCount: 0,
passCount: 3,
},
metadata: {
fromDigest: 'sha256:from',
toDigest: 'sha256:to',
fromLabel: 'v1.0.0',
toLabel: 'v1.1.0',
computedAt: '2026-02-10T21:00:00Z',
fromTotalComponents: 24,
toTotalComponents: 25,
},
};
describe('DeployDiffPanelComponent (deploy_diff)', () => {
let fixture: ComponentFixture<DeployDiffPanelComponent>;
let component: DeployDiffPanelComponent;
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DeployDiffPanelComponent],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
DeployDiffService,
],
}).compileComponents();
fixture = TestBed.createComponent(DeployDiffPanelComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('fromDigest', 'sha256:from');
fixture.componentRef.setInput('toDigest', 'sha256:to');
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
fixture.destroy();
});
it('loads diff data and renders summary strip', async () => {
fixture.detectChanges();
const req = httpMock.expectOne('/api/v1/sbom/diff?from=sha256:from&to=sha256:to');
req.flush(mockDiff);
await fixture.whenStable();
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Deployment Diff');
expect(text).toContain('1');
expect(text).toContain('policy failure');
});
it('renders error state when diff request fails', async () => {
fixture.detectChanges();
const req = httpMock.expectOne('/api/v1/sbom/diff?from=sha256:from&to=sha256:to');
req.flush({ message: 'boom' }, { status: 500, statusText: 'Server Error' });
await fixture.whenStable();
fixture.detectChanges();
const errorBlock = fixture.nativeElement.querySelector('.error-state');
expect(errorBlock).toBeTruthy();
expect((fixture.nativeElement.textContent as string)).toContain('Failed to load diff');
});
it('shows action bar after successful load', async () => {
fixture.detectChanges();
const req = httpMock.expectOne('/api/v1/sbom/diff?from=sha256:from&to=sha256:to');
req.flush(mockDiff);
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-deploy-action-bar')).toBeTruthy();
});
});

View File

@@ -0,0 +1,63 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { DeploymentDetailPageComponent } from '../../app/features/deployments/deployment-detail-page.component';
describe('DeploymentDetailPageComponent (deployment detail)', () => {
let fixture: ComponentFixture<DeploymentDetailPageComponent>;
let component: DeploymentDetailPageComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DeploymentDetailPageComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
params: of({ deploymentId: 'DEP-UNIT-1' }),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(DeploymentDetailPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('hydrates deployment id from route params', () => {
expect(component.deploymentId()).toBe('DEP-UNIT-1');
expect(component.deployment().id).toBe('DEP-UNIT-1');
});
it('toggles workflow step selection and reports completed count', () => {
expect(component.getCompletedSteps()).toBe(5);
component.selectStep('fetch');
expect(component.selectedStep()).toBe('fetch');
expect(component.getSelectedStepData()?.name).toBe('Fetch Bundle');
component.selectStep('fetch');
expect(component.selectedStep()).toBeNull();
});
it('filters logs by selected step and query', () => {
component.selectLogStep('deploy');
let logs = component.filteredLogs();
expect(logs).toContain('Starting deployment to 4 targets');
component.searchLogs('web-01');
logs = component.filteredLogs();
expect(logs).toContain('web-01');
expect(component.getLogLineCount()).toBeGreaterThan(0);
});
it('handles regex-like search text safely when counting matches', () => {
component.searchLogs('[');
expect(() => component.getMatchCount()).not.toThrow();
expect(component.getMatchCount()).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,204 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { DeploymentMonitorComponent } from '../../app/features/release-orchestrator/deployments/deployment-monitor/deployment-monitor.component';
import { DeploymentStore } from '../../app/features/release-orchestrator/deployments/deployment.store';
import {
Deployment,
DeploymentEvent,
DeploymentMetrics,
DeploymentTarget,
LogEntry,
} from '../../app/core/api/deployment.models';
describe('DeploymentMonitorComponent (deployment monitoring)', () => {
let fixture: ComponentFixture<DeploymentMonitorComponent>;
let component: DeploymentMonitorComponent;
let storeStub: {
loading: ReturnType<typeof signal<boolean>>;
selectedDeployment: ReturnType<typeof signal<Deployment | null>>;
targets: ReturnType<typeof signal<DeploymentTarget[]>>;
selectedTargetId: ReturnType<typeof signal<string | null>>;
filteredLogs: ReturnType<typeof signal<LogEntry[]>>;
events: ReturnType<typeof signal<DeploymentEvent[]>>;
metrics: ReturnType<typeof signal<DeploymentMetrics | null>>;
targetStats: ReturnType<typeof signal<{ total: number; completed: number; running: number; failed: number; pending: number }>>;
loadDeployment: jasmine.Spy;
subscribeToUpdates: jasmine.Spy;
clearSelection: jasmine.Spy;
selectTarget: jasmine.Spy;
pause: jasmine.Spy;
resume: jasmine.Spy;
cancel: jasmine.Spy;
rollback: jasmine.Spy;
retryTarget: jasmine.Spy;
};
const targets: DeploymentTarget[] = [
{
id: 't-running',
name: 'qa-web-01',
type: 'compose_host',
status: 'running',
progress: 70,
startedAt: '2026-02-10T00:00:00Z',
completedAt: null,
duration: null,
agentId: 'agent-a',
error: null,
previousVersion: 'v1.2.4',
},
{
id: 't-complete',
name: 'qa-api-01',
type: 'docker_host',
status: 'completed',
progress: 100,
startedAt: '2026-02-10T00:00:00Z',
completedAt: '2026-02-10T00:01:00Z',
duration: 60000,
agentId: 'agent-b',
error: null,
previousVersion: 'v1.2.4',
},
];
const deployment: Deployment = {
id: 'dep-1',
releaseId: 'rel-1',
releaseName: 'release-main',
releaseVersion: 'v1.2.5',
environmentId: 'qa',
environmentName: 'QA',
status: 'running',
strategy: 'rolling',
progress: 70,
startedAt: '2026-02-10T00:00:00Z',
completedAt: null,
initiatedBy: 'qa-user',
targetCount: 2,
completedTargets: 1,
failedTargets: 0,
targets,
currentStep: 'deploy',
canPause: true,
canResume: true,
canCancel: true,
canRollback: true,
};
const logs: LogEntry[] = [
{ timestamp: '2026-02-10T00:00:01Z', level: 'debug', source: 'worker', targetId: null, message: 'debug line' },
{ timestamp: '2026-02-10T00:00:02Z', level: 'info', source: 'worker', targetId: 't-running', message: 'deploy started' },
{ timestamp: '2026-02-10T00:00:03Z', level: 'error', source: 'worker', targetId: 't-running', message: 'deploy failed once' },
];
const events: DeploymentEvent[] = [
{ id: 'evt-1', type: 'started', targetId: null, targetName: null, message: 'Deployment started', timestamp: '2026-02-10T00:00:00Z' },
];
const metrics: DeploymentMetrics = {
totalDuration: 70000,
averageTargetDuration: 35000,
successRate: 50,
rollbackCount: 0,
imagesPulled: 2,
containersStarted: 2,
containersRemoved: 0,
healthChecksPerformed: 2,
};
beforeEach(async () => {
storeStub = {
loading: signal(false),
selectedDeployment: signal(deployment),
targets: signal(targets),
selectedTargetId: signal<string | null>(null),
filteredLogs: signal(logs),
events: signal(events),
metrics: signal(metrics),
targetStats: signal({ total: 2, completed: 1, running: 1, failed: 0, pending: 0 }),
loadDeployment: jasmine.createSpy('loadDeployment'),
subscribeToUpdates: jasmine.createSpy('subscribeToUpdates'),
clearSelection: jasmine.createSpy('clearSelection'),
selectTarget: jasmine.createSpy('selectTarget').and.callFake((id: string | null) => {
storeStub.selectedTargetId.set(id);
}),
pause: jasmine.createSpy('pause'),
resume: jasmine.createSpy('resume'),
cancel: jasmine.createSpy('cancel'),
rollback: jasmine.createSpy('rollback'),
retryTarget: jasmine.createSpy('retryTarget'),
};
await TestBed.configureTestingModule({
imports: [DeploymentMonitorComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ id: 'dep-1' }),
},
},
},
{ provide: DeploymentStore, useValue: storeStub },
],
}).compileComponents();
fixture = TestBed.createComponent(DeploymentMonitorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('loads deployment and subscribes to updates on init', () => {
expect(storeStub.loadDeployment).toHaveBeenCalledWith('dep-1');
expect(storeStub.subscribeToUpdates).toHaveBeenCalledWith('dep-1');
});
it('toggles selected target for drill-down', () => {
component.onSelectTarget('t-running');
expect(storeStub.selectTarget).toHaveBeenCalledWith('t-running');
expect(storeStub.selectedTargetId()).toBe('t-running');
component.onSelectTarget('t-running');
expect(storeStub.selectTarget).toHaveBeenCalledWith(null);
expect(storeStub.selectedTargetId()).toBeNull();
});
it('applies log level and search filters', () => {
expect(component.filteredLogs().length).toBe(3);
component.toggleLogLevel('debug');
expect(component.filteredLogs().some((entry) => entry.level === 'debug')).toBeFalse();
component.logSearch = 'failed';
const filtered = component.filteredLogs();
expect(filtered.length).toBe(1);
expect(filtered[0].message).toContain('failed');
});
it('validates rollback input and submits rollback with selected targets', () => {
component.rollbackScope = 'selected';
component.rollbackReason = 'short';
expect(component.canConfirmRollback()).toBeFalse();
component.rollbackReason = 'Need rollback because health checks failed.';
expect(component.canConfirmRollback()).toBeFalse();
component.toggleRollbackTarget('t-complete');
expect(component.canConfirmRollback()).toBeTrue();
component.confirmRollback();
expect(storeStub.rollback).toHaveBeenCalledWith(
'dep-1',
['t-complete'],
'Need rollback because health checks failed.'
);
expect(component.rollbackReason).toBe('');
expect(component.rollbackTargetIds().size).toBe(0);
});
});

View File

@@ -0,0 +1,97 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { PolicyVerdictStatus } from '../../app/core/models/determinization.models';
import { DecayProgressComponent } from '../../app/shared/components/determinization/decay-progress/decay-progress.component';
import { GuardrailsBadgeComponent } from '../../app/shared/components/determinization/guardrails-badge/guardrails-badge.component';
describe('Determinization config pane components', () => {
describe('DecayProgressComponent', () => {
let fixture: ComponentFixture<DecayProgressComponent>;
let component: DecayProgressComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, DecayProgressComponent],
}).compileComponents();
fixture = TestBed.createComponent(DecayProgressComponent);
component = fixture.componentInstance;
});
it('shows fresh signal state with primary progress color', () => {
component.decay = {
ageHours: 0.5,
freshnessPercent: 92,
isStale: false,
};
fixture.detectChanges();
expect(component.freshnessLabel).toBe('Fresh');
expect(component.ageLabel).toBe('Just now');
expect(component.progressColor).toBe('primary');
});
it('marks stale signals and exposes stale metadata in tooltip', () => {
component.decay = {
ageHours: 50,
freshnessPercent: 18,
isStale: true,
staleSince: '2026-02-08T00:00:00Z',
expiresAt: '2026-02-12T00:00:00Z',
};
fixture.detectChanges();
expect(component.freshnessLabel).toBe('Stale');
expect(component.progressColor).toBe('warn');
expect(component.tooltip).toContain('Stale since:');
expect(component.ariaLabel).toContain('Signal freshness: 18%');
});
});
describe('GuardrailsBadgeComponent', () => {
let fixture: ComponentFixture<GuardrailsBadgeComponent>;
let component: GuardrailsBadgeComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, GuardrailsBadgeComponent],
}).compileComponents();
fixture = TestBed.createComponent(GuardrailsBadgeComponent);
component = fixture.componentInstance;
});
it('renders blocked status with active guardrail count and tooltip details', () => {
component.guardrails = {
status: PolicyVerdictStatus.Blocked,
blockedBy: 'critical-policy',
activeGuardrails: [
{
guardrailId: 'g-1',
name: 'Critical exploit maturity',
type: 'exploit',
condition: 'critical',
isActive: true,
},
{
guardrailId: 'g-2',
name: 'Aging waiver',
type: 'waiver',
condition: 'age>30d',
isActive: false,
},
],
};
fixture.detectChanges();
expect(component.activeCount).toBe(1);
expect(component.statusColor).toBe('blocked');
expect(component.badgeColor).toBe('warn');
expect(component.statusLabel).toBe('Blocked');
expect(component.tooltip).toContain('Critical exploit maturity');
expect(component.tooltip).toContain('Blocked by: critical-policy');
expect(component.ariaLabel).toContain('1 active');
});
});
});

View File

@@ -0,0 +1,87 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ObservationState } from '../../app/core/models/determinization.models';
import { ObservationStateChipComponent } from '../../app/shared/components/determinization/observation-state-chip/observation-state-chip.component';
import { UncertaintyIndicatorComponent } from '../../app/shared/components/determinization/uncertainty-indicator/uncertainty-indicator.component';
describe('Determinization UI components', () => {
describe('ObservationStateChipComponent', () => {
let fixture: ComponentFixture<ObservationStateChipComponent>;
let component: ObservationStateChipComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, ObservationStateChipComponent],
}).compileComponents();
fixture = TestBed.createComponent(ObservationStateChipComponent);
component = fixture.componentInstance;
});
it('shows pending determinization state with ETA and accessibility label', () => {
component.state = ObservationState.PendingDeterminization;
component.nextReviewAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
fixture.detectChanges();
expect(component.label).toBe('Unknown (auto-tracking)');
expect(component.icon).toBe('schedule');
expect(component.etaText).toContain('in');
expect(component.ariaLabel).toContain('next review');
});
it('suppresses ETA for determined state', () => {
component.state = ObservationState.Determined;
component.nextReviewAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
fixture.detectChanges();
expect(component.label).toBe('Determined');
expect(component.etaText).toBe('');
});
});
describe('UncertaintyIndicatorComponent', () => {
let fixture: ComponentFixture<UncertaintyIndicatorComponent>;
let component: UncertaintyIndicatorComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UncertaintyIndicatorComponent],
}).compileComponents();
fixture = TestBed.createComponent(UncertaintyIndicatorComponent);
component = fixture.componentInstance;
});
it('renders very high uncertainty tier and warning completeness color', () => {
component.score = 0.9;
component.completeness = 40;
component.missingSignals = ['epss', 'vex'];
fixture.detectChanges();
expect(component.tierLabel).toBe('Very High');
expect(component.tierColor).toBe('error');
expect(component.progressColor).toBe('warn');
expect(component.tooltip).toContain('Completeness: 40%');
const missing = fixture.nativeElement.querySelector('.uncertainty-indicator__missing') as HTMLElement;
expect(missing).not.toBeNull();
expect(missing.textContent).toContain('epss');
});
it('hides missing-signal details when disabled', () => {
component.score = 0.3;
component.completeness = 85;
component.missingSignals = ['reachability'];
component.showMissingSignals = false;
fixture.detectChanges();
expect(component.tierLabel).toBe('Low');
expect(component.tierIcon).toBe('verified');
expect(component.progressColor).toBe('primary');
const missing = fixture.nativeElement.querySelector('.uncertainty-indicator__missing');
expect(missing).toBeNull();
});
});
});

View File

@@ -0,0 +1,155 @@
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { of } from 'rxjs';
import {
ActionEvent,
DeveloperWorkspaceComponent,
FindingSelectEvent,
} from '../../app/features/workspaces/developer/components/developer-workspace/developer-workspace.component';
import { DeveloperWorkspaceService } from '../../app/features/workspaces/developer/services/developer-workspace.service';
import { EvidenceRibbonService } from '../../app/features/evidence-ribbon/services/evidence-ribbon.service';
import {
Finding,
FindingSort,
VerifyResult,
VerifyStep,
} from '../../app/features/workspaces/developer/models/developer-workspace.models';
@Component({
standalone: true,
imports: [DeveloperWorkspaceComponent],
template: `
<stella-developer-workspace
[artifactDigest]="artifactDigest"
(findingSelect)="onFindingSelect($event)"
(action)="onAction($event)"
/>
`,
})
class HostComponent {
artifactDigest = 'sha256:artifact-1';
findingEvent: FindingSelectEvent | null = null;
actionEvent: ActionEvent | null = null;
onFindingSelect(event: FindingSelectEvent): void {
this.findingEvent = event;
}
onAction(event: ActionEvent): void {
this.actionEvent = event;
}
}
describe('DeveloperWorkspaceComponent (developer workspace)', () => {
let fixture: ComponentFixture<HostComponent>;
let host: HostComponent;
let workspaceSpy: jasmine.SpyObj<DeveloperWorkspaceService>;
const findings: Finding[] = [
{
id: 'finding-1',
cveId: 'CVE-2026-0001',
title: 'Critical issue',
severity: 'critical',
exploitability: 9.5,
reachability: 'reachable',
runtimePresence: 'present',
componentPurl: 'pkg:npm/pkg@1.0.0',
componentName: 'pkg',
componentVersion: '1.0.0',
fixedVersion: '1.0.1',
publishedAt: '2026-02-10T00:00:00Z',
},
];
beforeEach(async () => {
workspaceSpy = jasmine.createSpyObj('DeveloperWorkspaceService', [
'loadFindings',
'setSort',
'startVerification',
'downloadReceipt',
'clear',
]) as jasmine.SpyObj<DeveloperWorkspaceService>;
Object.assign(workspaceSpy, {
loading: signal(false),
error: signal<string | null>(null),
findings: signal<Finding[]>(findings),
sortedFindings: signal<Finding[]>(findings),
currentSort: signal<FindingSort>({ field: 'exploitability', direction: 'desc' }),
verifyInProgress: signal(false),
verifySteps: signal<VerifyStep[]>([]),
verifyResult: signal<VerifyResult | null>(null),
});
workspaceSpy.loadFindings.and.returnValue(of(findings));
workspaceSpy.startVerification.and.returnValue(
of({
success: true,
steps: [],
completedAt: new Date().toISOString(),
})
);
const evidenceSpy = jasmine.createSpyObj('EvidenceRibbonService', [
'loadEvidenceStatus',
'clear',
]) as jasmine.SpyObj<EvidenceRibbonService>;
Object.assign(evidenceSpy, {
loading: signal(false),
dsseStatus: signal(null),
rekorStatus: signal(null),
sbomStatus: signal(null),
vexStatus: signal(null),
policyStatus: signal(null),
});
evidenceSpy.loadEvidenceStatus.and.returnValue(of({} as never));
await TestBed.configureTestingModule({
imports: [HostComponent, DeveloperWorkspaceComponent],
providers: [
{ provide: DeveloperWorkspaceService, useValue: workspaceSpy },
{ provide: EvidenceRibbonService, useValue: evidenceSpy },
],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('loads findings for the artifact on init', () => {
expect(workspaceSpy.loadFindings).toHaveBeenCalledWith('sha256:artifact-1');
});
it('starts verification from quick-verify CTA', () => {
const cta = fixture.debugElement.query(By.css('.verify-panel__cta'));
cta.nativeElement.click();
expect(workspaceSpy.startVerification).toHaveBeenCalledWith('sha256:artifact-1');
});
it('changes sort direction through the sort toggle button', () => {
const toggle = fixture.debugElement.query(By.css('.sort-direction'));
toggle.nativeElement.click();
expect(workspaceSpy.setSort).toHaveBeenCalledWith({
field: 'exploitability',
direction: 'asc',
});
});
it('emits finding selection and action events', () => {
const findingButton = fixture.debugElement.query(By.css('.finding-item__button'));
findingButton.nativeElement.click();
expect(host.findingEvent?.finding.id).toBe('finding-1');
const github = fixture.debugElement.query(By.css('.action-btn--github'));
github.nativeElement.click();
expect(host.actionEvent?.action).toBe('github');
expect(host.actionEvent?.finding.id).toBe('finding-1');
});
});

View File

@@ -0,0 +1,155 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import {
Finding,
sortFindings,
} from '../../app/features/workspaces/developer/models/developer-workspace.models';
import { DeveloperWorkspaceService } from '../../app/features/workspaces/developer/services/developer-workspace.service';
describe('DeveloperWorkspaceService (developer workspace)', () => {
let service: DeveloperWorkspaceService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
DeveloperWorkspaceService,
provideHttpClient(),
provideHttpClientTesting(),
],
});
service = TestBed.inject(DeveloperWorkspaceService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('sortFindings respects ascending and descending exploitability order', () => {
const findings: Finding[] = [
{
id: 'a',
title: 'A',
severity: 'medium',
exploitability: 2.5,
reachability: 'unknown',
runtimePresence: 'unknown',
componentPurl: 'pkg:npm/a@1.0.0',
componentName: 'a',
componentVersion: '1.0.0',
publishedAt: '2026-02-09T00:00:00Z',
},
{
id: 'b',
title: 'B',
severity: 'high',
exploitability: 9.1,
reachability: 'reachable',
runtimePresence: 'present',
componentPurl: 'pkg:npm/b@1.0.0',
componentName: 'b',
componentVersion: '1.0.0',
publishedAt: '2026-02-10T00:00:00Z',
},
{
id: 'c',
title: 'C',
severity: 'low',
exploitability: 4.0,
reachability: 'unreachable',
runtimePresence: 'absent',
componentPurl: 'pkg:npm/c@1.0.0',
componentName: 'c',
componentVersion: '1.0.0',
publishedAt: '2026-02-08T00:00:00Z',
},
];
const asc = sortFindings(findings, { field: 'exploitability', direction: 'asc' });
expect(asc.map((f) => f.id)).toEqual(['a', 'c', 'b']);
const desc = sortFindings(findings, { field: 'exploitability', direction: 'desc' });
expect(desc.map((f) => f.id)).toEqual(['b', 'c', 'a']);
});
it('continues polling through pending statuses until result is available', async () => {
vi.useFakeTimers();
let verifyResult: { success: boolean; errorMessage?: string } | undefined;
service.startVerification('sha256:artifact-1').subscribe((result) => {
verifyResult = result;
});
httpMock.expectOne('/api/v1/rekor/verify').flush({ jobId: 'job-1' });
vi.advanceTimersByTime(500);
httpMock.expectOne('/api/v1/rekor/verify/job-1/status').flush({
status: 'running',
steps: [{ id: 'hash', label: 'Hash Check', status: 'running' }],
});
expect(service.verifyResult()).toBeNull();
expect(service.verifyInProgress()).toBeTrue();
const completedAt = '2026-02-10T23:00:00Z';
vi.advanceTimersByTime(500);
httpMock.expectOne('/api/v1/rekor/verify/job-1/status').flush({
status: 'completed',
steps: [
{ id: 'hash', label: 'Hash Check', status: 'success', durationMs: 120 },
{ id: 'dsse', label: 'DSSE Verify', status: 'success', durationMs: 140 },
{ id: 'rekor', label: 'Rekor Inclusion', status: 'success', durationMs: 180 },
],
result: {
success: true,
steps: [
{ id: 'hash', label: 'Hash Check', status: 'success', durationMs: 120 },
{ id: 'dsse', label: 'DSSE Verify', status: 'success', durationMs: 140 },
{ id: 'rekor', label: 'Rekor Inclusion', status: 'success', durationMs: 180 },
],
receiptUrl: '/api/v1/receipts/receipt.json',
completedAt,
},
});
await Promise.resolve();
expect(verifyResult?.success).toBeTrue();
expect(service.verifyResult()?.success).toBeTrue();
expect(service.verifyInProgress()).toBeFalse();
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it('returns a timeout verification result when status never completes', async () => {
vi.useFakeTimers();
let verifyResult: { success: boolean; errorMessage?: string } | undefined;
service.startVerification('sha256:artifact-timeout').subscribe((result) => {
verifyResult = result;
});
httpMock.expectOne('/api/v1/rekor/verify').flush({ jobId: 'job-timeout' });
for (let i = 0; i < 20; i++) {
vi.advanceTimersByTime(500);
httpMock.expectOne('/api/v1/rekor/verify/job-timeout/status').flush({
status: 'running',
steps: [{ id: 'hash', label: 'Hash Check', status: 'running' }],
});
}
await Promise.resolve();
expect(verifyResult?.success).toBeFalse();
expect(verifyResult?.errorMessage).toBe('Verification timed out');
expect(service.verifyInProgress()).toBeFalse();
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
});

View File

@@ -0,0 +1,71 @@
import { TestBed } from '@angular/core/testing';
import { DisplayPreferencesService } from '../../app/features/triage/services/display-preferences.service';
describe('DisplayPreferencesService (display preferences)', () => {
const storageKey = 'stellaops.display.preferences';
function createService(): DisplayPreferencesService {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [DisplayPreferencesService],
});
return TestBed.inject(DisplayPreferencesService);
}
beforeEach(() => {
localStorage.removeItem(storageKey);
});
it('starts with deterministic default preferences', () => {
const service = createService();
expect(service.showRuntimeOverlays()).toBeTrue();
expect(service.enableTraceExport()).toBeTrue();
expect(service.showRiskLine()).toBeTrue();
expect(service.showSignedOverrideIndicators()).toBeTrue();
expect(service.expandRuntimeEvidence()).toBeFalse();
expect(service.graphMaxNodes()).toBe(50);
expect(service.runtimeHighlightStyle()).toBe('both');
});
it('clamps graph max node values to supported bounds', () => {
const service = createService();
service.setGraphMaxNodes(5);
expect(service.graphMaxNodes()).toBe(10);
service.setGraphMaxNodes(300);
expect(service.graphMaxNodes()).toBe(200);
service.setGraphMaxNodes(120);
expect(service.graphMaxNodes()).toBe(120);
});
it('persists updates and reloads merged values from localStorage', async () => {
let service = createService();
service.setShowRiskLine(false);
service.setRuntimeHighlightStyle('color');
service.setGraphMaxNodes(88);
await Promise.resolve();
const stored = JSON.parse(localStorage.getItem(storageKey) ?? '{}');
expect(stored.showRiskLine).toBeFalse();
expect(stored.graph.maxNodes).toBe(88);
expect(stored.graph.runtimeHighlightStyle).toBe('color');
localStorage.setItem(
storageKey,
JSON.stringify({
showRuntimeOverlays: false,
graph: { maxNodes: 77 },
})
);
service = createService();
expect(service.showRuntimeOverlays()).toBeFalse();
expect(service.graphMaxNodes()).toBe(77);
expect(service.runtimeHighlightStyle()).toBe('both');
expect(service.showRiskLine()).toBeTrue();
});
});

View File

@@ -0,0 +1,190 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { DigestChipComponent } from '../../app/shared/domain/digest-chip/digest-chip.component';
import { EvidenceLinkComponent } from '../../app/shared/domain/evidence-link/evidence-link.component';
import { GateSummaryPanelComponent } from '../../app/shared/domain/gate-summary-panel/gate-summary-panel.component';
import { PolicyGateBadgeComponent } from '../../app/shared/domain/gate-badge/gate-badge.component';
import { ReachabilityStateChipComponent } from '../../app/shared/domain/reachability-state-chip/reachability-state-chip.component';
import { WitnessPathPreviewComponent } from '../../app/shared/domain/witness-path-preview/witness-path-preview.component';
describe('Domain widget library components', () => {
describe('DigestChipComponent', () => {
let fixture: ComponentFixture<DigestChipComponent>;
let component: DigestChipComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DigestChipComponent],
}).compileComponents();
fixture = TestBed.createComponent(DigestChipComponent);
component = fixture.componentInstance;
component.digest = 'sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';
component.variant = 'bundle';
fixture.detectChanges();
});
it('formats digest into short chip view and variant class', () => {
expect(component.shortDigest()).toContain('sha256:abcd...');
expect(component.variantClass()).toContain('digest-chip--bundle');
});
it('copies digest and emits copy event', async () => {
const writeText = jasmine.createSpy('writeText').and.returnValue(Promise.resolve());
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
const copySpy = jasmine.createSpy('copy');
component.copyDigest.subscribe(copySpy);
await component.handleCopy(new Event('click'));
expect(writeText).toHaveBeenCalledWith(component.digest);
expect(copySpy).toHaveBeenCalled();
expect(component.copied()).toBeTrue();
});
});
describe('PolicyGateBadgeComponent', () => {
it('maps gate state to class, icon, and aria label', async () => {
await TestBed.configureTestingModule({
imports: [PolicyGateBadgeComponent],
}).compileComponents();
const fixture = TestBed.createComponent(PolicyGateBadgeComponent);
const component = fixture.componentInstance;
component.state = 'BLOCK';
component.label = 'Runtime Witness Gate';
fixture.detectChanges();
expect(component.stateClass()).toContain('gate-badge--block');
expect(component.icon().length).toBeGreaterThan(0);
expect(component.ariaLabel()).toBe('Runtime Witness Gate: BLOCK');
});
});
describe('ReachabilityStateChipComponent', () => {
it('renders confidence text and emits witness-open event', async () => {
await TestBed.configureTestingModule({
imports: [ReachabilityStateChipComponent],
}).compileComponents();
const fixture = TestBed.createComponent(ReachabilityStateChipComponent);
const component = fixture.componentInstance;
component.state = 'Reachable';
component.confidence = 0.84;
fixture.detectChanges();
expect(component.confidencePercent()).toBe('84%');
expect(component.icon().length).toBeGreaterThan(0);
const openSpy = jasmine.createSpy('openWitness');
component.openWitness.subscribe(openSpy);
const button = fixture.nativeElement.querySelector('.reachability-chip') as HTMLButtonElement;
button.click();
expect(openSpy).toHaveBeenCalled();
});
});
describe('WitnessPathPreviewComponent', () => {
it('truncates long paths and toggles expanded mode', async () => {
await TestBed.configureTestingModule({
imports: [WitnessPathPreviewComponent],
}).compileComponents();
const fixture = TestBed.createComponent(WitnessPathPreviewComponent);
const component = fixture.componentInstance;
component.path = ['entry', 'controller', 'service', 'dao', 'sink', 'exec'];
component.guards = { authCheck: true, inputValidation: true };
fixture.detectChanges();
expect(component.needsTruncation()).toBeTrue();
expect(component.displayPath()).toContain('...');
expect(component.hasGuards()).toBeTrue();
expect(component.guardsTooltip()).toContain('Auth check');
component.toggleExpanded();
expect(component.displayPath()).toContain('entry');
expect(component.displayPath()).toContain('service');
});
});
describe('EvidenceLinkComponent', () => {
it('emits open event on click while retaining route target', async () => {
await TestBed.configureTestingModule({
imports: [EvidenceLinkComponent],
providers: [provideRouter([])],
}).compileComponents();
const fixture = TestBed.createComponent(EvidenceLinkComponent);
const component = fixture.componentInstance;
component.evidenceId = 'evd-123';
component.type = 'deployment';
component.verified = true;
component.signed = true;
fixture.detectChanges();
const openSpy = jasmine.createSpy('open');
component.open.subscribe(openSpy);
const anchor = fixture.nativeElement.querySelector('.evidence-link') as HTMLAnchorElement;
anchor.click();
expect(openSpy).toHaveBeenCalled();
expect(anchor.textContent).toContain('evd-123');
});
});
describe('GateSummaryPanelComponent', () => {
it('expands witness gate details and emits explain/evidence actions', async () => {
await TestBed.configureTestingModule({
imports: [GateSummaryPanelComponent],
}).compileComponents();
const fixture = TestBed.createComponent(GateSummaryPanelComponent);
const component = fixture.componentInstance;
component.gates = [
{
id: 'gate-1',
name: 'Runtime Witness Gate',
state: 'WARN',
gateType: 'witness',
witnessMetrics: {
totalPaths: 4,
witnessedPaths: 2,
unwitnessedPaths: 2,
unwitnessedPathDetails: [
{
pathId: 'p-1',
entrypoint: 'com.example.controllers.VeryLongController.start',
sink: 'com.example.sinks.Sink.execute',
severity: 'high',
vulnId: 'CVE-2026-0101',
},
],
},
},
];
fixture.detectChanges();
component.toggleExpanded('gate-1');
fixture.detectChanges();
expect(component.expandedGates().has('gate-1')).toBeTrue();
expect(component.truncateSymbol('com.example.very.long.Symbol.path.method', 20)).toContain('...');
const explainSpy = jasmine.createSpy('explain');
const evidenceSpy = jasmine.createSpy('evidence');
component.openExplain.subscribe(explainSpy);
component.openEvidence.subscribe(evidenceSpy);
(fixture.nativeElement.querySelector('.gate-summary__explain') as HTMLButtonElement).click();
(fixture.nativeElement.querySelector('.gate-summary__footer .btn--secondary') as HTMLButtonElement).click();
expect(explainSpy).toHaveBeenCalledWith('gate-1');
expect(evidenceSpy).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,186 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EntropyAnalysis } from '../../app/core/api/entropy.models';
import {
EntropyPolicyBannerComponent,
EntropyPolicyConfig,
EntropyMitigationStep,
} from '../../app/shared/components/entropy-policy-banner.component';
import { EntropyPanelComponent } from '../../app/shared/components/entropy-panel.component';
describe('Entropy components (panel + policy banner)', () => {
describe('EntropyPanelComponent', () => {
let fixture: ComponentFixture<EntropyPanelComponent>;
let component: EntropyPanelComponent;
const analysis: EntropyAnalysis = {
imageDigest: 'sha256:image-1',
overallScore: 7.4,
riskLevel: 'high',
analyzedAt: '2026-02-10T12:00:00Z',
reportUrl: '/reports/entropy.report.json',
layers: [
{
digest: 'sha256:layer-a',
command: 'RUN apt-get update',
size: 2048,
avgEntropy: 6.1,
opaqueByteRatio: 22,
highEntropyFileCount: 2,
riskContribution: 20,
},
{
digest: 'sha256:layer-b',
command: 'COPY ./bin/app /usr/local/bin/app',
size: 4096,
avgEntropy: 7.8,
opaqueByteRatio: 62,
highEntropyFileCount: 5,
riskContribution: 45,
},
],
highEntropyFiles: [
{
path: '/app/bin/packed.so',
layerDigest: 'sha256:layer-b',
size: 12000,
entropy: 7.95,
classification: 'suspicious',
reason: 'high entropy blocks',
},
{
path: '/app/config/blob.dat',
layerDigest: 'sha256:layer-a',
size: 4000,
entropy: 6.2,
classification: 'compressed',
reason: 'compressed payload',
},
],
detectorHints: [
{
id: 'hint-1',
severity: 'high',
type: 'packed',
description: 'Packed binary segment detected',
affectedPaths: ['/app/bin/packed.so'],
confidence: 88,
remediation: 'Unpack and inspect',
},
],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EntropyPanelComponent],
}).compileComponents();
fixture = TestBed.createComponent(EntropyPanelComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('analysis', analysis);
fixture.detectChanges();
});
it('computes risk class, score description, and sorted high-entropy files', () => {
expect(component.riskClass()).toBe('risk-high');
expect(component.scoreDescription()).toContain('High entropy');
expect(component.topHighEntropyFiles()[0].path).toBe('/app/bin/packed.so');
});
it('maps entropy thresholds and utility formatters', () => {
expect(component.getEntropyClass(7.5)).toBe('entropy-critical');
expect(component.getEntropyClass(6.2)).toBe('entropy-high');
expect(component.getEntropyBarWidth(4)).toBe('50%');
expect(component.formatBytes(2048)).toContain('KB');
expect(component.formatPath('/very/long/path/to/file.bin', 12).startsWith('...')).toBeTrue();
});
it('emits report/layer/file interactions', () => {
const reportSpy = jasmine.createSpy('report');
const layerSpy = jasmine.createSpy('layer');
const fileSpy = jasmine.createSpy('file');
component.viewReport.subscribe(reportSpy);
component.selectLayer.subscribe(layerSpy);
component.selectFile.subscribe(fileSpy);
component.onViewReport();
component.onSelectLayer('sha256:layer-b');
component.onSelectFile('/app/bin/packed.so');
expect(reportSpy).toHaveBeenCalled();
expect(layerSpy).toHaveBeenCalledWith('sha256:layer-b');
expect(fileSpy).toHaveBeenCalledWith('/app/bin/packed.so');
});
});
describe('EntropyPolicyBannerComponent', () => {
let fixture: ComponentFixture<EntropyPolicyBannerComponent>;
let component: EntropyPolicyBannerComponent;
const config: EntropyPolicyConfig = {
warnThreshold: 6.5,
blockThreshold: 8.2,
currentScore: 7.3,
action: 'warn',
policyId: 'policy-entropy-1',
policyName: 'Entropy Guardrails',
highEntropyFileCount: 3,
reportUrl: '/reports/entropy.report.json',
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EntropyPolicyBannerComponent],
}).compileComponents();
fixture = TestBed.createComponent(EntropyPolicyBannerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('config', config);
fixture.detectChanges();
});
it('computes banner state and threshold percentages', () => {
expect(component.bannerClass()).toBe('action-warn');
expect(component.bannerTitle()).toBe('Entropy Warning');
expect(component.bannerMessage()).toContain('exceeds warning threshold');
expect(component.warnPercentage()).toBeCloseTo(65, 3);
expect(component.blockPercentage()).toBeCloseTo(82, 3);
expect(component.scorePercentage()).toBeCloseTo(73, 3);
});
it('uses default mitigation steps and toggles expanded panel', () => {
expect(component.effectiveMitigationSteps().length).toBeGreaterThan(0);
expect(component.expanded()).toBeFalse();
component.toggleExpanded();
expect(component.expanded()).toBeTrue();
});
it('emits download/view/run mitigation actions', () => {
const downloadSpy = jasmine.createSpy('download');
const analysisSpy = jasmine.createSpy('analysis');
const runSpy = jasmine.createSpy('run');
const customStep: EntropyMitigationStep = {
id: 'custom',
title: 'Run custom action',
description: 'Custom mitigation',
impact: 'low',
effort: 'easy',
command: 'stella demo',
};
component.downloadReport.subscribe(downloadSpy);
component.viewAnalysis.subscribe(analysisSpy);
component.runMitigation.subscribe(runSpy);
component.onDownloadReport();
component.onViewAnalysis();
component.onRunMitigation(customStep);
expect(downloadSpy).toHaveBeenCalledWith('/reports/entropy.report.json');
expect(analysisSpy).toHaveBeenCalled();
expect(runSpy).toHaveBeenCalledWith(customStep);
});
});
});

View File

@@ -0,0 +1,86 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { GlobalSearchComponent, SearchResult } from '../../app/layout/global-search/global-search.component';
describe('GlobalSearchComponent', () => {
let fixture: ComponentFixture<GlobalSearchComponent>;
let component: GlobalSearchComponent;
let router: { navigate: jasmine.Spy };
beforeEach(async () => {
router = {
navigate: jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)),
};
await TestBed.configureTestingModule({
imports: [GlobalSearchComponent],
providers: [{ provide: Router, useValue: router }],
}).compileComponents();
localStorage.clear();
fixture = TestBed.createComponent(GlobalSearchComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
localStorage.clear();
});
it('renders the global search input and shortcut hint', () => {
const text = fixture.nativeElement.textContent as string;
const input = fixture.nativeElement.querySelector('input[aria-label="Global search"]') as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.placeholder).toContain('Search releases');
expect(text).toContain('K');
});
it('produces categorized results for matching query terms', async () => {
component.query.set('CVE-2026');
component.onSearch();
expect(component.isLoading()).toBeTrue();
await new Promise((resolve) => setTimeout(resolve, 220));
fixture.detectChanges();
expect(component.isLoading()).toBeFalse();
expect(component.results().length).toBeGreaterThan(0);
expect(component.results().some((result) => result.type === 'cve')).toBeTrue();
expect(component.groupedResults().some((group) => group.type === 'cve')).toBeTrue();
});
it('clears results when the query is shorter than two characters', () => {
component.results.set([
{
id: 'existing',
type: 'release',
label: 'v1.0.0',
route: '/releases/v1.0.0',
},
]);
component.query.set('a');
component.onSearch();
expect(component.results()).toEqual([]);
});
it('navigates to selected result and persists recent search', () => {
component.query.set('CVE-2026');
const result: SearchResult = {
id: 'cve-1',
type: 'cve',
label: 'CVE-2026-12345',
sublabel: 'Critical',
route: '/security/vulnerabilities/CVE-2026-12345',
};
component.onSelect(result);
expect(router.navigate).toHaveBeenCalledWith(['/security/vulnerabilities/CVE-2026-12345']);
const stored = JSON.parse(localStorage.getItem('stella-recent-searches') ?? '[]') as string[];
expect(stored[0]).toBe('CVE-2026');
});
});

View File

@@ -0,0 +1,67 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
AiPreferencesComponent,
DEFAULT_AI_PREFERENCES,
} from '../../app/features/settings/ai-preferences.component';
describe('AiPreferencesComponent (settings_ai_preferences)', () => {
let fixture: ComponentFixture<AiPreferencesComponent>;
let component: AiPreferencesComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AiPreferencesComponent],
}).compileComponents();
fixture = TestBed.createComponent(AiPreferencesComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('initialPreferences', DEFAULT_AI_PREFERENCES);
fixture.componentRef.setInput('teams', [
{ teamId: 'team-a', teamName: 'Platform' },
{ teamId: 'team-b', teamName: 'Security' },
]);
fixture.detectChanges();
});
it('renders preferences header and default verbosity state', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('AI Assistant Preferences');
expect(component.currentPreferences().verbosity).toBe('standard');
expect(component.hasChanges()).toBeFalse();
});
it('tracks unsaved changes when verbosity is modified', () => {
component.onVerbosityChange('detailed');
fixture.detectChanges();
expect(component.currentPreferences().verbosity).toBe('detailed');
expect(component.hasChanges()).toBeTrue();
});
it('emits save payload and updates baseline state', () => {
component.onVerbosityChange('minimal');
const emitSpy = spyOn(component.save, 'emit');
component.onSave();
expect(emitSpy).toHaveBeenCalled();
expect(component.hasChanges()).toBeFalse();
expect(component.currentPreferences().verbosity).toBe('minimal');
});
it('resets to defaults through reset action', () => {
component.onVerbosityChange('detailed');
component.onSurfaceChange(
'showInPrComments',
{ target: { checked: true } } as unknown as Event
);
component.onReset();
fixture.detectChanges();
expect(component.currentPreferences().verbosity).toBe('standard');
expect(component.currentPreferences().surfaces.showInPrComments).toBeFalse();
});
});

View File

@@ -0,0 +1,71 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TimelineEvent } from '../../app/features/timeline/models/timeline.models';
import { CausalLanesComponent } from '../../app/features/timeline/components/causal-lanes/causal-lanes.component';
describe('CausalLanesComponent (timeline)', () => {
let fixture: ComponentFixture<CausalLanesComponent>;
let component: CausalLanesComponent;
const events: TimelineEvent[] = [
{
eventId: 'evt-1',
tHlc: '1704067200000:0:node1',
tsWall: '2024-01-01T12:00:00Z',
correlationId: 'corr-1',
service: 'Scheduler',
kind: 'EXECUTE',
payload: '{}',
payloadDigest: 'sha256:a',
engineVersion: { name: 'Timeline', version: '1.0.0', digest: 'sha256:e1' },
schemaVersion: 1,
},
{
eventId: 'evt-2',
tHlc: '1704067201000:0:node1',
tsWall: '2024-01-01T12:00:01Z',
correlationId: 'corr-1',
service: 'Policy',
kind: 'DECIDE',
payload: '{}',
payloadDigest: 'sha256:b',
engineVersion: { name: 'Timeline', version: '1.0.0', digest: 'sha256:e1' },
schemaVersion: 1,
},
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CausalLanesComponent, NoopAnimationsModule],
}).compileComponents();
fixture = TestBed.createComponent(CausalLanesComponent);
component = fixture.componentInstance;
});
it('builds service lanes deterministically from timeline events', () => {
fixture.componentRef.setInput('events', events);
fixture.detectChanges();
expect(component.lanes.length).toBe(2);
expect(component.lanes.map((lane) => lane.service)).toEqual(['Policy', 'Scheduler']);
expect(component.timeRange.start).toBeLessThan(component.timeRange.end);
});
it('emits selected event on user action handlers', () => {
spyOn(component.eventSelected, 'emit');
component.onEventClick(events[0]);
expect(component.eventSelected.emit).toHaveBeenCalledWith(events[0]);
});
it('handles keyboard activation for accessibility', () => {
spyOn(component.eventSelected, 'emit');
const keyboardEvent = new KeyboardEvent('keydown', { key: 'Enter' });
component.onKeyDown(keyboardEvent, events[1]);
expect(component.eventSelected.emit).toHaveBeenCalledWith(events[1]);
});
});

View File

@@ -0,0 +1,73 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { CriticalPathResponse } from '../../app/features/timeline/models/timeline.models';
import { CriticalPathComponent } from '../../app/features/timeline/components/critical-path/critical-path.component';
describe('CriticalPathComponent (timeline)', () => {
let fixture: ComponentFixture<CriticalPathComponent>;
let component: CriticalPathComponent;
const criticalPath: CriticalPathResponse = {
correlationId: 'corr-1',
totalDurationMs: 5000,
stages: [
{
stage: 'ENQUEUE->EXECUTE',
service: 'Scheduler',
durationMs: 1000,
percentage: 20,
fromHlc: '1704067200000:0:node1',
toHlc: '1704067201000:0:node1',
},
{
stage: 'EXECUTE->DECIDE',
service: 'Policy',
durationMs: 3500,
percentage: 70,
fromHlc: '1704067201000:0:node1',
toHlc: '1704067204500:0:node1',
},
{
stage: 'DECIDE->COMPLETE',
service: 'Orchestrator',
durationMs: 500,
percentage: 10,
fromHlc: '1704067204500:0:node1',
toHlc: '1704067205000:0:node1',
},
],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CriticalPathComponent, NoopAnimationsModule],
}).compileComponents();
fixture = TestBed.createComponent(CriticalPathComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('criticalPath', criticalPath);
fixture.detectChanges();
});
it('identifies bottleneck stages and severity colors', () => {
expect(component.isBottleneck(criticalPath.stages[1])).toBeTrue();
expect(component.isBottleneck(criticalPath.stages[0])).toBeFalse();
expect(component.getStageColor(criticalPath.stages[1])).toBe('#EA4335');
expect(component.getStageColor(criticalPath.stages[0])).toBe('#34A853');
});
it('formats stage width and durations for display', () => {
expect(component.getStageWidth(criticalPath.stages[0])).toBe(20);
expect(component.formatDuration(500)).toBe('500ms');
expect(component.formatDuration(1500)).toBe('1.5s');
expect(component.formatDuration(90000)).toBe('1.5m');
});
it('builds detailed tooltip text for stage inspection', () => {
const tooltip = component.formatTooltip(criticalPath.stages[0]);
expect(tooltip).toContain('ENQUEUE->EXECUTE');
expect(tooltip).toContain('Scheduler');
expect(tooltip).toContain('20.0%');
});
});

View File

@@ -0,0 +1,147 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import {
AiRecommendationPanelComponent,
ApplySuggestionEvent,
} from '../../app/features/triage/components/ai-recommendation-panel/ai-recommendation-panel.component';
import {
AdvisoryAiService,
AiRecommendation,
AiExplanation,
SimilarVulnerability,
} from '../../app/features/triage/services/advisory-ai.service';
const recommendation: AiRecommendation = {
id: 'rec-1',
type: 'triage_action',
confidence: 0.91,
title: 'Mark as not affected',
description: 'Execution path is blocked by runtime gate.',
suggestedAction: {
type: 'mark_not_affected',
suggestedJustification: 'vulnerable code not in execute path',
},
reasoning: 'Reachability graph shows no executable path to sink.',
sources: ['reachgraph:node-14', 'runtime:evidence-22'],
createdAt: '2026-02-10T20:00:00Z',
};
const reachabilityExplanation: AiExplanation = {
question: 'Why reachable?',
answer: 'Guarded runtime path with no sink execution.',
confidence: 0.84,
sources: ['reachgraph:path-1'],
};
const vexSuggestion: AiExplanation = {
question: 'VEX suggestion',
answer: 'vulnerable code not in execute path',
confidence: 0.78,
sources: ['analysis:vex-1'],
};
const similar: SimilarVulnerability = {
vulnId: 'v-2',
cveId: 'CVE-2025-9999',
similarity: 0.82,
reason: 'Same package family and mitigated call chain.',
vexDecision: 'not_affected',
};
describe('AiRecommendationPanelComponent (triage_ai_recommendation)', () => {
let fixture: ComponentFixture<AiRecommendationPanelComponent>;
let component: AiRecommendationPanelComponent;
let mockService: jasmine.SpyObj<AdvisoryAiService> & {
loading: ReturnType<typeof signal<Set<string>>>;
};
beforeEach(async () => {
mockService = {
loading: signal(new Set<string>()),
getCachedRecommendations: jasmine
.createSpy('getCachedRecommendations')
.and.returnValue([recommendation]),
requestAnalysis: jasmine
.createSpy('requestAnalysis')
.and.returnValue(
of({
taskId: 'task-1',
status: 'completed',
result: [recommendation],
})
),
getSimilarVulnerabilities: jasmine
.createSpy('getSimilarVulnerabilities')
.and.returnValue(of([similar])),
getReachabilityExplanation: jasmine
.createSpy('getReachabilityExplanation')
.and.returnValue(of(reachabilityExplanation)),
getSuggestedJustification: jasmine
.createSpy('getSuggestedJustification')
.and.returnValue(of(vexSuggestion)),
getExplanation: jasmine
.createSpy('getExplanation')
.and.returnValue(
of({
question: 'What should I do?',
answer: 'Use not affected with path evidence.',
confidence: 0.72,
sources: ['analysis:q1'],
})
),
} as unknown as jasmine.SpyObj<AdvisoryAiService> & {
loading: ReturnType<typeof signal<Set<string>>>;
};
await TestBed.configureTestingModule({
imports: [AiRecommendationPanelComponent],
providers: [{ provide: AdvisoryAiService, useValue: mockService }],
}).compileComponents();
fixture = TestBed.createComponent(AiRecommendationPanelComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('vulnId', 'CVE-2026-1000');
fixture.detectChanges();
});
it('loads cached recommendations on vulnerability input change', () => {
expect(mockService.getCachedRecommendations).toHaveBeenCalledWith('CVE-2026-1000');
expect(component.recommendations().length).toBe(1);
expect((fixture.nativeElement.textContent as string)).toContain('Mark as not affected');
});
it('requests analysis and populates related AI surfaces', () => {
component.requestAnalysis();
fixture.detectChanges();
expect(mockService.requestAnalysis).toHaveBeenCalled();
expect(mockService.getSimilarVulnerabilities).toHaveBeenCalledWith('CVE-2026-1000');
expect(component.similarVulns().length).toBe(1);
expect(component.reachabilityExplanation()?.answer).toContain('Guarded runtime path');
expect(component.vexSuggestion()?.answer).toContain('vulnerable code not in execute path');
});
it('asks custom question and stores answer', () => {
component.customQuestion.set('What should I do?');
component.askCustomQuestion();
fixture.detectChanges();
expect(mockService.getExplanation).toHaveBeenCalledWith('CVE-2026-1000', 'What should I do?');
expect(component.askingQuestion()).toBeFalse();
expect(component.customAnswer()?.answer).toContain('Use not affected');
});
it('emits suggestionApplied with recommendation action payload', () => {
const emitSpy = spyOn(component.suggestionApplied, 'emit');
component.onApplySuggestion(recommendation);
expect(emitSpy).toHaveBeenCalled();
const mostRecent = emitSpy.calls.mostRecent();
expect(mostRecent).toBeDefined();
const eventArg = mostRecent!.args[0] as ApplySuggestionEvent;
expect(eventArg.recommendation.id).toBe('rec-1');
expect(eventArg.action.type).toBe('mark_not_affected');
});
});

View File

@@ -0,0 +1,66 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TriageLaneToggleComponent } from '../../app/features/triage/components/triage-lane-toggle/triage-lane-toggle.component';
describe('TriageLaneToggleComponent (triage_quiet_lane)', () => {
let fixture: ComponentFixture<TriageLaneToggleComponent>;
let component: TriageLaneToggleComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TriageLaneToggleComponent],
}).compileComponents();
fixture = TestBed.createComponent(TriageLaneToggleComponent);
component = fixture.componentInstance;
component.activeCount = 12;
component.parkedCount = 7;
component.reviewCount = 3;
fixture.detectChanges();
});
it('defaults to active lane and renders lane counts', () => {
expect(component.currentLane()).toBe('active');
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('(12)');
expect(text).toContain('(7)');
expect(text).toContain('(3)');
});
it('emits lane change when selecting parked lane', () => {
const emitSpy = spyOn(component.laneChange, 'emit');
component.selectLane('parked');
expect(component.currentLane()).toBe('parked');
expect(emitSpy).toHaveBeenCalledWith('parked');
});
it('switches lanes with keyboard shortcuts A/P/R when input is not focused', () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'p' }));
fixture.detectChanges();
expect(component.currentLane()).toBe('parked');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'r' }));
fixture.detectChanges();
expect(component.currentLane()).toBe('review');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
fixture.detectChanges();
expect(component.currentLane()).toBe('active');
});
it('ignores lane shortcuts while an input has focus', () => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'p' }));
fixture.detectChanges();
expect(component.currentLane()).toBe('active');
input.remove();
});
});

View File

@@ -16,7 +16,6 @@
"src/app/core/api/vex-hub.client.spec.ts",
"src/app/core/services/*.spec.ts",
"src/app/features/**/*.spec.ts",
"src/app/shared/components/**/*.spec.ts",
"src/app/layout/**/*.spec.ts"
"src/app/shared/components/**/*.spec.ts"
]
}