save checkpoint
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 + '/')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'] || '');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 },
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
|
||||
import { OfflineStatusChipComponent } from './offline-status-chip.component';
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}).`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)'"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -38,7 +38,8 @@ import {
|
||||
[matBadge]="activeCount"
|
||||
[matBadgeColor]="badgeColor"
|
||||
matBadgeSize="small"
|
||||
[matBadgeHidden]="activeCount === 0">
|
||||
[matBadgeHidden]="activeCount === 0"
|
||||
aria-hidden="false">
|
||||
{{ statusIcon }}
|
||||
</mat-icon>
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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!);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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('-');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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%');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user