Close scratch iteration 009 grouped policy and VEX audit repairs
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user