Close scratch iteration 009 grouped policy and VEX audit repairs

This commit is contained in:
master
2026-03-13 19:25:48 +02:00
parent 6954ac7967
commit bf4ff5bfd7
41 changed files with 2413 additions and 553 deletions

View File

@@ -174,7 +174,30 @@ async function waitForNotificationsPanel(page, timeoutMs = 12_000) {
await page.waitForTimeout(500);
}
async function findNavigationTarget(page, name, index = 0) {
async function findNavigationTarget(page, route, name, index = 0) {
if (route === '/ops/policy/overview') {
const locator = page.locator('#main-content [data-testid^="policy-overview-card-"]').getByRole('link', { name }).nth(index);
if ((await locator.count()) > 0) {
return {
matchedRole: 'link',
locator,
};
}
}
if (route === '/ops/policy/governance') {
const tabBar = page.locator('#main-content .governance__tabs').first();
for (const role of ['tab', 'link']) {
const locator = tabBar.getByRole(role, { name }).nth(index);
if ((await locator.count()) > 0) {
return {
matchedRole: role,
locator,
};
}
}
}
const candidates = [
{ role: 'link', locator: page.getByRole('link', { name }) },
{ role: 'tab', locator: page.getByRole('tab', { name }) },
@@ -196,7 +219,7 @@ async function findNavigationTarget(page, name, index = 0) {
async function waitForNavigationTarget(page, name, index = 0, timeoutMs = ELEMENT_WAIT_MS) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const target = await findNavigationTarget(page, name, index);
const target = await findNavigationTarget(page, page.url().replace(/^https:\/\/stella-ops\.local/, '').split('?')[0], name, index);
if (target) {
return target;
}
@@ -301,6 +324,113 @@ async function clickLink(context, page, route, name, index = 0) {
};
}
async function clickLinkExpectPath(context, page, route, name, expectedPath, index = 0) {
const result = await clickLink(context, page, route, name, index);
const targetUrl = result.mode === 'popup' ? result.targetUrl : result.targetUrl ?? result.snapshot?.url ?? '';
return {
...result,
ok: Boolean(result.ok) && typeof targetUrl === 'string' && targetUrl.includes(expectedPath),
expectedPath,
targetUrl,
};
}
async function verifyTextLoad(page, route, label, expectedTexts, timeoutMs = 12_000) {
await navigate(page, route);
await page.waitForFunction(
(values) => {
const text = (document.body?.innerText || '').toLowerCase();
return values.every((value) => text.includes(value.toLowerCase()));
},
expectedTexts,
{ timeout: timeoutMs },
).catch(() => {});
const snapshot = await captureSnapshot(page, `${slugify(route)}:${slugify(label)}`);
const bodyText = await page.locator('body').innerText().catch(() => '');
const normalized = bodyText.toLowerCase();
const missingTexts = expectedTexts.filter((value) => !normalized.includes(value.toLowerCase()));
return {
action: label,
ok: missingTexts.length === 0 && snapshot.alerts.length === 0,
missingTexts,
snapshot,
};
}
async function verifyConflictDashboardLoad(page) {
const route = '/ops/policy/governance/conflicts';
await navigate(page, route);
await page.waitForFunction(() => {
const text = (document.body?.innerText || '').toLowerCase();
return text.includes('policy conflicts') && (text.includes('resolve wizard') || text.includes('no conflicts found'));
}, { timeout: 12_000 }).catch(() => {});
const snapshot = await captureSnapshot(page, 'policy-conflicts:load-dashboard');
const bodyText = (await page.locator('body').innerText().catch(() => '')).toLowerCase();
const hasHeading = bodyText.includes('policy conflicts');
const hasActionableConflict = bodyText.includes('resolve wizard');
const hasEmptyState = bodyText.includes('no conflicts found');
return {
action: 'load:Conflict dashboard',
ok: hasHeading && (hasActionableConflict || hasEmptyState) && snapshot.alerts.length === 0,
snapshot,
mode: hasActionableConflict ? 'actionable-conflicts' : hasEmptyState ? 'empty-state' : 'unknown',
missingTexts: hasHeading ? [] : ['Policy Conflicts'],
};
}
async function openConflictResolutionWizard(page) {
const route = '/ops/policy/governance/conflicts';
await navigate(page, route);
await page
.waitForFunction(() => {
const text = (document.body?.innerText || '').toLowerCase();
return text.includes('resolve wizard') || text.includes('no conflicts found');
}, { timeout: 12_000 })
.catch(() => {});
const currentBodyText = await page.locator('body').innerText().catch(() => '');
if (currentBodyText.includes('No conflicts found')) {
return {
action: 'link:Resolve Wizard',
ok: true,
mode: 'no-open-conflicts',
snapshot: await captureSnapshot(page, 'policy-conflicts:no-open-conflicts'),
};
}
const link = page.locator('.conflict-card').getByRole('link', { name: 'Resolve Wizard' }).first();
if (!(await link.isVisible().catch(() => false))) {
return {
action: 'link:Resolve Wizard',
ok: false,
reason: 'missing-link',
snapshot: await captureSnapshot(page, 'policy-conflicts:missing-resolve-wizard'),
};
}
await link.click({ timeout: 10_000 });
await settle(page);
const snapshot = await captureSnapshot(page, 'policy-conflicts:resolve-wizard');
const bodyText = await page.locator('body').innerText().catch(() => '');
const ok =
page.url().includes('/ops/policy/governance/conflicts/') &&
bodyText.includes('Conflict Resolution Wizard') &&
bodyText.includes('Review Conflict Details');
return {
action: 'link:Resolve Wizard',
ok,
targetUrl: page.url(),
snapshot,
};
}
async function clickButton(page, route, name, index = 0) {
await navigate(page, route);
const locator = await waitForButton(page, name, index);
@@ -812,6 +942,68 @@ async function main() {
});
try {
results.push({
route: '/ops/policy/overview',
actions: [
await runAction(page, '/ops/policy/overview', 'link:Packs', () =>
clickLinkExpectPath(context, page, '/ops/policy/overview', 'Packs', '/ops/policy/packs')),
await runAction(page, '/ops/policy/overview', 'link:Governance', () =>
clickLinkExpectPath(context, page, '/ops/policy/overview', 'Governance', '/ops/policy/governance')),
await runAction(page, '/ops/policy/overview', 'link:Simulation', () =>
clickLinkExpectPath(context, page, '/ops/policy/overview', 'Simulation', '/ops/policy/simulation')),
await runAction(page, '/ops/policy/overview', 'link:VEX & Exceptions', () =>
clickLinkExpectPath(context, page, '/ops/policy/overview', 'VEX & Exceptions', '/ops/policy/vex')),
await runAction(page, '/ops/policy/overview', 'link:Release Gates', () =>
clickLinkExpectPath(context, page, '/ops/policy/overview', 'Release Gates', '/ops/policy/gates')),
await runAction(page, '/ops/policy/overview', 'link:Policy Audit', () =>
clickLinkExpectPath(context, page, '/ops/policy/overview', 'Policy Audit', '/ops/policy/audit')),
],
});
await persistSummary(summary);
results.push({
route: '/ops/policy/governance',
actions: [
await runAction(page, '/ops/policy/governance', 'link:Trust Weights', () =>
clickLinkExpectPath(context, page, '/ops/policy/governance', 'Trust Weights', '/ops/policy/governance/trust-weights')),
await runAction(page, '/ops/policy/governance', 'link:Sealed Mode', () =>
clickLinkExpectPath(context, page, '/ops/policy/governance', 'Sealed Mode', '/ops/policy/governance/sealed-mode')),
await runAction(page, '/ops/policy/governance', 'link:Profiles', () =>
clickLinkExpectPath(context, page, '/ops/policy/governance', 'Profiles', '/ops/policy/governance/profiles')),
await runAction(page, '/ops/policy/governance', 'link:Validator', () =>
clickLinkExpectPath(context, page, '/ops/policy/governance', 'Validator', '/ops/policy/governance/validator')),
await runAction(page, '/ops/policy/governance', 'link:Audit Log', () =>
clickLinkExpectPath(context, page, '/ops/policy/governance', 'Audit Log', '/ops/policy/governance/audit')),
await runAction(page, '/ops/policy/governance', 'link:Conflicts', () =>
clickLinkExpectPath(context, page, '/ops/policy/governance', 'Conflicts', '/ops/policy/governance/conflicts')),
await runAction(page, '/ops/policy/governance', 'link:Playground', () =>
clickLinkExpectPath(context, page, '/ops/policy/governance', 'Playground', '/ops/policy/governance/schema-playground')),
await runAction(page, '/ops/policy/governance', 'link:Docs', () =>
clickLinkExpectPath(context, page, '/ops/policy/governance', 'Docs', '/ops/policy/governance/schema-docs')),
],
});
await persistSummary(summary);
results.push({
route: '/ops/policy/vex',
actions: [
await runAction(page, '/ops/policy/vex', 'load:Dashboard data', () =>
verifyTextLoad(page, '/ops/policy/vex', 'load:Dashboard data', ['VEX Statement Dashboard', 'Statement Sources'])),
],
});
await persistSummary(summary);
results.push({
route: '/ops/policy/governance/conflicts',
actions: [
await runAction(page, '/ops/policy/governance/conflicts', 'load:Conflict dashboard', () =>
verifyConflictDashboardLoad(page)),
await runAction(page, '/ops/policy/governance/conflicts', 'link:Resolve Wizard', () =>
openConflictResolutionWizard(page)),
],
});
await persistSummary(summary);
results.push({
route: '/ops/operations/quotas',
actions: [

View File

@@ -191,6 +191,8 @@ async function findLinkForRoute(page, route, name, timeoutMs = 10_000) {
if (route === '/releases/environments' && name === 'Open Environment') {
locator = page.locator('.actions').getByRole('link', { name }).first();
} else if (route === '/security/posture' && name === 'Configure sources') {
locator = page.locator('#main-content .panel').getByRole('link', { name }).first();
} else if (route === '/ops/operations/jobengine' && name instanceof RegExp && String(name) === String(/Execution Quotas/i)) {
locator = await findScopedLink(page, '[data-testid="jobengine-quotas-card"]', name);
} else if (route === '/ops/operations/offline-kit' && name === 'Bundles') {
@@ -209,7 +211,44 @@ async function findLinkForRoute(page, route, name, timeoutMs = 10_000) {
return null;
}
async function findButton(page, name, timeoutMs = 10_000) {
async function findButton(page, route, name, timeoutMs = 10_000) {
const deadline = Date.now() + timeoutMs;
const scopes = [];
if (route === '/releases/versions' && name === 'Create Hotfix Run') {
scopes.push(page.locator('#main-content .header-actions').first());
}
scopes.push(page.locator('#main-content').first(), page.locator('body'));
while (Date.now() < deadline) {
for (const scope of scopes) {
for (const role of ['button', 'tab']) {
const button = scope.getByRole(role, { name }).first();
if (await button.count()) {
return button;
}
}
}
await page.waitForTimeout(250);
}
return null;
}
async function waitForPath(page, expectedPath, timeoutMs = 10_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (normalizeUrl(page.url()).includes(expectedPath)) {
return true;
}
await page.waitForTimeout(250);
}
return false;
}
async function findButtonLegacy(page, name, timeoutMs = 10_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
for (const role of ['button', 'tab']) {
@@ -269,6 +308,9 @@ async function runLinkCheck(page, route, name, expectedPath) {
}
await link.click({ timeout: 10_000 });
if (expectedPath) {
await waitForPath(page, expectedPath, 10_000);
}
await settle(page);
const snapshot = await captureSnapshot(page, action);
const finalUrl = normalizeUrl(snapshot.url);
@@ -294,7 +336,7 @@ async function runButtonCheck(page, route, name, expectedPath = null) {
const action = `${route} -> button:${name}`;
try {
await navigate(page, route);
const button = await findButton(page, name);
const button = await findButton(page, route, name).catch(() => null) ?? await findButtonLegacy(page, name);
if (!button) {
return { action, ok: false, reason: 'missing-button', snapshot: await captureSnapshot(page, action) };
}
@@ -313,6 +355,9 @@ async function runButtonCheck(page, route, name, expectedPath = null) {
}
await button.click({ timeout: 10_000 });
if (expectedPath) {
await waitForPath(page, expectedPath, 10_000);
}
await settle(page);
const snapshot = await captureSnapshot(page, action);
const finalUrl = normalizeUrl(snapshot.url);

View File

@@ -171,6 +171,10 @@ async function collectReportsTabState(page, tab) {
};
}
function tabContainsText(values, expected) {
return values.some((value) => value.toLowerCase().includes(expected.toLowerCase()));
}
async function runSearchQueryCheck(page, query) {
const searchInput = page.locator('input[aria-label="Global search"]').first();
const responses = [];
@@ -487,6 +491,33 @@ async function main() {
if (!tabState.url.includes('/security/reports')) {
failures.push(`Security Reports tab "${tabState.tab}" still navigates away instead of embedding its workspace.`);
}
if (tabState.tab === 'Risk Report') {
const riskEmbedded =
tabContainsText(tabState.headings, 'Artifact triage') &&
(tabContainsText(tabState.headings, 'Findings') || tabContainsText(tabState.primaryButtons, 'Open witness workspace'));
if (!riskEmbedded) {
failures.push('Security Reports risk tab did not render the embedded triage workspace.');
}
}
if (tabState.tab === 'VEX Ledger') {
const vexEmbedded =
tabContainsText(tabState.headings, 'Security / Advisories & VEX') &&
(tabContainsText(tabState.headings, 'Providers') || tabContainsText(tabState.headings, 'VEX Library'));
if (!vexEmbedded) {
failures.push('Security Reports VEX tab did not render the embedded advisories and VEX workspace.');
}
}
if (tabState.tab === 'Evidence Export') {
const evidenceEmbedded =
tabContainsText(tabState.headings, 'Export Center') &&
(tabContainsText(tabState.primaryButtons, 'Create Profile') || tabContainsText(tabState.headings, 'Export Runs'));
if (!evidenceEmbedded) {
failures.push('Security Reports evidence tab did not render the embedded export workspace.');
}
}
}
if (byAction.get('security-triage:raw-svg-visible')?.triageRawSvgTextVisible) {

View File

@@ -90,7 +90,7 @@ export interface VexStatementSearchResponse {
export interface VexHubStats {
totalStatements: number;
byStatus: Record<VexStatementStatus, number>;
bySource: Record<VexIssuerType, number>;
bySource: Record<string, number>;
recentActivity: VexActivityItem[];
trends?: VexTrendData[];
}

View File

@@ -1,65 +1,105 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { ActivatedRoute, Router, provideRouter } from '@angular/router';
import { convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { POLICY_GOVERNANCE_API, PolicyGovernanceApi } from '../../core/api/policy-governance.client';
import { PolicyConflict } from '../../core/api/policy-governance.models';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
import { TenantActivationService } from '../../core/auth/tenant-activation.service';
import { ConflictResolutionWizardComponent } from './conflict-resolution-wizard.component';
describe('ConflictResolutionWizardComponent', () => {
let component: ConflictResolutionWizardComponent;
let fixture: ComponentFixture<ConflictResolutionWizardComponent>;
let api: jasmine.SpyObj<PolicyGovernanceApi>;
let router: Router;
let tenantActivation: { activeTenantId: jasmine.Spy; activeProjectId: jasmine.Spy };
let authSession: { getActiveTenantId: jasmine.Spy };
const mockActivatedRoute = {
paramMap: of({
get: (key: string) => key === 'conflictId' ? 'conflict-123' : null,
}),
const conflict: PolicyConflict = {
id: 'conflict-001',
type: 'rule_overlap',
severity: 'warning',
summary: 'Overlapping severity rules in profiles',
description: 'Two profiles can emit conflicting outcomes for the same release.',
sourceA: { id: 'profile-default', type: 'profile', name: 'Default Risk Profile', version: '1.0.0', path: 'severityOverrides[0]' },
sourceB: { id: 'profile-strict', type: 'profile', name: 'Strict Security Profile', version: '1.1.0', path: 'severityOverrides[0]' },
affectedScope: ['production'],
impactAssessment: 'Live releases may oscillate between warn and block.',
suggestedResolution: 'Choose one precedence order.',
detectedAt: '2026-03-13T10:00:00Z',
status: 'open',
};
beforeEach(async () => {
api = jasmine.createSpyObj<PolicyGovernanceApi>('PolicyGovernanceApi', [
'getConflicts',
'resolveConflict',
]);
tenantActivation = {
activeTenantId: jasmine.createSpy('activeTenantId').and.returnValue(null),
activeProjectId: jasmine.createSpy('activeProjectId').and.returnValue(null),
};
authSession = {
getActiveTenantId: jasmine.createSpy('getActiveTenantId').and.returnValue('session-tenant'),
};
api.getConflicts.and.returnValue(of([conflict]));
api.resolveConflict.and.returnValue(of({ ...conflict, status: 'resolved', resolutionNotes: 'Consolidated precedence ordering' }));
await TestBed.configureTestingModule({
imports: [ConflictResolutionWizardComponent],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: POLICY_GOVERNANCE_API, useValue: api },
{ provide: TenantActivationService, useValue: tenantActivation },
{ provide: AuthSessionStore, useValue: authSession },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ conflictId: 'conflict-001' }),
},
},
},
],
}).compileComponents();
router = TestBed.inject(Router);
spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
fixture = TestBed.createComponent(ConflictResolutionWizardComponent);
component = fixture.componentInstance;
});
it('loads the requested conflict with the resolved tenant scope', () => {
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
expect(api.getConflicts).toHaveBeenCalledWith({ tenantId: 'session-tenant' });
expect((component as any).conflict()).toEqual(conflict);
it('should render wizard header', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.wizard__header')).toBeTruthy();
expect(compiled.textContent).toContain('Conflict Resolution Wizard');
expect(compiled.textContent).toContain('Review');
});
it('should display step indicators', () => {
const compiled = fixture.nativeElement as HTMLElement;
const steps = compiled.querySelectorAll('.wizard__step');
expect(steps.length).toBe(4);
});
it('applies the resolution through the resolved scope and returns to the conflicts page', async () => {
fixture.detectChanges();
(component as any).selectedWinner.set('A');
(component as any).selectedStrategy.set('keep_higher_priority');
(component as any).resolutionNotes = 'Consolidated precedence ordering';
it('should show step labels for all steps', () => {
const compiled = fixture.nativeElement as HTMLElement;
const content = compiled.textContent;
expect(content).toContain('Review');
expect(content).toContain('Compare');
expect(content).toContain('Strategy');
expect(content).toContain('Confirm');
});
(component as any).applyResolution();
await fixture.whenStable();
it('should display navigation buttons', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.wizard__actions')).toBeTruthy();
});
it('should have back to conflicts link', () => {
const compiled = fixture.nativeElement as HTMLElement;
const backLink = compiled.querySelector('a[routerLink="../.."]');
expect(backLink).toBeTruthy();
expect(api.resolveConflict).toHaveBeenCalledWith(
'conflict-001',
'Consolidated precedence ordering',
{ tenantId: 'session-tenant' },
);
expect(router.navigate).toHaveBeenCalledWith(['../conflicts'], {
relativeTo: TestBed.inject(ActivatedRoute),
});
});
});

View File

@@ -11,6 +11,7 @@ import {
PolicyConflict,
PolicyConflictSource,
} from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/**
* Conflict Resolution Wizard component.
@@ -957,6 +958,7 @@ export class ConflictResolutionWizardComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
protected readonly loading = signal(false);
protected readonly applying = signal(false);
@@ -1006,7 +1008,7 @@ export class ConflictResolutionWizardComponent implements OnInit {
private loadConflict(conflictId: string): void {
this.loading.set(true);
this.api
.getConflicts({ tenantId: 'acme-tenant' })
.getConflicts(this.governanceScope())
.pipe(finalize(() => this.loading.set(false)))
.subscribe({
next: (conflicts) => {
@@ -1104,7 +1106,7 @@ export class ConflictResolutionWizardComponent implements OnInit {
this.applying.set(true);
this.api
.resolveConflict(c.id, this.resolutionNotes, { tenantId: 'acme-tenant' })
.resolveConflict(c.id, this.resolutionNotes, this.governanceScope())
.pipe(finalize(() => this.applying.set(false)))
.subscribe({
next: () => {

View File

@@ -13,6 +13,7 @@ import {
AuditEventType,
GovernanceAuditDiff,
} from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/**
* Governance Audit component.
@@ -571,6 +572,7 @@ import {
})
export class GovernanceAuditComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
protected readonly loading = signal(false);
protected readonly events = signal<GovernanceAuditEvent[]>([]);
@@ -606,7 +608,7 @@ export class GovernanceAuditComponent implements OnInit {
this.loading.set(true);
const options: AuditQueryOptions = {
tenantId: 'acme-tenant',
...this.governanceScope(),
page,
pageSize: 20,
sortOrder: 'desc',
@@ -735,7 +737,7 @@ export class GovernanceAuditComponent implements OnInit {
targetResourceType: this.toString(record?.['targetResourceType']) || 'unknown',
summary,
traceId: this.toString(record?.['traceId']) || undefined,
tenantId: this.toString(record?.['tenantId']) || 'acme-tenant',
tenantId: this.toString(record?.['tenantId']) || this.governanceScope().tenantId,
projectId: this.toString(record?.['projectId']) || undefined,
};

View File

@@ -11,6 +11,7 @@ import {
TrustWeightAffectedFinding,
Severity,
} from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/**
* Impact Preview component.
@@ -525,6 +526,7 @@ import {
})
export class ImpactPreviewComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
private readonly route = inject(ActivatedRoute);
protected readonly loading = signal(true);
@@ -541,7 +543,7 @@ export class ImpactPreviewComponent implements OnInit {
this.loading.set(true);
// In real implementation, get changes from query params or service
this.api
.previewTrustWeightImpact([], { tenantId: 'acme-tenant' })
.previewTrustWeightImpact([], this.governanceScope())
.pipe(finalize(() => this.loading.set(false)))
.subscribe({
next: (impact) => this.impact.set(impact),

View File

@@ -1,55 +1,140 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { of } from 'rxjs';
import { POLICY_GOVERNANCE_API, PolicyGovernanceApi } from '../../core/api/policy-governance.client';
import { PolicyConflict, PolicyConflictDashboard } from '../../core/api/policy-governance.models';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
import { TenantActivationService } from '../../core/auth/tenant-activation.service';
import { PolicyConflictDashboardComponent } from './policy-conflict-dashboard.component';
describe('PolicyConflictDashboardComponent', () => {
let component: PolicyConflictDashboardComponent;
let fixture: ComponentFixture<PolicyConflictDashboardComponent>;
let api: jasmine.SpyObj<PolicyGovernanceApi>;
let tenantActivation: { activeTenantId: jasmine.Spy; activeProjectId: jasmine.Spy };
let authSession: { getActiveTenantId: jasmine.Spy };
const dashboard: PolicyConflictDashboard = {
totalConflicts: 2,
openConflicts: 1,
byType: {
rule_overlap: 1,
precedence_ambiguity: 1,
circular_dependency: 0,
incompatible_actions: 0,
scope_collision: 0,
},
bySeverity: {
info: 1,
warning: 1,
error: 0,
critical: 0,
},
recentConflicts: [],
trend: [
{ date: '2026-03-07', count: 0 },
{ date: '2026-03-08', count: 0 },
{ date: '2026-03-09', count: 1 },
{ date: '2026-03-10', count: 0 },
{ date: '2026-03-11', count: 1 },
{ date: '2026-03-12', count: 0 },
{ date: '2026-03-13', count: 0 },
],
lastAnalyzedAt: '2026-03-13T12:00:00Z',
};
const conflicts: PolicyConflict[] = [
{
id: 'conflict-001',
type: 'rule_overlap',
severity: 'warning',
summary: 'Overlapping severity rules in profiles',
description: 'Two profiles can emit conflicting outcomes for the same release.',
sourceA: { id: 'profile-default', type: 'profile', name: 'Default Risk Profile', version: '1.0.0', path: 'severityOverrides[0]' },
sourceB: { id: 'profile-strict', type: 'profile', name: 'Strict Security Profile', version: '1.1.0', path: 'severityOverrides[0]' },
affectedScope: ['production'],
impactAssessment: 'Live releases may oscillate between warn and block.',
suggestedResolution: 'Choose one precedence order.',
detectedAt: '2026-03-13T10:00:00Z',
status: 'open',
},
{
id: 'conflict-002',
type: 'precedence_ambiguity',
severity: 'info',
summary: 'Ambiguous rule precedence',
description: 'Two rules share the same priority.',
sourceA: { id: 'gate-cvss-high', type: 'rule', name: 'CVSS High Escalation' },
sourceB: { id: 'gate-exploit-available', type: 'rule', name: 'Exploit Available Escalation' },
affectedScope: ['all'],
impactAssessment: 'Explain traces may differ between runs.',
suggestedResolution: 'Assign distinct priorities.',
detectedAt: '2026-03-12T12:00:00Z',
status: 'acknowledged',
},
];
beforeEach(async () => {
api = jasmine.createSpyObj<PolicyGovernanceApi>('PolicyGovernanceApi', [
'getConflictDashboard',
'getConflicts',
'resolveConflict',
'ignoreConflict',
]);
tenantActivation = {
activeTenantId: jasmine.createSpy('activeTenantId').and.returnValue('demo-prod'),
activeProjectId: jasmine.createSpy('activeProjectId').and.returnValue('stage'),
};
authSession = {
getActiveTenantId: jasmine.createSpy('getActiveTenantId').and.returnValue('fallback-tenant'),
};
api.getConflictDashboard.and.returnValue(of(dashboard));
api.getConflicts.and.returnValue(of(conflicts));
api.resolveConflict.and.returnValue(of({ ...conflicts[0], status: 'resolved', resolutionNotes: 'Consolidated precedence ordering' }));
api.ignoreConflict.and.returnValue(of({ ...conflicts[1], status: 'ignored', resolutionNotes: 'Accepted for lab-only profile' }));
await TestBed.configureTestingModule({
imports: [PolicyConflictDashboardComponent, FormsModule],
providers: [provideRouter([])],
imports: [PolicyConflictDashboardComponent],
providers: [
provideRouter([]),
{ provide: POLICY_GOVERNANCE_API, useValue: api },
{ provide: TenantActivationService, useValue: tenantActivation },
{ provide: AuthSessionStore, useValue: authSession },
],
}).compileComponents();
fixture = TestBed.createComponent(PolicyConflictDashboardComponent);
component = fixture.componentInstance;
});
it('loads the dashboard and conflicts with the active governance scope', () => {
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
expect(api.getConflictDashboard).toHaveBeenCalledWith({ tenantId: 'demo-prod', projectId: 'stage' });
expect(api.getConflicts).toHaveBeenCalledWith({ tenantId: 'demo-prod', projectId: 'stage' });
it('should render conflicts header', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.conflicts__header')).toBeTruthy();
expect(compiled.textContent).toContain('Policy Conflicts');
expect(compiled.textContent).toContain('Overlapping severity rules in profiles');
expect(compiled.textContent).toContain('Resolve Wizard');
});
it('should display conflict statistics', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.conflicts__stats')).toBeTruthy();
});
it('uses the same resolved scope for quick resolve actions and refreshes the page state', () => {
fixture.detectChanges();
api.getConflictDashboard.calls.reset();
api.getConflicts.calls.reset();
spyOn(window, 'prompt').and.returnValue('Consolidated precedence ordering');
it('should show conflicts list', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.conflicts__list, .conflicts__table')).toBeTruthy();
});
(component as any).resolveConflict(conflicts[0]);
it('should display resolve buttons for conflicts', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Resolve');
});
it('should show conflict severity indicators', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.conflict__severity')).toBeTruthy();
});
it('should have filter controls', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.conflicts__filters')).toBeTruthy();
expect(api.resolveConflict).toHaveBeenCalledWith(
'conflict-001',
'Consolidated precedence ordering',
{ tenantId: 'demo-prod', projectId: 'stage' },
);
expect(api.getConflictDashboard).toHaveBeenCalledWith({ tenantId: 'demo-prod', projectId: 'stage' });
expect(api.getConflicts).toHaveBeenCalledWith({ tenantId: 'demo-prod', projectId: 'stage' });
});
});

View File

@@ -13,6 +13,7 @@ import {
PolicyConflictType,
PolicyConflictSeverity,
} from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/**
* Policy Conflict Dashboard component.
@@ -598,6 +599,7 @@ import {
})
export class PolicyConflictDashboardComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
protected readonly loading = signal(false);
protected readonly analyzing = signal(false);
@@ -613,7 +615,7 @@ export class PolicyConflictDashboardComponent implements OnInit {
}
private loadDashboard(): void {
this.api.getConflictDashboard({ tenantId: 'acme-tenant' }).subscribe({
this.api.getConflictDashboard(this.governanceScope()).subscribe({
next: (d) => this.dashboard.set(d),
error: (err) => console.error('Failed to load dashboard:', err),
});
@@ -621,7 +623,7 @@ export class PolicyConflictDashboardComponent implements OnInit {
protected loadConflicts(): void {
this.loading.set(true);
const options: any = { tenantId: 'acme-tenant' };
const options: any = { ...this.governanceScope() };
if (this.typeFilter) options.type = this.typeFilter;
if (this.severityFilter) options.severity = this.severityFilter;
@@ -684,7 +686,7 @@ export class PolicyConflictDashboardComponent implements OnInit {
const resolution = prompt('Enter resolution notes:');
if (!resolution) return;
this.api.resolveConflict(conflict.id, resolution, { tenantId: 'acme-tenant' }).subscribe({
this.api.resolveConflict(conflict.id, resolution, this.governanceScope()).subscribe({
next: () => {
this.loadConflicts();
this.loadDashboard();
@@ -697,7 +699,7 @@ export class PolicyConflictDashboardComponent implements OnInit {
const reason = prompt('Enter reason for ignoring:');
if (!reason) return;
this.api.ignoreConflict(conflict.id, reason, { tenantId: 'acme-tenant' }).subscribe({
this.api.ignoreConflict(conflict.id, reason, this.governanceScope()).subscribe({
next: () => {
this.loadConflicts();
this.loadDashboard();

View File

@@ -0,0 +1,22 @@
import { inject } from '@angular/core';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
import { TenantActivationService } from '../../core/auth/tenant-activation.service';
import { GovernanceQueryOptions } from '../../core/api/policy-governance.models';
export function injectPolicyGovernanceScopeResolver(
fallbackTenantId = 'demo-prod',
): () => GovernanceQueryOptions {
const tenantActivation = inject(TenantActivationService);
const authSession = inject(AuthSessionStore);
return () => {
const tenantId =
tenantActivation.activeTenantId()?.trim() ||
authSession.getActiveTenantId()?.trim() ||
fallbackTenantId;
const projectId = tenantActivation.activeProjectId()?.trim() || undefined;
return projectId ? { tenantId, projectId } : { tenantId };
};
}

View File

@@ -20,6 +20,7 @@ import {
RiskBudgetGovernance,
RiskBudgetThreshold,
} from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/**
* Risk Budget Configuration component.
@@ -515,6 +516,7 @@ import {
})
export class RiskBudgetConfigComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
private readonly fb = inject(FormBuilder);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
@@ -595,7 +597,7 @@ export class RiskBudgetConfigComponent implements OnInit {
this.loadError.set(null);
this.loading.set(true);
this.api
.getRiskBudgetDashboard({ tenantId: 'acme-tenant' })
.getRiskBudgetDashboard(this.governanceScope())
.pipe(finalize(() => this.loading.set(false)))
.subscribe({
next: (dashboard) => {
@@ -682,8 +684,8 @@ export class RiskBudgetConfigComponent implements OnInit {
const config: RiskBudgetGovernance = {
id: baseline?.id ?? 'budget-001',
tenantId: baseline?.tenantId ?? 'acme-tenant',
projectId: baseline?.projectId,
tenantId: baseline?.tenantId ?? this.governanceScope().tenantId,
projectId: baseline?.projectId ?? this.governanceScope().projectId,
name: formValue.name,
totalBudget: formValue.totalBudget,
period: formValue.period,
@@ -705,7 +707,7 @@ export class RiskBudgetConfigComponent implements OnInit {
};
this.api
.updateRiskBudgetConfig(config, { tenantId: 'acme-tenant' })
.updateRiskBudgetConfig(config, this.governanceScope())
.pipe(finalize(() => this.saving.set(false)))
.subscribe({
next: (updatedConfig) => {

View File

@@ -11,6 +11,7 @@ import {
RiskBudgetContributor,
RiskBudgetAlert,
} from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/**
* Risk Budget Dashboard component.
@@ -626,6 +627,7 @@ import {
})
export class RiskBudgetDashboardComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
protected readonly loading = signal(false);
protected readonly data = signal<RiskBudgetDashboard | null>(null);
@@ -639,7 +641,7 @@ export class RiskBudgetDashboardComponent implements OnInit {
this.loadError.set(null);
this.loading.set(true);
this.api
.getRiskBudgetDashboard({ tenantId: 'acme-tenant' })
.getRiskBudgetDashboard(this.governanceScope())
.pipe(finalize(() => this.loading.set(false)))
.subscribe({
next: (dashboard) => {
@@ -663,8 +665,8 @@ export class RiskBudgetDashboardComponent implements OnInit {
const config = {
id: rawConfig?.id ?? 'risk-budget-default',
tenantId: rawConfig?.tenantId ?? 'acme-tenant',
projectId: rawConfig?.projectId,
tenantId: rawConfig?.tenantId ?? this.governanceScope().tenantId,
projectId: rawConfig?.projectId ?? this.governanceScope().projectId,
name: rawConfig?.name ?? 'Default Risk Budget',
totalBudget,
warningThreshold,
@@ -750,7 +752,7 @@ export class RiskBudgetDashboardComponent implements OnInit {
}
protected acknowledgeAlert(alert: RiskBudgetAlert): void {
this.api.acknowledgeAlert(alert.id, { tenantId: 'acme-tenant' }).subscribe({
this.api.acknowledgeAlert(alert.id, this.governanceScope()).subscribe({
next: () => this.loadData(),
error: (err) => console.error('Failed to acknowledge alert:', err),
});

View File

@@ -12,6 +12,7 @@ import {
RiskProfileValidation,
SignalWeight,
} from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/**
* Risk Profile Editor component.
@@ -559,6 +560,7 @@ import {
})
export class RiskProfileEditorComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
private readonly fb = inject(FormBuilder);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
@@ -610,7 +612,7 @@ export class RiskProfileEditorComponent implements OnInit {
}
private loadAvailableProfiles(): void {
this.api.listRiskProfiles({ tenantId: 'acme-tenant', status: 'active' }).subscribe({
this.api.listRiskProfiles({ ...this.governanceScope(), status: 'active' }).subscribe({
next: (profiles) => this.availableProfiles.set(profiles),
});
}
@@ -618,7 +620,7 @@ export class RiskProfileEditorComponent implements OnInit {
private loadProfile(profileId: string): void {
this.loading.set(true);
this.api
.getRiskProfile(profileId, { tenantId: 'acme-tenant' })
.getRiskProfile(profileId, this.governanceScope())
.pipe(finalize(() => this.loading.set(false)))
.subscribe({
next: (profile) => {
@@ -736,8 +738,8 @@ export class RiskProfileEditorComponent implements OnInit {
const profile = this.buildProfile();
const request$ = this.isNew()
? this.api.createRiskProfile(profile, { tenantId: 'acme-tenant' })
: this.api.updateRiskProfile(profile.id!, profile, { tenantId: 'acme-tenant' });
? this.api.createRiskProfile(profile, this.governanceScope())
: this.api.updateRiskProfile(profile.id!, profile, this.governanceScope());
request$.pipe(finalize(() => this.saving.set(false))).subscribe({
next: () => this.router.navigate(['../'], { relativeTo: this.route }),

View File

@@ -10,6 +10,7 @@ import {
RiskProfileGov,
RiskProfileGovernanceStatus,
} from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/**
* Risk Profile List component.
@@ -389,6 +390,7 @@ import {
})
export class RiskProfileListComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
protected readonly loading = signal(false);
protected readonly profiles = signal<RiskProfileGov[]>([]);
@@ -400,7 +402,7 @@ export class RiskProfileListComponent implements OnInit {
private loadProfiles(): void {
this.loading.set(true);
const options: any = { tenantId: 'acme-tenant' };
const options: any = { ...this.governanceScope() };
const filter = this.statusFilter();
if (filter) {
options.status = filter;
@@ -423,7 +425,7 @@ export class RiskProfileListComponent implements OnInit {
protected activateProfile(profile: RiskProfileGov): void {
if (!confirm(`Activate profile "${profile.name}"?`)) return;
this.api.activateRiskProfile(profile.id, { tenantId: 'acme-tenant' }).subscribe({
this.api.activateRiskProfile(profile.id, this.governanceScope()).subscribe({
next: () => this.loadProfiles(),
error: (err) => console.error('Failed to activate profile:', err),
});
@@ -433,7 +435,7 @@ export class RiskProfileListComponent implements OnInit {
const reason = prompt(`Reason for deprecating "${profile.name}":`);
if (!reason) return;
this.api.deprecateRiskProfile(profile.id, reason, { tenantId: 'acme-tenant' }).subscribe({
this.api.deprecateRiskProfile(profile.id, reason, this.governanceScope()).subscribe({
next: () => this.loadProfiles(),
error: (err) => console.error('Failed to deprecate profile:', err),
});
@@ -442,7 +444,7 @@ export class RiskProfileListComponent implements OnInit {
protected deleteProfile(profile: RiskProfileGov): void {
if (!confirm(`Delete profile "${profile.name}"? This cannot be undone.`)) return;
this.api.deleteRiskProfile(profile.id, { tenantId: 'acme-tenant' }).subscribe({
this.api.deleteRiskProfile(profile.id, this.governanceScope()).subscribe({
next: () => this.loadProfiles(),
error: (err) => console.error('Failed to delete profile:', err),
});

View File

@@ -12,6 +12,7 @@ import {
SealedModeToggleRequest,
SealedModeOverrideRequest,
} from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/**
* Sealed Mode Control component.
@@ -754,6 +755,7 @@ import {
})
export class SealedModeControlComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
private readonly fb = inject(FormBuilder);
protected readonly loading = signal(false);
@@ -788,7 +790,7 @@ export class SealedModeControlComponent implements OnInit {
private loadStatus(): void {
this.loading.set(true);
this.api
.getSealedModeStatus({ tenantId: 'acme-tenant' })
.getSealedModeStatus(this.governanceScope())
.pipe(finalize(() => this.loading.set(false)))
.subscribe({
next: (status) => this.status.set(this.buildSafeStatus(status)),
@@ -823,7 +825,7 @@ export class SealedModeControlComponent implements OnInit {
};
this.api
.toggleSealedMode(request, { tenantId: 'acme-tenant' })
.toggleSealedMode(request, this.governanceScope())
.pipe(finalize(() => this.toggling.set(false)))
.subscribe({
next: (status) => {
@@ -854,7 +856,7 @@ export class SealedModeControlComponent implements OnInit {
};
this.api
.toggleSealedMode(request, { tenantId: 'acme-tenant' })
.toggleSealedMode(request, this.governanceScope())
.pipe(finalize(() => this.toggling.set(false)))
.subscribe({
next: (status) => {
@@ -888,7 +890,7 @@ export class SealedModeControlComponent implements OnInit {
};
this.api
.createSealedModeOverride(request, { tenantId: 'acme-tenant' })
.createSealedModeOverride(request, this.governanceScope())
.pipe(finalize(() => this.creatingOverride.set(false)))
.subscribe({
next: () => {
@@ -902,7 +904,7 @@ export class SealedModeControlComponent implements OnInit {
protected revokeOverride(override: SealedModeOverride): void {
if (!confirm('Revoke this override?')) return;
this.api.revokeSealedModeOverride(override.id, 'user_revoked', { tenantId: 'acme-tenant' }).subscribe({
this.api.revokeSealedModeOverride(override.id, 'user_revoked', this.governanceScope()).subscribe({
next: () => this.loadStatus(),
error: (err) => console.error('Failed to revoke override:', err),
});

View File

@@ -11,6 +11,7 @@ import {
SealedModeOverride,
SealedModeOverrideRequest,
} from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/**
* Sealed Mode Overrides component.
@@ -615,6 +616,7 @@ import {
})
export class SealedModeOverridesComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
protected readonly loading = signal(false);
protected readonly creating = signal(false);
@@ -659,7 +661,7 @@ export class SealedModeOverridesComponent implements OnInit {
private loadOverrides(): void {
this.loading.set(true);
this.api
.getSealedModeOverrides({ tenantId: 'acme-tenant' })
.getSealedModeOverrides(this.governanceScope())
.pipe(finalize(() => this.loading.set(false)))
.subscribe({
next: (overrides) => this.allOverrides.set(overrides),
@@ -698,7 +700,7 @@ export class SealedModeOverridesComponent implements OnInit {
this.creating.set(true);
this.api
.createSealedModeOverride(this.newOverride, { tenantId: 'acme-tenant' })
.createSealedModeOverride(this.newOverride, this.governanceScope())
.pipe(finalize(() => this.creating.set(false)))
.subscribe({
next: () => {
@@ -726,7 +728,7 @@ export class SealedModeOverridesComponent implements OnInit {
reason: `Extension for ${override.id}: ${override.reason}`,
durationHours,
},
{ tenantId: 'acme-tenant' }
this.governanceScope()
)
.pipe(finalize(() => this.creating.set(false)))
.subscribe({
@@ -739,7 +741,7 @@ export class SealedModeOverridesComponent implements OnInit {
const reason = prompt('Reason for revoking this override:');
if (!reason) return;
this.api.revokeSealedModeOverride(override.id, reason, { tenantId: 'acme-tenant' }).subscribe({
this.api.revokeSealedModeOverride(override.id, reason, this.governanceScope()).subscribe({
next: () => this.loadOverrides(),
error: (err) => console.error('Failed to revoke override:', err),
});

View File

@@ -13,6 +13,7 @@ import {
StalenessDataType,
StalenessLevel,
} from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/**
* Staleness Configuration component.
@@ -601,6 +602,7 @@ import {
})
export class StalenessConfigComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
protected readonly loading = signal(false);
protected readonly saving = signal(false);
@@ -625,7 +627,7 @@ export class StalenessConfigComponent implements OnInit {
private loadConfig(): void {
this.loading.set(true);
this.api
.getStalenessConfig({ tenantId: 'acme-tenant' })
.getStalenessConfig(this.governanceScope())
.pipe(finalize(() => this.loading.set(false)))
.subscribe({
next: (container) => {
@@ -642,7 +644,7 @@ export class StalenessConfigComponent implements OnInit {
}
private loadStatus(): void {
this.api.getStalenessStatus({ tenantId: 'acme-tenant' }).subscribe({
this.api.getStalenessStatus(this.governanceScope()).subscribe({
next: (statuses) => this.statusList.set(Array.isArray(statuses) ? statuses : []),
error: (err) => console.error('Failed to load staleness status:', err),
});
@@ -710,7 +712,7 @@ export class StalenessConfigComponent implements OnInit {
protected saveConfig(config: StalenessConfig): void {
this.saving.set(true);
this.api
.updateStalenessConfig(config, { tenantId: 'acme-tenant' })
.updateStalenessConfig(config, this.governanceScope())
.pipe(finalize(() => this.saving.set(false)))
.subscribe({
next: () => this.loadConfig(),
@@ -735,8 +737,8 @@ export class StalenessConfigComponent implements OnInit {
);
return {
tenantId: container?.tenantId ?? 'acme-tenant',
projectId: container?.projectId,
tenantId: container?.tenantId ?? this.governanceScope().tenantId,
projectId: container?.projectId ?? this.governanceScope().projectId,
configs,
modifiedAt: container?.modifiedAt ?? now,
etag: container?.etag,

View File

@@ -12,6 +12,7 @@ import {
TrustWeightImpact,
TrustWeightSource,
} from '../../core/api/policy-governance.models';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
/**
* Trust Weighting component.
@@ -708,6 +709,7 @@ import {
})
export class TrustWeightingComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
private readonly fb = inject(FormBuilder);
protected readonly loading = signal(false);
@@ -738,7 +740,7 @@ export class TrustWeightingComponent implements OnInit {
private loadConfig(): void {
this.loading.set(true);
this.api
.getTrustWeightConfig({ tenantId: 'acme-tenant' })
.getTrustWeightConfig(this.governanceScope())
.pipe(finalize(() => this.loading.set(false)))
.subscribe({
next: (config) => this.config.set(config),
@@ -814,7 +816,7 @@ export class TrustWeightingComponent implements OnInit {
};
this.api
.updateTrustWeight(weight, { tenantId: 'acme-tenant' })
.updateTrustWeight(weight, this.governanceScope())
.pipe(finalize(() => this.saving.set(false)))
.subscribe({
next: () => {
@@ -828,7 +830,7 @@ export class TrustWeightingComponent implements OnInit {
protected deleteWeight(weight: TrustWeight): void {
if (!confirm(`Delete trust weight for ${weight.issuerName}?`)) return;
this.api.deleteTrustWeight(weight.id, { tenantId: 'acme-tenant' }).subscribe({
this.api.deleteTrustWeight(weight.id, this.governanceScope()).subscribe({
next: () => this.loadConfig(),
error: (err) => console.error('Failed to delete weight:', err),
});
@@ -840,7 +842,7 @@ export class TrustWeightingComponent implements OnInit {
this.impact.set(null);
this.api
.previewTrustWeightImpact([weight], { tenantId: 'acme-tenant' })
.previewTrustWeightImpact([weight], this.governanceScope())
.pipe(finalize(() => this.impactLoading.set(false)))
.subscribe({
next: (result) => this.impact.set(result),

View File

@@ -662,7 +662,7 @@ export class VexHubDashboardComponent implements OnInit {
getSourcePercentage(value: number): number {
const s = this.stats();
if (!s) return 0;
const max = Math.max(...Object.values(s.bySource));
const max = Math.max(0, ...Object.values(s.bySource));
return max > 0 ? (value / max) * 100 : 0;
}
@@ -673,6 +673,11 @@ export class VexHubDashboardComponent implements OnInit {
oss: 'OSS Maintainer',
researcher: 'Security Researcher',
ai_generated: 'AI Generated',
internal: 'Internal',
community: 'Community',
distributor: 'Distributor',
aggregator: 'Aggregator',
unknown: 'Unknown',
};
return labels[type] || type;
}

View File

@@ -0,0 +1,112 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { VEX_HUB_API, VexHubApi } from '../../core/api/vex-hub.client';
import { VexHubStats } from '../../core/api/vex-hub.models';
import { VexHubDashboardComponent } from './vex-hub-dashboard.component';
import { VexHubStatsComponent } from './vex-hub-stats.component';
describe('VexHub source contract coverage', () => {
let api: jasmine.SpyObj<VexHubApi>;
const dashboardContract: VexHubStats = {
totalStatements: 12,
byStatus: {
affected: 4,
not_affected: 5,
fixed: 2,
under_investigation: 1,
},
bySource: {
internal: 9,
community: 3,
},
recentActivity: [],
trends: [],
};
beforeEach(() => {
api = jasmine.createSpyObj<VexHubApi>('VexHubApi', [
'searchStatements',
'getStatement',
'createStatement',
'createStatementSimple',
'getStats',
'getConsensus',
'getConsensusResult',
'getConflicts',
'getConflictStatements',
'resolveConflict',
'getVexLensConsensus',
'getVexLensConflicts',
]);
api.getStats.and.returnValue(of(dashboardContract));
});
it('keeps dynamic source buckets in descending order for the stats breakdown', async () => {
await TestBed.configureTestingModule({
imports: [VexHubStatsComponent],
providers: [
provideRouter([]),
{ provide: VEX_HUB_API, useValue: api },
],
}).compileComponents();
const fixture = TestBed.createComponent(VexHubStatsComponent);
const component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(component.sourceItems()).toEqual([
{ source: 'internal', count: 9 },
{ source: 'community', count: 3 },
]);
expect(component.formatSourceType('internal')).toBe('Internal');
expect(component.formatSourceType('community')).toBe('Community');
expect(component.getSourceIcon('aggregator')).toBe('A');
});
it('renders dynamic dashboard source labels from the live stats contract', async () => {
await TestBed.configureTestingModule({
imports: [VexHubDashboardComponent],
providers: [
provideRouter([]),
{ provide: VEX_HUB_API, useValue: api },
],
}).compileComponents();
const fixture: ComponentFixture<VexHubDashboardComponent> = TestBed.createComponent(VexHubDashboardComponent);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const labels = Array.from(
fixture.nativeElement.querySelectorAll('.source-card__label') as NodeListOf<HTMLElement>,
).map((node) => node.textContent?.trim());
expect(labels).toContain('Internal');
expect(labels).toContain('Community');
});
it('returns zero dashboard source width when the source map is empty', async () => {
await TestBed.configureTestingModule({
imports: [VexHubDashboardComponent],
providers: [
provideRouter([]),
{ provide: VEX_HUB_API, useValue: api },
],
}).compileComponents();
const fixture = TestBed.createComponent(VexHubDashboardComponent);
const component = fixture.componentInstance;
component.stats.set({
...dashboardContract,
bySource: {},
});
expect(component.getSourcePercentage(4)).toBe(0);
});
});

View File

@@ -1,23 +1,18 @@
/**
* Unit tests for VexHubStatsComponent.
* Tests for VEX-AI-004: Statements by status, source breakdown, trends.
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { By } from '@angular/platform-browser';
import { of, throwError } from 'rxjs';
import { VexHubStatsComponent } from './vex-hub-stats.component';
import { VEX_HUB_API, VexHubApi } from '../../core/api/vex-hub.client';
import { VexHubStats, VexActivityItem, VexTrendData } from '../../core/api/vex-hub.models';
import { VexHubStats } from '../../core/api/vex-hub.models';
import { VexHubStatsComponent } from './vex-hub-stats.component';
describe('VexHubStatsComponent', () => {
let component: VexHubStatsComponent;
let fixture: ComponentFixture<VexHubStatsComponent>;
let mockVexHubApi: jasmine.SpyObj<VexHubApi>;
let component: VexHubStatsComponent;
let api: jasmine.SpyObj<VexHubApi>;
const mockStats: VexHubStats = {
const stats: VexHubStats = {
totalStatements: 1000,
byStatus: {
affected: 150,
@@ -63,8 +58,8 @@ describe('VexHubStatsComponent', () => {
],
};
beforeEach(async () => {
mockVexHubApi = jasmine.createSpyObj<VexHubApi>('VexHubApi', [
async function createComponent(): Promise<void> {
api = jasmine.createSpyObj<VexHubApi>('VexHubApi', [
'searchStatements',
'getStatement',
'createStatement',
@@ -78,424 +73,135 @@ describe('VexHubStatsComponent', () => {
'getVexLensConsensus',
'getVexLensConflicts',
]);
mockVexHubApi.getStats.and.returnValue(of(mockStats));
api.getStats.and.returnValue(of(stats));
await TestBed.configureTestingModule({
imports: [VexHubStatsComponent],
providers: [
provideRouter([]),
{ provide: VEX_HUB_API, useValue: mockVexHubApi },
{ provide: VEX_HUB_API, useValue: api },
],
}).compileComponents();
fixture = TestBed.createComponent(VexHubStatsComponent);
component = fixture.componentInstance;
}
async function renderComponent(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(async () => {
await createComponent();
});
describe('Component Creation', () => {
it('should create the component', () => {
expect(component).toBeTruthy();
});
it('loads stats on init and renders the summary surface', async () => {
await renderComponent();
it('should have default signal values', () => {
expect(component.loading()).toBe(false);
expect(component.error()).toBeNull();
expect(component.stats()).toBeNull();
});
expect(api.getStats).toHaveBeenCalled();
expect(component.stats()).toEqual(stats);
expect(component.loading()).toBe(false);
it('should have default refreshInterval input', () => {
expect(component.refreshInterval()).toBe(0);
});
const header = fixture.debugElement.query(By.css('.stats-header h1'));
const summaryValue = fixture.debugElement.query(By.css('.summary-value'));
const sourceRows = fixture.debugElement.queryAll(By.css('.source-row'));
expect(header.nativeElement.textContent).toContain('VEX Hub Statistics');
expect(summaryValue.nativeElement.textContent).toContain('1,000');
expect(sourceRows.length).toBe(5);
});
describe('Template Rendering', () => {
it('should render header', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
it('computes status, source, and trend values from the loaded contract', async () => {
await renderComponent();
const header = fixture.debugElement.query(By.css('.stats-header h1'));
expect(header.nativeElement.textContent).toContain('VEX Hub Statistics');
}));
expect(component.statusItems()).toEqual([
{ status: 'affected', count: 150 },
{ status: 'not_affected', count: 600 },
{ status: 'fixed', count: 200 },
{ status: 'under_investigation', count: 50 },
]);
it('should render back button', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const backButton = fixture.debugElement.query(By.css('.btn-back'));
expect(backButton).not.toBeNull();
expect(backButton.nativeElement.textContent).toContain('Dashboard');
}));
it('should show loading state', () => {
component.loading.set(true);
fixture.detectChanges();
const loadingState = fixture.debugElement.query(By.css('.loading-state'));
expect(loadingState).not.toBeNull();
expect(loadingState.nativeElement.textContent).toContain('Loading statistics');
});
it('should render total statements card', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const summaryCard = fixture.debugElement.query(By.css('.summary-card--total'));
expect(summaryCard).not.toBeNull();
const summaryValue = fixture.debugElement.query(By.css('.summary-value'));
expect(summaryValue.nativeElement.textContent).toContain('1,000');
}));
it('should render status distribution section', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const distributionSection = fixture.debugElement.query(By.css('.distribution-section'));
expect(distributionSection).not.toBeNull();
const statusCards = fixture.debugElement.queryAll(By.css('.status-card'));
expect(statusCards.length).toBe(4);
}));
it('should render status legend', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const legendItems = fixture.debugElement.queryAll(By.css('.legend-item'));
expect(legendItems.length).toBe(4);
}));
it('should render source breakdown section', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const sourcesSection = fixture.debugElement.query(By.css('.sources-section'));
expect(sourcesSection).not.toBeNull();
const sourceRows = fixture.debugElement.queryAll(By.css('.source-row'));
expect(sourceRows.length).toBe(5);
}));
it('should render recent activity section', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const activitySection = fixture.debugElement.query(By.css('.activity-section'));
expect(activitySection).not.toBeNull();
const activityItems = fixture.debugElement.queryAll(By.css('.activity-item'));
expect(activityItems.length).toBe(3);
}));
it('should render trends section', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const trendsSection = fixture.debugElement.query(By.css('.trends-section'));
expect(trendsSection).not.toBeNull();
const trendBarGroups = fixture.debugElement.queryAll(By.css('.trend-bar-group'));
expect(trendBarGroups.length).toBe(7);
}));
it('should show error banner when error is set', () => {
component.error.set('Failed to load statistics');
fixture.detectChanges();
const errorBanner = fixture.debugElement.query(By.css('.error-banner'));
expect(errorBanner).not.toBeNull();
expect(errorBanner.nativeElement.textContent).toContain('Failed to load statistics');
});
it('should show empty activity message when no activity', fakeAsync(() => {
mockVexHubApi.getStats.and.returnValue(of({ ...mockStats, recentActivity: [] }));
component.ngOnInit();
tick();
fixture.detectChanges();
const emptyActivity = fixture.debugElement.query(By.css('.empty-activity'));
expect(emptyActivity).not.toBeNull();
expect(emptyActivity.nativeElement.textContent).toContain('No recent activity');
}));
it('should not render trends section when no trends data', fakeAsync(() => {
mockVexHubApi.getStats.and.returnValue(of({ ...mockStats, trends: [] }));
component.ngOnInit();
tick();
fixture.detectChanges();
const trendsSection = fixture.debugElement.query(By.css('.trends-section'));
expect(trendsSection).toBeNull();
}));
expect(component.sourceItems()[0]).toEqual({ source: 'vendor', count: 400 });
expect(component.maxTrendValue()).toBe(75);
expect(component.getStatusPercentage(150)).toBe(15);
expect(component.getSourcePercentage(400)).toBe(40);
expect(component.getTrendHeight(75)).toBe(80);
expect(component.getTrendHeight(0)).toBe(4);
});
describe('OnInit', () => {
it('should load stats on init', fakeAsync(() => {
fixture.detectChanges();
tick();
it('renders recent activity and trend bars after loading', async () => {
await renderComponent();
expect(mockVexHubApi.getStats).toHaveBeenCalled();
expect(component.stats()).toEqual(mockStats);
}));
const activityItems = fixture.debugElement.queryAll(By.css('.activity-item'));
const trendGroups = fixture.debugElement.queryAll(By.css('.trend-bar-group'));
const createdIndicator = fixture.debugElement.query(By.css('.activity-indicator--created'));
it('should set loading state during API call', fakeAsync(() => {
component.loadStats();
expect(component.loading()).toBe(true);
tick();
expect(component.loading()).toBe(false);
}));
expect(activityItems.length).toBe(3);
expect(trendGroups.length).toBe(7);
expect(createdIndicator).not.toBeNull();
});
describe('Service Interactions', () => {
it('should set stats when API returns successfully', fakeAsync(() => {
component.loadStats();
tick();
it('renders the empty activity state and hides trends when the backend returns none', async () => {
api.getStats.and.returnValue(of({ ...stats, recentActivity: [], trends: [] }));
expect(component.stats()).toEqual(mockStats);
expect(component.error()).toBeNull();
}));
fixture = TestBed.createComponent(VexHubStatsComponent);
component = fixture.componentInstance;
await renderComponent();
it('should set error when API call fails', fakeAsync(() => {
const errorMessage = 'Network error';
mockVexHubApi.getStats.and.returnValue(throwError(() => new Error(errorMessage)));
component.loadStats();
tick();
expect(component.error()).toBe(errorMessage);
expect(component.loading()).toBe(false);
}));
it('should handle non-Error exceptions', fakeAsync(() => {
mockVexHubApi.getStats.and.returnValue(throwError(() => 'String error'));
component.loadStats();
tick();
expect(component.error()).toBe('Failed to load statistics');
}));
it('should retry when retry button is clicked', fakeAsync(() => {
component.error.set('Some error');
fixture.detectChanges();
const retryButton = fixture.debugElement.query(By.css('.btn--text'));
retryButton.triggerEventHandler('click', null);
tick();
expect(mockVexHubApi.getStats).toHaveBeenCalled();
}));
expect(fixture.debugElement.query(By.css('.empty-activity'))).not.toBeNull();
expect(fixture.debugElement.query(By.css('.trends-section'))).toBeNull();
expect(component.maxTrendValue()).toBe(0);
});
describe('Computed Values', () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
tick();
}));
it('surfaces backend failures and retries through the same API contract', async () => {
api.getStats.and.returnValues(
throwError(() => new Error('Network error')),
of(stats),
);
it('should compute statusItems correctly', () => {
const items = component.statusItems();
expect(items.length).toBe(4);
expect(items.find(i => i.status === 'affected')?.count).toBe(150);
expect(items.find(i => i.status === 'not_affected')?.count).toBe(600);
});
fixture = TestBed.createComponent(VexHubStatsComponent);
component = fixture.componentInstance;
await renderComponent();
it('should compute sourceItems sorted by count descending', () => {
const items = component.sourceItems();
expect(items.length).toBe(5);
expect(items[0].source).toBe('vendor');
expect(items[0].count).toBe(400);
});
const errorBanner = fixture.debugElement.query(By.css('.error-banner'));
expect(errorBanner).not.toBeNull();
expect(errorBanner.nativeElement.textContent).toContain('Network error');
expect(component.loading()).toBe(false);
it('should compute maxTrendValue correctly', () => {
expect(component.maxTrendValue()).toBe(75); // max notAffected value
});
errorBanner.query(By.css('.btn--text')).triggerEventHandler('click', null);
await fixture.whenStable();
fixture.detectChanges();
it('should return 0 for maxTrendValue when no trends', () => {
component.stats.set({ ...mockStats, trends: [] });
expect(component.maxTrendValue()).toBe(0);
});
expect(api.getStats).toHaveBeenCalledTimes(2);
expect(component.error()).toBeNull();
expect(component.stats()).toEqual(stats);
});
describe('Percentage Calculations', () => {
beforeEach(fakeAsync(() => {
fixture.detectChanges();
tick();
}));
it('formats current source labels and icons for live stats buckets', async () => {
await renderComponent();
it('should calculate status percentage correctly', () => {
const percentage = component.getStatusPercentage(150);
expect(percentage).toBe(15); // 150/1000 = 15%
});
expect(component.formatSourceType('internal')).toBe('Internal');
expect(component.formatSourceType('community')).toBe('Community');
expect(component.formatSourceType('distributor')).toBe('Distributor');
expect(component.formatSourceType('aggregator')).toBe('Aggregator');
expect(component.formatSourceType('unknown')).toBe('Unknown');
it('should return 0 for status percentage when total is 0', () => {
component.stats.set({ ...mockStats, totalStatements: 0 });
expect(component.getStatusPercentage(150)).toBe(0);
});
it('should calculate source percentage correctly', () => {
const percentage = component.getSourcePercentage(400);
expect(percentage).toBe(40); // 400/1000 = 40%
});
it('should return 0 for source percentage when total is 0', () => {
component.stats.set({ ...mockStats, totalStatements: 0 });
expect(component.getSourcePercentage(400)).toBe(0);
});
it('should calculate trend height correctly', () => {
const height = component.getTrendHeight(75); // max value
expect(height).toBe(80); // (75/75) * 80 = 80
});
it('should return minimum height when value is 0', () => {
const height = component.getTrendHeight(0);
expect(height).toBe(4);
});
it('should return minimum height when maxTrendValue is 0', () => {
component.stats.set({ ...mockStats, trends: [] });
const height = component.getTrendHeight(10);
expect(height).toBe(4);
});
expect(component.getSourceIcon('internal')).toBe('I');
expect(component.getSourceIcon('community')).toBe('C');
expect(component.getSourceIcon('distributor')).toBe('D');
expect(component.getSourceIcon('aggregator')).toBe('A');
expect(component.getSourceIcon('unknown')).toBe('?');
});
describe('Format Functions', () => {
it('should format status correctly', () => {
expect(component.formatStatus('affected')).toBe('Affected');
expect(component.formatStatus('not_affected')).toBe('Not Affected');
expect(component.formatStatus('fixed')).toBe('Fixed');
expect(component.formatStatus('under_investigation')).toBe('Investigating');
});
it('keeps percentage helpers at zero when the total statement count is missing', async () => {
await renderComponent();
it('should return original status for unknown values', () => {
expect(component.formatStatus('unknown' as any)).toBe('unknown');
});
component.stats.set({ ...stats, totalStatements: 0, trends: [] });
it('should format source type correctly', () => {
expect(component.formatSourceType('vendor')).toBe('Vendor');
expect(component.formatSourceType('cert')).toBe('CERT/CSIRT');
expect(component.formatSourceType('oss')).toBe('OSS Maintainer');
expect(component.formatSourceType('researcher')).toBe('Researcher');
expect(component.formatSourceType('ai_generated')).toBe('AI Generated');
});
it('should return original source type for unknown values', () => {
expect(component.formatSourceType('unknown' as any)).toBe('unknown');
});
it('should format activity action correctly', () => {
expect(component.formatActivityAction('created')).toBe('Statement created');
expect(component.formatActivityAction('updated')).toBe('Statement updated');
expect(component.formatActivityAction('superseded')).toBe('Statement superseded');
});
it('should return original action for unknown values', () => {
expect(component.formatActivityAction('unknown')).toBe('unknown');
});
it('should get source icon correctly', () => {
expect(component.getSourceIcon('vendor')).toBe('V');
expect(component.getSourceIcon('cert')).toBe('C');
expect(component.getSourceIcon('oss')).toBe('O');
expect(component.getSourceIcon('researcher')).toBe('R');
expect(component.getSourceIcon('ai_generated')).toBe('AI');
});
it('should return ? for unknown source icon', () => {
expect(component.getSourceIcon('unknown' as any)).toBe('?');
});
});
describe('Activity Icons', () => {
it('should render correct icon for created action', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const createdIndicator = fixture.debugElement.query(By.css('.activity-indicator--created'));
expect(createdIndicator).not.toBeNull();
}));
it('should render correct icon for updated action', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const updatedIndicator = fixture.debugElement.query(By.css('.activity-indicator--updated'));
expect(updatedIndicator).not.toBeNull();
}));
it('should render correct icon for superseded action', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const supersededIndicator = fixture.debugElement.query(By.css('.activity-indicator--superseded'));
expect(supersededIndicator).not.toBeNull();
}));
});
describe('Status Cards', () => {
it('should render status cards with correct counts', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const statusCounts = fixture.debugElement.queryAll(By.css('.status-count'));
const countTexts = statusCounts.map(el => el.nativeElement.textContent.trim());
expect(countTexts).toContain('150');
expect(countTexts).toContain('600');
expect(countTexts).toContain('200');
expect(countTexts).toContain('50');
}));
it('should render status bars with correct heights', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const statusBars = fixture.debugElement.queryAll(By.css('.status-bar'));
expect(statusBars.length).toBe(4);
}));
});
describe('Source Bars', () => {
it('should render source bars with correct widths', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const sourceBars = fixture.debugElement.queryAll(By.css('.source-bar'));
expect(sourceBars.length).toBe(5);
}));
it('should display source counts', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const sourceCounts = fixture.debugElement.queryAll(By.css('.source-count'));
expect(sourceCounts.length).toBe(5);
}));
});
describe('Input Handling', () => {
it('should accept refreshInterval input', () => {
fixture.componentRef.setInput('refreshInterval', 30000);
fixture.detectChanges();
expect(component.refreshInterval()).toBe(30000);
});
expect(component.getStatusPercentage(10)).toBe(0);
expect(component.getSourcePercentage(10)).toBe(0);
expect(component.getTrendHeight(10)).toBe(4);
});
});

View File

@@ -20,7 +20,6 @@ import { VEX_HUB_API, VexHubApi } from '../../core/api/vex-hub.client';
import {
VexHubStats,
VexStatementStatus,
VexIssuerType,
VexActivityItem,
VexTrendData,
} from '../../core/api/vex-hub.models';
@@ -759,11 +758,10 @@ export class VexHubStatsComponent implements OnInit {
readonly sourceItems = computed(() => {
const s = this.stats();
if (!s) return [];
const sources: VexIssuerType[] = ['vendor', 'cert', 'oss', 'researcher', 'ai_generated'];
return sources
.map((source) => ({
return Object.entries(s.bySource)
.map(([source, count]) => ({
source,
count: s.bySource[source] || 0,
count,
}))
.sort((a, b) => b.count - a.count);
});
@@ -812,13 +810,18 @@ export class VexHubStatsComponent implements OnInit {
return Math.max(4, (value / max) * 80);
}
getSourceIcon(source: VexIssuerType): string {
const icons: Record<VexIssuerType, string> = {
getSourceIcon(source: string): string {
const icons: Record<string, string> = {
vendor: 'V',
cert: 'C',
oss: 'O',
researcher: 'R',
ai_generated: 'AI',
internal: 'I',
community: 'C',
distributor: 'D',
aggregator: 'A',
unknown: '?',
};
return icons[source] || '?';
}
@@ -833,13 +836,18 @@ export class VexHubStatsComponent implements OnInit {
return labels[status] || status;
}
formatSourceType(type: VexIssuerType): string {
const labels: Record<VexIssuerType, string> = {
formatSourceType(type: string): string {
const labels: Record<string, string> = {
vendor: 'Vendor',
cert: 'CERT/CSIRT',
oss: 'OSS Maintainer',
researcher: 'Researcher',
ai_generated: 'AI Generated',
internal: 'Internal',
community: 'Community',
distributor: 'Distributor',
aggregator: 'Aggregator',
unknown: 'Unknown',
};
return labels[type] || type;
}

View File

@@ -23,12 +23,30 @@ function createSpy<T extends (...args: any[]) => any>(
// ---------------------------------------------------------------------------
// jasmine.createSpyObj → object whose methods are vi.fn()
// ---------------------------------------------------------------------------
function createSpyObj<T extends string>(
baseNameOrMethods: string | T[],
methodNamesOrProperties?: T[] | Record<string, unknown>,
function createSpyObj<T extends object>(
baseName: string,
methodNames: ReadonlyArray<Extract<keyof T, string>>,
propertyNames?: Partial<Record<Extract<keyof T, string>, unknown>>,
): jasmine.SpyObj<T>;
function createSpyObj<T extends object>(
methodNames: ReadonlyArray<Extract<keyof T, string>>,
propertyNames?: Partial<Record<Extract<keyof T, string>, unknown>>,
): jasmine.SpyObj<T>;
function createSpyObj(
baseName: string,
methodNames: readonly string[],
propertyNames?: Record<string, unknown>,
): Record<string, Mock>;
function createSpyObj(
methodNames: readonly string[],
propertyNames?: Record<string, unknown>,
): Record<string, Mock>;
function createSpyObj(
baseNameOrMethods: string | readonly string[],
methodNamesOrProperties?: readonly string[] | Record<string, unknown>,
propertyNames?: Record<string, unknown>,
): Record<string, Mock> {
let methods: T[];
let methods: readonly string[];
let properties: Record<string, unknown> | undefined;
if (Array.isArray(baseNameOrMethods)) {
@@ -38,7 +56,7 @@ function createSpyObj<T extends string>(
? (methodNamesOrProperties as Record<string, unknown>)
: undefined;
} else {
methods = (methodNamesOrProperties ?? []) as T[];
methods = Array.isArray(methodNamesOrProperties) ? methodNamesOrProperties : [];
properties = propertyNames;
}
@@ -124,15 +142,24 @@ declare global {
name?: string,
originalFn?: T,
): Mock<T>;
function createSpyObj<T extends string>(
function createSpyObj<T extends object>(
baseName: string,
methodNames: T[],
methodNames: ReadonlyArray<Extract<keyof T, string>>,
propertyNames?: Partial<Record<Extract<keyof T, string>, unknown>>,
): SpyObj<T>;
function createSpyObj<T extends object>(
methodNames: ReadonlyArray<Extract<keyof T, string>>,
propertyNames?: Partial<Record<Extract<keyof T, string>, unknown>>,
): SpyObj<T>;
function createSpyObj(
baseName: string,
methodNames: readonly string[],
propertyNames?: Record<string, unknown>,
): Record<T, Mock>;
function createSpyObj<T extends string>(
methodNames: T[],
): Record<string, Mock>;
function createSpyObj(
methodNames: readonly string[],
propertyNames?: Record<string, unknown>,
): Record<T, Mock>;
): Record<string, Mock>;
function objectContaining(sample: Record<string, unknown>): any;
function arrayContaining(sample: unknown[]): any;
function stringMatching(pattern: string | RegExp): any;
@@ -209,6 +236,14 @@ if (!Object.getOwnPropertyDescriptor(MockPrototype, 'and')) {
self.mockRestore();
return self;
},
resolveTo(val: unknown) {
self.mockResolvedValue(val);
return self;
},
rejectWith(val: unknown) {
self.mockRejectedValue(val);
return self;
},
throwError(msg: string | Error) {
self.mockImplementation(() => {
throw typeof msg === 'string' ? new Error(msg) : msg;

View File

@@ -26,6 +26,8 @@
"src/app/features/evidence-export/stella-bundle-export-button/stella-bundle-export-button.component.spec.ts",
"src/app/features/jobengine/jobengine-dashboard.component.spec.ts",
"src/app/features/platform/ops/platform-jobs-queues-page.component.spec.ts",
"src/app/features/policy-governance/conflict-resolution-wizard.component.spec.ts",
"src/app/features/policy-governance/policy-conflict-dashboard.component.spec.ts",
"src/app/features/policy-simulation/policy-simulation-direct-route-defaults.spec.ts",
"src/app/features/policy-simulation/policy-simulation-defaults.spec.ts",
"src/app/features/policy-simulation/simulation-dashboard.component.spec.ts",
@@ -34,6 +36,8 @@
"src/app/features/trust-admin/trust-admin.component.spec.ts",
"src/app/features/triage/services/ttfs-telemetry.service.spec.ts",
"src/app/features/triage/triage-workspace.component.spec.ts",
"src/app/features/vex-hub/vex-hub-stats.component.spec.ts",
"src/app/features/vex-hub/vex-hub-source-contract.spec.ts",
"src/app/shared/ui/filter-bar/filter-bar.component.spec.ts",
"src/app/features/watchlist/watchlist-page.component.spec.ts",
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts"