feat(web): VEX hub client expansion, integration hub bootstrap, policy e2e

app.config: wiring updates for VEX hub statement providers + integration
hub DI.

VEX hub client: large refactor and expansion of vex-hub.client.ts (+spec)
with the shape needed by the statement detail panel and the new
noise-gating surfaces. vex-statement-detail-panel.component aligned with
the new client contract.

Integration hub component: extends the bootstrap + verification flow
(browser-backed, no mocks) and updates the spec coverage accordingly.

New tooling:
- scripts/run-policy-orchestrator-proof-e2e.mjs to drive the orchestrator
  proof flow from outside the Angular test harness.
- src/tests/triage/noise-gating-api.providers.spec.ts covers the DI
  providers wiring for the triage noise-gating surface.
- tests/e2e/integrations/policy-orchestrator.e2e.spec.ts exercises the
  policy orchestrator UI end-to-end.
- tsconfig.spec.vex.json isolates the VEX spec compile so it does not
  fight the main triage configs.
- angular.json + package.json wire the new spec/e2e targets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-15 11:16:05 +03:00
parent 1e8dbbeeb0
commit fc14a59b1f
14 changed files with 1318 additions and 401 deletions

View File

@@ -134,6 +134,8 @@
"runnerConfig": "vitest.active-surfaces.config.ts",
"setupFiles": ["src/test-setup.ts"],
"include": [
"src/app/features/setup-wizard/components/setup-wizard.component.spec.ts",
"src/app/features/setup-wizard/services/setup-wizard-state.service.spec.ts",
"src/app/features/integration-hub/integration.service.spec.ts",
"src/app/features/integrations/integration-wizard.component.spec.ts",
"src/tests/deployments/create-deployment.component.spec.ts",
@@ -147,6 +149,20 @@
]
}
},
"test-vex": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "tsconfig.spec.vex.json",
"buildTarget": "stellaops-web:build:development",
"runner": "vitest",
"runnerConfig": "vitest.codex.config.ts",
"setupFiles": ["src/test-setup.ts"],
"include": [
"src/app/core/api/vex-hub.client.spec.ts",
"src/app/features/vex-hub/vex-statement-detail-panel.component.spec.ts"
]
}
},
"storybook": {
"builder": "@storybook/angular:start-storybook",
"options": {

View File

@@ -15,8 +15,10 @@
"test:active-surfaces": "node --max-old-space-size=3072 ./node_modules/@angular/cli/bin/ng run stellaops-web:test-active-surfaces --watch=false",
"test:watch": "node --max-old-space-size=3072 ./node_modules/@angular/cli/bin/ng test",
"test:ci": "npm run test",
"test:vex": "node --max-old-space-size=3072 ./node_modules/@angular/cli/bin/ng run stellaops-web:test-vex --watch=false",
"test:e2e": "playwright test",
"test:e2e:search:live": "node ./scripts/run-live-search-e2e.mjs",
"test:e2e:policy:producer:live": "node ./scripts/run-policy-orchestrator-proof-e2e.mjs",
"test:e2e:live:auth": "node ./scripts/live-frontdoor-auth.mjs",
"test:e2e:live:changed-surfaces": "node ./scripts/live-frontdoor-changed-surfaces.mjs",
"serve:test": "ng serve --configuration development --port 4400 --host 127.0.0.1 --ssl",

View File

@@ -251,6 +251,22 @@ async function settle(page, waitMs = 750) {
await page.waitForTimeout(waitMs);
}
async function waitForVisibleWizardHeading(candidates, timeoutMs = 15_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
for (const candidate of candidates) {
if (await candidate.locator.isVisible().catch(() => false)) {
return candidate.key;
}
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for wizard headings: ${candidates.map((candidate) => candidate.label).join(', ')}`);
}
function getAccessToken(authReport) {
const fullSessionEntry = getSessionStorageEntries(authReport)
.find(([key]) => key === 'stellaops.auth.session.full');
@@ -442,7 +458,14 @@ async function waitForNextEnabled(page, timeoutMs = 20_000) {
async function selectProviderIfNeeded(page, definition) {
const providerHeading = page.getByRole('heading', { name: new RegExp(`Select .* Provider`, 'i') });
if (await providerHeading.isVisible().catch(() => false)) {
const connectionHeading = page.getByRole('heading', { name: /Connection & Credentials/i });
const activeStep = await waitForVisibleWizardHeading([
{ key: 'provider', label: 'Select provider', locator: providerHeading },
{ key: 'connection', label: 'Connection & Credentials', locator: connectionHeading },
]);
if (activeStep === 'provider') {
await page.getByRole('button', { name: new RegExp(definition.provider.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') })
.click({ timeout: 10_000 });
const nextButton = await waitForNextEnabled(page);
@@ -450,7 +473,9 @@ async function selectProviderIfNeeded(page, definition) {
await settle(page);
}
await page.getByRole('heading', { name: /Connection & Credentials/i }).waitFor({ timeout: 15_000 });
await waitForVisibleWizardHeading([
{ key: 'connection', label: 'Connection & Credentials', locator: connectionHeading },
]);
}
async function populateConnection(page, definition) {

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const webRoot = path.resolve(__dirname, '..');
function main() {
const forwardedArgs = process.argv.slice(2);
const env = {
...process.env,
PLAYWRIGHT_BASE_URL: process.env.PLAYWRIGHT_BASE_URL?.trim() || 'https://stella-ops.local',
PLAYWRIGHT_POLICY_ENGINE_BASE_URL:
process.env.PLAYWRIGHT_POLICY_ENGINE_BASE_URL?.trim() || 'http://policy-engine.stella-ops.local',
PLAYWRIGHT_POLICY_TENANT: process.env.PLAYWRIGHT_POLICY_TENANT?.trim() || 'demo-prod',
};
const playwrightArgs = [
'playwright',
'test',
'--config',
'playwright.integrations.config.ts',
'tests/e2e/integrations/policy-orchestrator.e2e.spec.ts',
...forwardedArgs,
];
const result = process.platform === 'win32'
? spawnSync('cmd.exe', ['/d', '/s', '/c', 'npx', ...playwrightArgs], {
cwd: webRoot,
env,
stdio: 'inherit',
})
: spawnSync('npx', playwrightArgs, {
cwd: webRoot,
env,
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
process.exit(result.status ?? 1);
}
try {
main();
} catch (error) {
console.error(`[policy-orchestrator-proof-e2e] ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}

View File

@@ -8,7 +8,7 @@ import localeUk from '@angular/common/locales/uk';
import localeZhHant from '@angular/common/locales/zh-Hant';
import localeZhHans from '@angular/common/locales/zh-Hans';
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { ApplicationConfig, inject, LOCALE_ID, provideAppInitializer } from '@angular/core';
import { ApplicationConfig, inject, LOCALE_ID, Provider, provideAppInitializer } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideRouter, TitleStrategy, withComponentInputBinding } from '@angular/router';
import { provideMarkdown } from 'ngx-markdown';
@@ -90,6 +90,7 @@ import {
VexDecisionsHttpClient,
} from './core/api/vex-decisions.client';
import { VEX_HUB_API, VEX_HUB_API_BASE_URL, VEX_LENS_API_BASE_URL, VexHubApiHttpClient } from './core/api/vex-hub.client';
import { NOISE_GATING_API, NOISE_GATING_API_BASE_URL, NoiseGatingApiHttpClient } from './core/api/noise-gating.client';
import {
AUDIT_BUNDLES_API,
AUDIT_BUNDLES_API_BASE_URL,
@@ -293,6 +294,24 @@ export function resolveApiRootUrl(baseUrl: string | undefined): string {
: normalizedBase;
}
export function provideNoiseGatingApi(): Provider[] {
return [
{
provide: NOISE_GATING_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
return resolveApiBaseUrl(gatewayBase, '/api/v1/vexlens');
},
},
NoiseGatingApiHttpClient,
{
provide: NOISE_GATING_API,
useExisting: NoiseGatingApiHttpClient,
},
];
}
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding()),
@@ -497,6 +516,7 @@ export const appConfig: ApplicationConfig = {
provide: VEX_HUB_API,
useExisting: VexHubApiHttpClient,
},
...provideNoiseGatingApi(),
VexEvidenceHttpClient,
{
provide: VEX_EVIDENCE_API,

View File

@@ -5,13 +5,12 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { firstValueFrom, of, throwError } from 'rxjs';
import {
VexHubApiHttpClient,
VEX_HUB_API_BASE_URL,
VEX_LENS_API_BASE_URL,
MockVexHubClient,
} from './vex-hub.client';
import { AuthSessionStore } from '../auth/auth-session.store';
import {
@@ -35,14 +34,71 @@ describe('VexHubApiHttpClient', () => {
let httpClientSpy: jasmine.SpyObj<HttpClient>;
let authSessionSpy: jasmine.SpyObj<AuthSessionStore>;
const mockStatementDto = {
id: 'stmt-123',
sourceStatementId: 'stmt-123',
sourceId: 'vendor-acme',
sourceDocumentId: 'DOC-001',
vulnerabilityId: 'CVE-2024-12345',
productKey: 'docker.io/acme/web:1.0',
status: 'affected',
sourceUpdatedAt: '2024-01-15T00:00:00Z',
updatedAt: '2024-01-15T00:00:00Z',
verificationStatus: 'verified',
isFlagged: false,
};
const mockConflictStatementDto = {
...mockStatementDto,
id: 'stmt-456',
sourceStatementId: 'stmt-456',
sourceId: 'research-lab',
status: 'not_affected',
verificationStatus: 'failed',
};
const mockStatement: VexStatement = {
id: 'stmt-123',
statementId: 'stmt-123',
cveId: 'CVE-2024-12345',
productRef: 'docker.io/acme/web:1.0',
status: 'affected',
sourceName: 'ACME Security',
sourceName: 'Vendor Acme',
sourceType: 'vendor',
publishedAt: new Date('2024-01-15'),
issuerName: 'Vendor Acme',
issuerType: 'vendor',
issuerTrustLevel: 'high',
documentId: 'DOC-001',
publishedAt: '2024-01-15T00:00:00Z',
createdAt: '2024-01-15T00:00:00Z',
updatedAt: '2024-01-15T00:00:00Z',
version: 1,
};
const mockConflictStatement: VexStatement = {
...mockStatement,
id: 'stmt-456',
statementId: 'stmt-456',
status: 'not_affected',
sourceName: 'Research Lab',
sourceType: 'researcher',
issuerName: 'Research Lab',
issuerType: 'researcher',
issuerTrustLevel: 'low',
};
const mockSearchDto = {
statements: [mockStatementDto],
totalCount: 1,
offset: 0,
limit: 20,
};
const mockConflictSearchDto = {
statements: [mockStatementDto, mockConflictStatementDto],
totalCount: 2,
offset: 0,
limit: 20,
};
const mockSearchResponse: VexStatementSearchResponse = {
@@ -67,14 +123,77 @@ describe('VexHubApiHttpClient', () => {
researcher: 150,
ai_generated: 50,
},
recentActivity: [],
};
const mockConsensusDto = {
vulnerabilityId: 'CVE-2024-12345',
productKey: 'docker.io/acme/web:1.0',
status: 'affected',
confidenceScore: 0.85,
contributions: [
{
statementId: 'stmt-123',
issuerId: 'vendor-acme',
status: 'affected',
weight: 0.85,
},
],
conflicts: [],
computedAt: '2024-01-15T10:00:00Z',
};
const mockConflictConsensusDto = {
vulnerabilityId: 'CVE-2024-12345',
productKey: 'docker.io/acme/web:1.0',
status: 'affected',
confidenceScore: 0.55,
contributions: [
{
statementId: 'stmt-123',
issuerId: 'vendor-acme',
status: 'affected',
weight: 0.95,
},
{
statementId: 'stmt-456',
issuerId: 'research-lab',
status: 'not_affected',
weight: 0.42,
},
],
conflicts: [
{
statement1Id: 'stmt-123',
statement2Id: 'stmt-456',
status1: 'affected',
status2: 'not_affected',
severity: 'medium',
resolution: 'Review evidence',
},
],
computedAt: '2024-01-15T10:00:00Z',
};
const mockConsensus: VexConsensus = {
cveId: 'CVE-2024-12345',
productRef: 'docker.io/acme/web:1.0',
consensusStatus: 'affected',
confidence: 0.85,
votes: [
{
issuerId: 'vendor-acme',
issuerName: 'Vendor Acme',
issuerType: 'vendor',
status: 'affected',
weight: 0.85,
statementId: 'stmt-123',
publishedAt: '2024-01-15T10:00:00Z',
},
],
hasConflict: false,
votes: [],
conflictDetails: [],
calculatedAt: '2024-01-15T10:00:00Z',
};
beforeEach(() => {
@@ -100,15 +219,15 @@ describe('VexHubApiHttpClient', () => {
});
describe('searchStatements', () => {
it('should call GET /statements with no params', () => {
httpClientSpy.get.and.returnValue(of(mockSearchResponse));
it('should call GET /search with no params', () => {
httpClientSpy.get.and.returnValue(of(mockSearchDto));
service.searchStatements({}).subscribe((result) => {
expect(result).toEqual(mockSearchResponse);
});
expect(httpClientSpy.get).toHaveBeenCalledWith(
'/api/v1/vex/statements',
'/api/v1/vex/search',
jasmine.objectContaining({
headers: jasmine.any(HttpHeaders),
params: jasmine.any(HttpParams),
@@ -117,41 +236,41 @@ describe('VexHubApiHttpClient', () => {
});
it('should include cveId in query params', () => {
httpClientSpy.get.and.returnValue(of(mockSearchResponse));
httpClientSpy.get.and.returnValue(of(mockSearchDto));
const params: VexStatementSearchParams = { cveId: 'CVE-2024-12345' };
service.searchStatements(params).subscribe();
const callArgs = httpClientSpy.get.calls.mostRecent().args;
const callArgs = httpClientSpy.get.calls.mostRecent()!.args;
const httpParams = callArgs[1]!.params as HttpParams;
expect(httpParams.get('cveId')).toBe('CVE-2024-12345');
expect(httpParams.get('vulnerabilityId')).toBe('CVE-2024-12345');
});
it('should include status filter', () => {
httpClientSpy.get.and.returnValue(of(mockSearchResponse));
httpClientSpy.get.and.returnValue(of(mockSearchDto));
const params: VexStatementSearchParams = { status: 'affected' };
service.searchStatements(params).subscribe();
const callArgs = httpClientSpy.get.calls.mostRecent().args;
const callArgs = httpClientSpy.get.calls.mostRecent()!.args;
const httpParams = callArgs[1]!.params as HttpParams;
expect(httpParams.get('status')).toBe('affected');
});
it('should include pagination params', () => {
httpClientSpy.get.and.returnValue(of(mockSearchResponse));
httpClientSpy.get.and.returnValue(of(mockSearchDto));
const params: VexStatementSearchParams = { limit: 50, offset: 100 };
service.searchStatements(params).subscribe();
const callArgs = httpClientSpy.get.calls.mostRecent().args;
const callArgs = httpClientSpy.get.calls.mostRecent()!.args;
const httpParams = callArgs[1]!.params as HttpParams;
expect(httpParams.get('limit')).toBe('50');
expect(httpParams.get('offset')).toBe('100');
});
it('should include all filter params', () => {
httpClientSpy.get.and.returnValue(of(mockSearchResponse));
httpClientSpy.get.and.returnValue(of(mockSearchDto));
const params: VexStatementSearchParams = {
cveId: 'CVE-2024-12345',
product: 'acme/web',
@@ -165,72 +284,76 @@ describe('VexHubApiHttpClient', () => {
service.searchStatements(params).subscribe();
const callArgs = httpClientSpy.get.calls.mostRecent().args;
const callArgs = httpClientSpy.get.calls.mostRecent()!.args;
const httpParams = callArgs[1]!.params as HttpParams;
expect(httpParams.get('cveId')).toBe('CVE-2024-12345');
expect(httpParams.get('product')).toBe('acme/web');
expect(httpParams.get('vulnerabilityId')).toBe('CVE-2024-12345');
expect(httpParams.get('productKey')).toBe('acme/web');
expect(httpParams.get('status')).toBe('affected');
expect(httpParams.get('source')).toBe('vendor');
expect(httpParams.get('sourceId')).toBe('vendor');
expect(httpParams.get('dateFrom')).toBe('2024-01-01');
expect(httpParams.get('dateTo')).toBe('2024-12-31');
});
it('should handle error response', (done) => {
it('should handle error response', () => {
httpClientSpy.get.and.returnValue(throwError(() => new Error('Network error')));
let actualErrorMessage = '';
service.searchStatements({}).subscribe({
error: (err) => {
expect(err.message).toContain('VEX Hub error');
done();
actualErrorMessage = err.message;
},
});
expect(actualErrorMessage).toContain('VEX Hub error');
});
it('should use custom traceId when provided', () => {
httpClientSpy.get.and.returnValue(of(mockSearchResponse));
httpClientSpy.get.and.returnValue(of(mockSearchDto));
service.searchStatements({}, { traceId: 'custom-trace-123' }).subscribe();
const callArgs = httpClientSpy.get.calls.mostRecent().args;
const callArgs = httpClientSpy.get.calls.mostRecent()!.args;
const headers = callArgs[1]!.headers as HttpHeaders;
expect(headers.get('X-Stella-Ops-Trace-Id')).toBe('custom-trace-123');
});
});
describe('getStatement', () => {
it('should call GET /statements/:id', () => {
httpClientSpy.get.and.returnValue(of(mockStatement));
it('should call GET /statement/:id', () => {
httpClientSpy.get.and.returnValue(of(mockStatementDto));
service.getStatement('stmt-123').subscribe((result) => {
expect(result).toEqual(mockStatement);
});
expect(httpClientSpy.get).toHaveBeenCalledWith(
'/api/v1/vex/statements/stmt-123',
'/api/v1/vex/statement/stmt-123',
jasmine.objectContaining({ headers: jasmine.any(HttpHeaders) })
);
});
it('should encode statement ID', () => {
httpClientSpy.get.and.returnValue(of(mockStatement));
httpClientSpy.get.and.returnValue(of(mockStatementDto));
service.getStatement('stmt/with/slashes').subscribe();
expect(httpClientSpy.get).toHaveBeenCalledWith(
'/api/v1/vex/statements/stmt%2Fwith%2Fslashes',
'/api/v1/vex/statement/stmt%2Fwith%2Fslashes',
jasmine.any(Object)
);
});
it('should handle error response', (done) => {
it('should handle error response', () => {
httpClientSpy.get.and.returnValue(throwError(() => new Error('Not found')));
let actualErrorMessage = '';
service.getStatement('stmt-123').subscribe({
error: (err) => {
expect(err.message).toContain('VEX Hub error');
done();
actualErrorMessage = err.message;
},
});
expect(actualErrorMessage).toContain('VEX Hub error');
});
});
@@ -240,6 +363,7 @@ describe('VexHubApiHttpClient', () => {
productRef: 'docker.io/acme/web:1.0',
status: 'affected',
justification: 'Component is vulnerable',
justificationType: 'component_not_present',
};
const createResponse: VexStatementCreateResponse = {
@@ -262,15 +386,17 @@ describe('VexHubApiHttpClient', () => {
);
});
it('should handle error response', (done) => {
it('should handle error response', () => {
httpClientSpy.post.and.returnValue(throwError(() => new Error('Validation failed')));
let actualErrorMessage = '';
service.createStatement(createRequest).subscribe({
error: (err) => {
expect(err.message).toContain('VEX Hub error');
done();
actualErrorMessage = err.message;
},
});
expect(actualErrorMessage).toContain('VEX Hub error');
});
});
@@ -290,52 +416,88 @@ describe('VexHubApiHttpClient', () => {
});
describe('getConsensus', () => {
it('should call GET /consensus/:cveId', () => {
httpClientSpy.get.and.returnValue(of(mockConsensus));
it('should search statements and call POST /vexlens/consensus when productRef is omitted', async () => {
httpClientSpy.get.and.returnValue(of(mockSearchDto));
httpClientSpy.post.and.returnValue(of(mockConsensusDto));
service.getConsensus('CVE-2024-12345').subscribe((result) => {
expect(result).toEqual(mockConsensus);
});
const result = await firstValueFrom(service.getConsensus('CVE-2024-12345'));
expect(result.cveId).toBe(mockConsensus.cveId);
expect(result.productRef).toBe(mockConsensus.productRef);
expect(result.consensusStatus).toBe(mockConsensus.consensusStatus);
expect(result.hasConflict).toBeFalse();
expect(result.votes).toHaveLength(1);
expect(result.votes[0].issuerName).toBe('Vendor Acme');
expect(httpClientSpy.get).toHaveBeenCalledWith(
'/api/v1/vex/consensus/CVE-2024-12345',
'/api/v1/vex/search',
jasmine.objectContaining({
headers: jasmine.any(HttpHeaders),
params: jasmine.any(HttpParams),
})
);
expect(httpClientSpy.post).toHaveBeenCalledWith(
'/api/v1/vexlens/consensus',
{
vulnerabilityId: 'CVE-2024-12345',
productKey: 'docker.io/acme/web:1.0',
storeResult: false,
emitEvent: false,
},
jasmine.objectContaining({ headers: jasmine.any(HttpHeaders) })
);
});
it('should include productRef when provided', () => {
httpClientSpy.get.and.returnValue(of(mockConsensus));
it('should skip statement search when productRef is provided', async () => {
httpClientSpy.post.and.returnValue(of(mockConsensusDto));
service.getConsensus('CVE-2024-12345', 'docker.io/acme/web:1.0').subscribe();
const result = await firstValueFrom(service.getConsensus('CVE-2024-12345', 'docker.io/acme/web:1.0'));
const callArgs = httpClientSpy.get.calls.mostRecent().args;
const httpParams = callArgs[1]!.params as HttpParams;
expect(httpParams.get('productRef')).toBe('docker.io/acme/web:1.0');
expect(result.productRef).toBe('docker.io/acme/web:1.0');
expect(httpClientSpy.get).not.toHaveBeenCalled();
expect(httpClientSpy.post).toHaveBeenCalledWith(
'/api/v1/vexlens/consensus',
jasmine.objectContaining({
vulnerabilityId: 'CVE-2024-12345',
productKey: 'docker.io/acme/web:1.0',
}),
jasmine.any(Object)
);
});
});
describe('getConsensusResult', () => {
const mockResult: VexConsensusResult = {
consensusStatus: 'agreed',
totalIssuers: 3,
agreeing: 2,
conflicting: 1,
totalIssuers: 1,
agreeing: 1,
conflicting: 0,
issuers: [
{ issuerId: 'vendor-acme', issuerName: 'Vendor Acme', agrees: true },
],
};
it('should call GET /statements/:id/consensus', () => {
httpClientSpy.get.and.returnValue(of(mockResult));
it('should derive consensus result from the real statement and VexLens consensus', async () => {
httpClientSpy.get.and.returnValue(of(mockStatementDto));
httpClientSpy.post.and.returnValue(of(mockConsensusDto));
service.getConsensusResult('stmt-123').subscribe((result) => {
expect(result).toEqual(mockResult);
});
const result = await firstValueFrom(service.getConsensusResult('stmt-123'));
expect(result).toEqual(mockResult);
expect(httpClientSpy.get).toHaveBeenCalledWith(
'/api/v1/vex/statements/stmt-123/consensus',
'/api/v1/vex/statement/stmt-123',
jasmine.objectContaining({ headers: jasmine.any(HttpHeaders) })
);
expect(httpClientSpy.post).toHaveBeenCalledWith(
'/api/v1/vexlens/consensus',
jasmine.objectContaining({
vulnerabilityId: 'CVE-2024-12345',
productKey: 'docker.io/acme/web:1.0',
}),
jasmine.any(Object)
);
});
});
@@ -344,22 +506,27 @@ describe('VexHubApiHttpClient', () => {
{
primaryStatus: 'affected',
conflictingStatus: 'not_affected',
primaryIssuers: ['Vendor A'],
conflictingIssuers: ['Researcher B'],
primaryIssuers: ['Vendor Acme'],
conflictingIssuers: ['Research Lab'],
resolutionSuggestion: 'Review evidence',
},
];
it('should call GET /conflicts/:cveId', () => {
httpClientSpy.get.and.returnValue(of(mockConflicts));
it('should derive conflicts from VexLens consensus responses', async () => {
httpClientSpy.get.and.returnValue(of(mockConflictSearchDto));
httpClientSpy.post.and.returnValue(of(mockConflictConsensusDto));
service.getConflicts('CVE-2024-12345').subscribe((result) => {
expect(result).toEqual(mockConflicts);
});
const result = await firstValueFrom(service.getConflicts('CVE-2024-12345'));
expect(httpClientSpy.get).toHaveBeenCalledWith(
'/api/v1/vex/conflicts/CVE-2024-12345',
jasmine.objectContaining({ headers: jasmine.any(HttpHeaders) })
expect(result).toEqual(mockConflicts);
expect(httpClientSpy.post).toHaveBeenCalledWith(
'/api/v1/vexlens/consensus',
jasmine.objectContaining({
vulnerabilityId: 'CVE-2024-12345',
productKey: 'docker.io/acme/web:1.0',
}),
jasmine.any(Object)
);
});
});
@@ -367,30 +534,29 @@ describe('VexHubApiHttpClient', () => {
describe('getConflictStatements', () => {
const mockConflict: VexConflict = {
cveId: 'CVE-2024-12345',
statements: [mockStatement],
statements: [mockStatement, mockConflictStatement],
detectedAt: '2024-01-15T10:00:00Z',
};
it('should call GET /conflicts/:cveId/statements', () => {
httpClientSpy.get.and.returnValue(of(mockConflict));
it('should return statements participating in the first live conflict', async () => {
httpClientSpy.get.and.returnValues(of(mockConflictSearchDto), of(mockConflictSearchDto));
httpClientSpy.post.and.returnValue(of(mockConflictConsensusDto));
service.getConflictStatements('CVE-2024-12345').subscribe((result) => {
expect(result).toEqual(mockConflict);
});
const result = await firstValueFrom(service.getConflictStatements('CVE-2024-12345'));
expect(httpClientSpy.get).toHaveBeenCalledWith(
'/api/v1/vex/conflicts/CVE-2024-12345/statements',
jasmine.objectContaining({ headers: jasmine.any(HttpHeaders) })
);
expect(result).toEqual(mockConflict);
expect(httpClientSpy.get.calls.count()).toBe(2);
expect(httpClientSpy.post).toHaveBeenCalledTimes(1);
});
});
describe('resolveConflict', () => {
const resolveRequest: VexResolveConflictRequest = {
cveId: 'CVE-2024-12345',
resolution: 'prefer',
preferredStatementId: 'stmt-123',
reason: 'Vendor has higher trust',
selectedStatementId: 'stmt-123',
resolutionType: 'prefer',
notes: 'Vendor has higher trust',
};
it('should call POST /conflicts/resolve', () => {
@@ -409,58 +575,96 @@ describe('VexHubApiHttpClient', () => {
describe('VexLens operations', () => {
const mockLensConsensus: VexLensConsensus = {
cveId: 'CVE-2024-12345',
productRef: 'docker.io/acme/web:1.0',
consensusStatus: 'affected',
confidence: 0.87,
totalVoters: 4,
votes: [],
totalVoters: 1,
votes: [
{
issuerId: 'vendor-acme',
issuerName: 'Vendor Acme',
issuerType: 'vendor',
trustLevel: 0.87,
status: 'affected',
weight: 0.87,
statementId: 'stmt-123',
publishedAt: '2024-01-15T10:00:00Z',
},
],
hasConflict: false,
calculatedAt: '2024-01-15T10:00:00Z',
};
const mockLensConflicts: VexLensConflict[] = [
{
cveId: 'CVE-2024-12345',
conflictId: 'conflict-001',
productRef: 'docker.io/acme/web:1.0',
conflictId: 'CVE-2024-12345:docker.io/acme/web:1.0:1',
severity: 'medium',
primaryClaim: {
issuerId: 'vendor-1',
issuerName: 'Vendor',
issuerId: 'vendor-acme',
issuerName: 'Vendor Acme',
issuerType: 'vendor',
status: 'affected',
statementId: 'stmt-1',
statementId: 'stmt-123',
trustScore: 0.95,
},
conflictingClaims: [],
conflictingClaims: [
{
issuerId: 'research-lab',
issuerName: 'Research Lab',
issuerType: 'researcher',
status: 'not_affected',
statementId: 'stmt-456',
trustScore: 0.42,
},
],
resolutionSuggestion: 'Review evidence',
resolutionStatus: 'unresolved',
detectedAt: '2024-01-15T10:00:00Z',
},
];
it('should call GET /vexlens/consensus/:cveId', () => {
httpClientSpy.get.and.returnValue(of(mockLensConsensus));
it('should call POST /vexlens/consensus', async () => {
httpClientSpy.get.and.returnValue(of(mockSearchDto));
httpClientSpy.post.and.returnValue(of({
...mockConsensusDto,
confidenceScore: 0.87,
}));
service.getVexLensConsensus('CVE-2024-12345').subscribe((result) => {
expect(result).toEqual(mockLensConsensus);
});
const result = await firstValueFrom(service.getVexLensConsensus('CVE-2024-12345'));
expect(httpClientSpy.get).toHaveBeenCalledWith(
'/api/v1/vexlens/consensus/CVE-2024-12345',
expect(result.cveId).toBe(mockLensConsensus.cveId);
expect(result.productRef).toBe(mockLensConsensus.productRef);
expect(result.consensusStatus).toBe(mockLensConsensus.consensusStatus);
expect(result.totalVoters).toBe(1);
expect(result.votes[0].issuerName).toBe('Vendor Acme');
expect(httpClientSpy.post).toHaveBeenCalledWith(
'/api/v1/vexlens/consensus',
jasmine.objectContaining({
headers: jasmine.any(HttpHeaders),
params: jasmine.any(HttpParams),
})
vulnerabilityId: 'CVE-2024-12345',
productKey: 'docker.io/acme/web:1.0',
}),
jasmine.any(Object)
);
});
it('should call GET /vexlens/conflicts/:cveId', () => {
httpClientSpy.get.and.returnValue(of(mockLensConflicts));
it('should derive conflicts from POST /vexlens/consensus responses', async () => {
httpClientSpy.get.and.returnValue(of(mockConflictSearchDto));
httpClientSpy.post.and.returnValue(of(mockConflictConsensusDto));
service.getVexLensConflicts('CVE-2024-12345').subscribe((result) => {
expect(result).toEqual(mockLensConflicts);
});
const result = await firstValueFrom(service.getVexLensConflicts('CVE-2024-12345'));
expect(httpClientSpy.get).toHaveBeenCalledWith(
'/api/v1/vexlens/conflicts/CVE-2024-12345',
jasmine.objectContaining({ headers: jasmine.any(HttpHeaders) })
expect(result).toEqual(mockLensConflicts);
expect(httpClientSpy.post).toHaveBeenCalledWith(
'/api/v1/vexlens/consensus',
jasmine.objectContaining({
vulnerabilityId: 'CVE-2024-12345',
productKey: 'docker.io/acme/web:1.0',
}),
jasmine.any(Object)
);
});
});
@@ -472,7 +676,7 @@ describe('VexHubApiHttpClient', () => {
service.getStats().subscribe();
const callArgs = httpClientSpy.get.calls.mostRecent().args;
const callArgs = httpClientSpy.get.calls.mostRecent()!.args;
const headers = callArgs[1]!.headers as HttpHeaders;
expect(headers.get('X-Stella-Ops-Tenant')).toBe('tenant-abc');
});
@@ -482,7 +686,7 @@ describe('VexHubApiHttpClient', () => {
service.getStats().subscribe();
const callArgs = httpClientSpy.get.calls.mostRecent().args;
const callArgs = httpClientSpy.get.calls.mostRecent()!.args;
const headers = callArgs[1]!.headers as HttpHeaders;
expect(headers.get('X-Stella-Ops-Trace-Id')).toBeTruthy();
});
@@ -492,7 +696,7 @@ describe('VexHubApiHttpClient', () => {
service.getStats().subscribe();
const callArgs = httpClientSpy.get.calls.mostRecent().args;
const callArgs = httpClientSpy.get.calls.mostRecent()!.args;
const headers = callArgs[1]!.headers as HttpHeaders;
expect(headers.get('Accept')).toBe('application/json');
});
@@ -503,187 +707,37 @@ describe('VexHubApiHttpClient', () => {
service.getStats().subscribe();
const callArgs = httpClientSpy.get.calls.mostRecent().args;
const callArgs = httpClientSpy.get.calls.mostRecent()!.args;
const headers = callArgs[1]!.headers as HttpHeaders;
expect(headers.get('X-Stella-Ops-Tenant')).toBe('');
});
});
describe('Error mapping', () => {
it('should include traceId in error message', (done) => {
it('should include traceId in error message', () => {
httpClientSpy.get.and.returnValue(throwError(() => new Error('Network error')));
let actualErrorMessage = '';
service.getStats({ traceId: 'trace-xyz' }).subscribe({
error: (err) => {
expect(err.message).toContain('[trace-xyz]');
done();
actualErrorMessage = err.message;
},
});
expect(actualErrorMessage).toContain('[trace-xyz]');
});
it('should handle non-Error objects', (done) => {
it('should handle non-Error objects', () => {
httpClientSpy.get.and.returnValue(throwError(() => 'String error'));
let actualErrorMessage = '';
service.getStats({ traceId: 'trace-abc' }).subscribe({
error: (err) => {
expect(err.message).toContain('Unknown error');
done();
},
});
});
});
});
describe('MockVexHubClient', () => {
let mockClient: MockVexHubClient;
beforeEach(() => {
mockClient = new MockVexHubClient();
});
it('should be created', () => {
expect(mockClient).toBeTruthy();
});
describe('searchStatements', () => {
it('should return mock statements', (done) => {
mockClient.searchStatements({}).subscribe((result) => {
expect(result.items.length).toBeGreaterThan(0);
expect(result.total).toBeGreaterThan(0);
done();
});
});
it('should filter by cveId', (done) => {
mockClient.searchStatements({ cveId: 'CVE-2024-12345' }).subscribe((result) => {
expect(result.items.every((s) => s.cveId.includes('CVE-2024-12345'))).toBeTrue();
done();
});
});
it('should filter by status', (done) => {
mockClient.searchStatements({ status: 'affected' }).subscribe((result) => {
expect(result.items.every((s) => s.status === 'affected')).toBeTrue();
done();
});
});
it('should apply pagination', (done) => {
mockClient.searchStatements({ offset: 0, limit: 1 }).subscribe((result) => {
expect(result.items.length).toBeLessThanOrEqual(1);
done();
});
});
});
describe('getStatement', () => {
it('should return statement by ID', (done) => {
mockClient.getStatement('vex-001').subscribe((result) => {
expect(result.id).toBe('vex-001');
done();
});
});
it('should throw error for unknown ID', (done) => {
mockClient.getStatement('unknown-id').subscribe({
error: (err) => {
expect(err.message).toContain('not found');
done();
},
});
});
});
describe('createStatement', () => {
it('should return created statement', (done) => {
const request: VexStatementCreateRequest = {
cveId: 'CVE-2024-99999',
productRef: 'test/product:1.0',
status: 'affected',
};
mockClient.createStatement(request).subscribe((result) => {
expect(result.statement.cveId).toBe('CVE-2024-99999');
expect(result.documentId).toBeTruthy();
done();
});
});
});
describe('getStats', () => {
it('should return statistics', (done) => {
mockClient.getStats().subscribe((result) => {
expect(result.totalStatements).toBeGreaterThan(0);
expect(result.byStatus).toBeDefined();
expect(result.bySource).toBeDefined();
done();
});
});
});
describe('getConsensus', () => {
it('should return consensus data', (done) => {
mockClient.getConsensus('CVE-2024-12345').subscribe((result) => {
expect(result.cveId).toBe('CVE-2024-12345');
expect(result.consensusStatus).toBeTruthy();
expect(result.votes.length).toBeGreaterThan(0);
done();
});
});
it('should include productRef if provided', (done) => {
mockClient.getConsensus('CVE-2024-12345', 'custom/product:1.0').subscribe((result) => {
expect(result.productRef).toBe('custom/product:1.0');
done();
});
});
});
describe('getConflicts', () => {
it('should return conflict details', (done) => {
mockClient.getConflicts('CVE-2024-12345').subscribe((result) => {
expect(result.length).toBeGreaterThan(0);
expect(result[0].primaryStatus).toBeTruthy();
done();
});
});
});
describe('getVexLensConsensus', () => {
it('should return VexLens consensus', (done) => {
mockClient.getVexLensConsensus('CVE-2024-12345').subscribe((result) => {
expect(result.cveId).toBe('CVE-2024-12345');
expect(result.totalVoters).toBeGreaterThan(0);
expect(result.votes.length).toBeGreaterThan(0);
done();
});
});
});
describe('getVexLensConflicts', () => {
it('should return VexLens conflicts', (done) => {
mockClient.getVexLensConflicts('CVE-2024-12345').subscribe((result) => {
expect(result.length).toBeGreaterThan(0);
expect(result[0].conflictId).toBeTruthy();
done();
});
});
});
describe('resolveConflict', () => {
it('should resolve without error', (done) => {
const request: VexResolveConflictRequest = {
cveId: 'CVE-2024-12345',
resolution: 'prefer',
preferredStatementId: 'vex-001',
reason: 'Test resolution',
};
mockClient.resolveConflict(request).subscribe({
complete: () => {
done();
actualErrorMessage = err.message;
},
});
expect(actualErrorMessage).toContain('Unknown error');
});
});
});

View File

@@ -5,7 +5,7 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, InjectionToken, inject } from '@angular/core';
import { Observable, of, delay, throwError } from 'rxjs';
import { Observable, of, delay, throwError, from, firstValueFrom } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
@@ -63,6 +63,62 @@ export const VEX_LENS_API_BASE_URL = new InjectionToken<string>('VEX_LENS_API_BA
const normalizeBaseUrl = (baseUrl: string): string =>
baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
interface VexHubStatementDto {
id?: string;
sourceStatementId?: string;
sourceId?: string;
sourceDocumentId?: string;
vulnerabilityId?: string;
productKey?: string;
status?: string;
justification?: string | null;
statusNotes?: string | null;
impactStatement?: string | null;
actionStatement?: string | null;
issuedAt?: string | null;
sourceUpdatedAt?: string | null;
ingestedAt?: string;
updatedAt?: string | null;
verificationStatus?: string | null;
isFlagged?: boolean;
}
interface VexHubSearchDto {
statements?: VexHubStatementDto[];
totalCount?: number;
offset?: number;
limit?: number;
}
interface VexLensContributionDto {
statementId?: string;
issuerId?: string | null;
status?: string;
justification?: string | null;
weight?: number;
contribution?: number;
isWinner?: boolean;
}
interface VexLensConflictDto {
statement1Id?: string;
statement2Id?: string;
status1?: string;
status2?: string;
severity?: string;
resolution?: string;
}
interface VexLensConsensusDto {
vulnerabilityId?: string;
productKey?: string;
status?: string;
confidenceScore?: number;
contributions?: VexLensContributionDto[];
conflicts?: VexLensConflictDto[] | null;
computedAt?: string;
}
@Injectable({ providedIn: 'root' })
export class VexHubApiHttpClient implements VexHubApi {
private readonly http = inject(HttpClient);
@@ -84,7 +140,13 @@ export class VexHubApiHttpClient implements VexHubApi {
if (params.limit) httpParams = httpParams.set('limit', params.limit.toString());
if (params.offset) httpParams = httpParams.set('offset', params.offset.toString());
return this.http.get<VexStatementSearchResponse>(`${this.baseUrl}/search`, { headers, params: httpParams }).pipe(
return this.http.get<VexHubSearchDto>(`${this.baseUrl}/search`, { headers, params: httpParams }).pipe(
map((response) => ({
items: (response?.statements ?? []).map((statement) => this.mapStatement(statement)),
total: response?.totalCount ?? 0,
offset: response?.offset ?? params.offset ?? 0,
limit: response?.limit ?? params.limit ?? 20,
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
@@ -93,7 +155,8 @@ export class VexHubApiHttpClient implements VexHubApi {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<VexStatement>(`${this.baseUrl}/statement/${encodeURIComponent(statementId)}`, { headers }).pipe(
return this.http.get<VexHubStatementDto>(`${this.baseUrl}/statement/${encodeURIComponent(statementId)}`, { headers }).pipe(
map((statement) => this.mapStatement(statement)),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
@@ -139,38 +202,28 @@ export class VexHubApiHttpClient implements VexHubApi {
getConsensus(cveId: string, productRef?: string, options: VexQueryOptions = {}): Observable<VexConsensus> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
let httpParams = new HttpParams();
if (productRef) httpParams = httpParams.set('productRef', productRef);
return this.http.get<VexConsensus>(`${this.baseUrl}/consensus/${encodeURIComponent(cveId)}`, { headers, params: httpParams }).pipe(
return from(this.fetchConsensusAsync(cveId, productRef, traceId)).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getConsensusResult(statementId: string, options: VexQueryOptions = {}): Observable<VexConsensusResult> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<VexConsensusResult>(`${this.baseUrl}/statements/${encodeURIComponent(statementId)}/consensus`, { headers }).pipe(
return from(this.fetchConsensusResultAsync(statementId, traceId)).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getConflicts(cveId: string, options: VexQueryOptions = {}): Observable<VexConflictDetail[]> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<VexConflictDetail[]>(`${this.baseUrl}/conflicts/${encodeURIComponent(cveId)}`, { headers }).pipe(
return from(this.fetchConflictDetailsAsync(cveId, traceId)).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getConflictStatements(cveId: string, options: VexQueryOptions = {}): Observable<VexConflict> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<VexConflict>(`${this.baseUrl}/conflicts/${encodeURIComponent(cveId)}/statements`, { headers }).pipe(
return from(this.fetchConflictStatementsAsync(cveId, traceId)).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
@@ -186,20 +239,14 @@ export class VexHubApiHttpClient implements VexHubApi {
getVexLensConsensus(cveId: string, productRef?: string, options: VexQueryOptions = {}): Observable<VexLensConsensus> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
let httpParams = new HttpParams();
if (productRef) httpParams = httpParams.set('productRef', productRef);
return this.http.get<VexLensConsensus>(`${this.vexLensBaseUrl}/consensus/${encodeURIComponent(cveId)}`, { headers, params: httpParams }).pipe(
return from(this.fetchVexLensConsensusAsync(cveId, productRef, traceId)).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getVexLensConflicts(cveId: string, options: VexQueryOptions = {}): Observable<VexLensConflict[]> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<VexLensConflict[]>(`${this.vexLensBaseUrl}/conflicts/${encodeURIComponent(cveId)}`, { headers }).pipe(
return from(this.fetchVexLensConflictsAsync(cveId, traceId)).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
@@ -214,7 +261,406 @@ export class VexHubApiHttpClient implements VexHubApi {
});
}
private async fetchConsensusAsync(cveId: string, productRef: string | undefined, traceId: string): Promise<VexConsensus> {
const { response, resolvedProductRef } = await this.computeVexLensConsensusResponseAsync(cveId, productRef, traceId);
const consensus = this.mapVexLensConsensus(response, cveId, resolvedProductRef);
const conflictDetails = this.mapConflictDetails(this.mapVexLensConflicts(response, cveId, resolvedProductRef));
return {
cveId: consensus.cveId,
productRef: consensus.productRef ?? '',
consensusStatus: consensus.consensusStatus,
confidence: consensus.confidence,
votes: consensus.votes.map((vote) => ({
issuerId: vote.issuerId,
issuerName: vote.issuerName,
issuerType: vote.issuerType,
status: vote.status,
weight: vote.weight,
statementId: vote.statementId,
publishedAt: vote.publishedAt,
})),
hasConflict: consensus.hasConflict,
conflictDetails,
calculatedAt: consensus.calculatedAt,
};
}
private async fetchConsensusResultAsync(statementId: string, traceId: string): Promise<VexConsensusResult> {
const statement = await firstValueFrom(this.getStatement(statementId, { traceId }));
const consensus = await this.fetchVexLensConsensusAsync(statement.cveId, statement.productRef, traceId);
const agreeing = consensus.votes.filter((vote) => vote.status === consensus.consensusStatus).length;
const conflicting = Math.max(consensus.votes.length - agreeing, 0);
return {
consensusStatus: conflicting > 0 ? 'disputed' : agreeing > 0 ? 'agreed' : 'pending',
totalIssuers: consensus.totalVoters,
agreeing,
conflicting,
issuers: consensus.votes.map((vote) => ({
issuerId: vote.issuerId,
issuerName: vote.issuerName,
agrees: vote.status === consensus.consensusStatus,
})),
};
}
private async fetchConflictDetailsAsync(cveId: string, traceId: string): Promise<VexConflictDetail[]> {
const conflicts = await this.fetchVexLensConflictsAsync(cveId, traceId);
return this.mapConflictDetails(conflicts);
}
private async fetchConflictStatementsAsync(cveId: string, traceId: string): Promise<VexConflict> {
const conflicts = await this.fetchVexLensConflictsAsync(cveId, traceId);
if (conflicts.length === 0) {
throw new Error(`No active conflicts found for ${cveId}.`);
}
const statements = await this.listStatementsForVulnerabilityAsync(cveId, traceId);
const targetProduct = conflicts[0].productRef;
const scopedStatements = targetProduct
? statements.filter((statement) => statement.productRef === targetProduct)
: statements;
if (scopedStatements.length === 0) {
throw new Error(`Unable to load conflicting statements for ${cveId}.`);
}
return {
cveId,
statements: scopedStatements,
detectedAt: conflicts[0].detectedAt,
};
}
private async fetchVexLensConsensusAsync(
cveId: string,
productRef: string | undefined,
traceId: string
): Promise<VexLensConsensus> {
const { response, resolvedProductRef } = await this.computeVexLensConsensusResponseAsync(cveId, productRef, traceId);
return this.mapVexLensConsensus(response, cveId, resolvedProductRef);
}
private async fetchVexLensConflictsAsync(cveId: string, traceId: string): Promise<VexLensConflict[]> {
const products = await this.listProductsForVulnerabilityAsync(cveId, traceId);
const conflicts: VexLensConflict[] = [];
for (const productRef of products) {
const { response } = await this.computeVexLensConsensusResponseAsync(cveId, productRef, traceId);
conflicts.push(...this.mapVexLensConflicts(response, cveId, productRef));
}
return conflicts;
}
private async resolveProductRefAsync(cveId: string, productRef: string | undefined, traceId: string): Promise<string> {
if (productRef?.trim()) {
return productRef.trim();
}
const products = await this.listProductsForVulnerabilityAsync(cveId, traceId);
if (products.length === 0) {
throw new Error(`No VEX statements were found for ${cveId}.`);
}
if (products.length > 1) {
throw new Error(`Multiple products were found for ${cveId}. Select a product to compute real consensus.`);
}
return products[0];
}
private async computeVexLensConsensusResponseAsync(
cveId: string,
productRef: string | undefined,
traceId: string
): Promise<{ response: VexLensConsensusDto; resolvedProductRef: string }> {
const headers = this.buildHeaders(traceId);
const resolvedProductRef = await this.resolveProductRefAsync(cveId, productRef, traceId);
const response = await firstValueFrom(
this.http.post<VexLensConsensusDto>(
`${this.vexLensBaseUrl}/consensus`,
{
vulnerabilityId: cveId,
productKey: resolvedProductRef,
storeResult: false,
emitEvent: false,
},
{ headers }
)
);
return { response, resolvedProductRef };
}
private async listProductsForVulnerabilityAsync(cveId: string, traceId: string): Promise<string[]> {
const statements = await this.listStatementsForVulnerabilityAsync(cveId, traceId);
return [...new Set(statements.map((statement) => statement.productRef).filter((productRef) => !!productRef))].sort((left, right) =>
left.localeCompare(right)
);
}
private async listStatementsForVulnerabilityAsync(cveId: string, traceId: string): Promise<VexStatement[]> {
const response = await firstValueFrom(this.searchStatements({ cveId, limit: 500, offset: 0 }, { traceId }));
return response.items;
}
private mapStatement(statement: VexHubStatementDto): VexStatement {
const sourceId = statement.sourceId ?? 'unknown-source';
const publishedAt = statement.sourceUpdatedAt
?? statement.issuedAt
?? statement.updatedAt
?? statement.ingestedAt
?? new Date().toISOString();
return {
id: statement.id ?? '',
statementId: statement.id ?? statement.sourceStatementId ?? '',
cveId: statement.vulnerabilityId ?? '',
productRef: statement.productKey ?? '',
status: this.normalizeStatus(statement.status),
justification: statement.statusNotes ?? statement.impactStatement ?? statement.actionStatement ?? undefined,
justificationType: this.normalizeJustification(statement.justification),
actionStatement: statement.actionStatement ?? undefined,
sourceType: this.inferIssuerType(sourceId),
sourceName: this.formatIssuerName(sourceId),
issuerName: this.formatIssuerName(sourceId),
issuerType: this.inferIssuerType(sourceId),
issuerTrustLevel: this.mapTrustLevel(statement.verificationStatus),
documentId: statement.sourceDocumentId ?? statement.sourceStatementId ?? statement.id ?? '',
publishedAt,
createdAt: publishedAt,
updatedAt: statement.updatedAt ?? undefined,
version: statement.isFlagged ? 2 : 1,
};
}
private mapVexLensConsensus(response: VexLensConsensusDto, cveId: string, productRef: string): VexLensConsensus {
const votes = (response?.contributions ?? []).map((contribution) => this.mapVexLensVote(contribution));
const conflicts = this.mapVexLensConflicts(response, cveId, productRef);
return {
cveId,
productRef,
consensusStatus: this.normalizeStatus(response?.status),
confidence: this.clampFraction(response?.confidenceScore),
totalVoters: votes.length,
votes,
hasConflict: conflicts.length > 0,
conflictSeverity: conflicts.length > 0 ? this.pickHighestSeverity(conflicts) : undefined,
resolutionHints: [...new Set(conflicts.map((conflict) => conflict.resolutionSuggestion).filter((hint): hint is string => !!hint))],
calculatedAt: response?.computedAt ?? new Date().toISOString(),
};
}
private mapVexLensVote(contribution: VexLensContributionDto): VexLensConsensus['votes'][number] {
const issuerId = contribution.issuerId?.trim() || contribution.statementId || 'unknown-issuer';
const weight = this.clampFraction(contribution.weight);
return {
issuerId,
issuerName: this.formatIssuerName(issuerId),
issuerType: this.inferIssuerType(issuerId),
trustLevel: weight,
status: this.normalizeStatus(contribution.status),
weight,
justificationType: this.normalizeJustification(contribution.justification),
statementId: contribution.statementId ?? issuerId,
publishedAt: new Date().toISOString(),
};
}
private mapVexLensConflicts(response: VexLensConsensusDto, cveId: string, productRef: string): VexLensConflict[] {
const contributions = new Map(
(response?.contributions ?? [])
.filter((contribution): contribution is VexLensContributionDto & { statementId: string } => !!contribution.statementId)
.map((contribution) => [contribution.statementId, this.mapVexLensVote(contribution)])
);
return (response?.conflicts ?? []).map((conflict, index) => {
const first = this.mapConflictClaim(
conflict.statement1Id,
conflict.status1,
contributions.get(conflict.statement1Id ?? '')
);
const second = this.mapConflictClaim(
conflict.statement2Id,
conflict.status2,
contributions.get(conflict.statement2Id ?? '')
);
const claims = [first, second].sort((left, right) => {
const trustDelta = right.trustScore - left.trustScore;
if (trustDelta !== 0) {
return trustDelta;
}
return left.issuerName.localeCompare(right.issuerName);
});
return {
cveId,
productRef,
conflictId: `${cveId}:${productRef}:${index + 1}`,
severity: this.normalizeConflictSeverity(conflict.severity),
primaryClaim: claims[0],
conflictingClaims: claims.slice(1),
resolutionSuggestion: conflict.resolution ?? undefined,
resolutionStatus: 'unresolved',
detectedAt: response?.computedAt ?? new Date().toISOString(),
};
});
}
private mapConflictClaim(
statementId: string | undefined,
status: string | undefined,
vote?: VexLensConsensus['votes'][number]
): VexLensConflict['primaryClaim'] {
if (vote) {
return {
issuerId: vote.issuerId,
issuerName: vote.issuerName,
issuerType: vote.issuerType,
status: vote.status,
justificationType: vote.justificationType,
statementId: vote.statementId,
trustScore: vote.weight,
};
}
const fallbackId = statementId ?? 'unknown-statement';
return {
issuerId: fallbackId,
issuerName: this.formatIssuerName(fallbackId),
issuerType: this.inferIssuerType(fallbackId),
status: this.normalizeStatus(status),
statementId: fallbackId,
trustScore: 0,
};
}
private mapConflictDetails(conflicts: VexLensConflict[]): VexConflictDetail[] {
return conflicts.map((conflict) => ({
primaryStatus: conflict.primaryClaim.status,
conflictingStatus: conflict.conflictingClaims[0]?.status ?? conflict.primaryClaim.status,
primaryIssuers: [conflict.primaryClaim.issuerName],
conflictingIssuers: conflict.conflictingClaims.map((claim) => claim.issuerName),
resolutionSuggestion: conflict.resolutionSuggestion,
}));
}
private normalizeStatus(value: string | undefined | null): VexStatementStatus {
switch ((value ?? '').toLowerCase()) {
case 'notaffected':
case 'not_affected':
return 'not_affected';
case 'fixed':
return 'fixed';
case 'underinvestigation':
case 'under_investigation':
return 'under_investigation';
default:
return 'affected';
}
}
private normalizeJustification(value: string | undefined | null): VexStatement['justificationType'] {
switch ((value ?? '').toLowerCase()) {
case 'component_not_present':
case 'vulnerable_code_not_present':
case 'vulnerable_code_not_in_execute_path':
case 'vulnerable_code_cannot_be_controlled_by_adversary':
case 'inline_mitigations_already_exist':
return value as VexStatement['justificationType'];
default:
return undefined;
}
}
private normalizeConflictSeverity(value: string | undefined | null): 'low' | 'medium' | 'high' {
switch ((value ?? '').toLowerCase()) {
case 'low':
return 'low';
case 'critical':
case 'high':
return 'high';
default:
return 'medium';
}
}
private pickHighestSeverity(conflicts: VexLensConflict[]): 'low' | 'medium' | 'high' {
if (conflicts.some((conflict) => conflict.severity === 'high')) {
return 'high';
}
if (conflicts.some((conflict) => conflict.severity === 'medium')) {
return 'medium';
}
return 'low';
}
private inferIssuerType(sourceId: string): VexIssuerType {
const normalized = sourceId.toLowerCase();
if (normalized.includes('cert') || normalized.includes('cisa') || normalized.includes('csirt')) {
return 'cert';
}
if (normalized.includes('community') || normalized.includes('openvex') || normalized.includes('oss')) {
return 'oss';
}
if (normalized.includes('research')) {
return 'researcher';
}
return 'vendor';
}
private mapTrustLevel(verificationStatus: string | undefined | null): VexStatement['issuerTrustLevel'] {
switch ((verificationStatus ?? '').toLowerCase()) {
case 'verified':
return 'high';
case 'pending':
case 'none':
return 'medium';
case 'failed':
case 'untrusted':
return 'low';
default:
return undefined;
}
}
private formatIssuerName(sourceId: string): string {
return sourceId
.split(/[-_:/]+/)
.filter((segment) => segment.length > 0)
.map((segment) => segment[0].toUpperCase() + segment.slice(1))
.join(' ');
}
private clampFraction(value: number | undefined | null): number {
if (typeof value !== 'number' || Number.isNaN(value)) {
return 0;
}
if (value < 0) {
return 0;
}
if (value > 1) {
return 1;
}
return value;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error && err.message.startsWith(`[${traceId}]`)) {
return err;
}
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
return new Error(`[${traceId}] VEX Hub error: ${(err as any).status} ${(err as any).statusText ?? (err as any).message}`);
}

View File

@@ -1,6 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { NEVER, of } from 'rxjs';
import { StellaHelperContextService } from '../../shared/components/stella-helper/stella-helper-context.service';
import { IntegrationHubComponent } from './integration-hub.component';
@@ -38,7 +38,7 @@ describe('IntegrationHubComponent', () => {
return of(mockListResponse(8));
case IntegrationType.FeedMirror:
return of(mockListResponse(4));
case IntegrationType.RepoSource:
case IntegrationType.SecretsManager:
return of(mockListResponse(1));
default:
return of(mockListResponse(0));
@@ -84,6 +84,8 @@ describe('IntegrationHubComponent', () => {
fixture.detectChanges();
expect(mockIntegrationService.list).toHaveBeenCalledTimes(6);
const requestedTypes = mockIntegrationService.list.calls.allArgs().map(([params]) => params?.type);
expect(component.stats().registries).toBe(5);
expect(component.stats().scm).toBe(3);
expect(component.stats().ci).toBe(2);
@@ -92,6 +94,8 @@ describe('IntegrationHubComponent', () => {
expect(component.stats().vexSources).toBe(4);
expect(component.stats().secrets).toBe(1);
expect(component.configuredConnectorCount()).toBe(27);
expect(requestedTypes).toContain(IntegrationType.SecretsManager);
expect(requestedTypes).not.toContain(IntegrationType.RepoSource);
});
it('keeps the suggested setup-order links on the canonical integrations routes', async () => {
@@ -133,6 +137,23 @@ describe('IntegrationHubComponent', () => {
expect(text).toContain('0 connectors configured');
});
it('shows loading indicators instead of not-started placeholders before stats resolve', () => {
fixture.destroy();
mockIntegrationService.list.and.returnValue(NEVER);
const loadingFixture = TestBed.createComponent(IntegrationHubComponent);
loadingFixture.detectChanges();
const text = loadingFixture.nativeElement.textContent as string;
expect(text).toContain('loading connector counts');
expect(text).toContain('Loading connectors...');
expect(text).toContain('Loading integration summary');
expect(text).not.toContain('Not started');
loadingFixture.destroy();
});
it('publishes a no-integrations helper context once all stats resolve empty', async () => {
fixture.detectChanges();
await fixture.whenStable();

View File

@@ -38,8 +38,8 @@ interface IntegrationSetupStep {
<div class="hub-header__right">
<app-page-action-outlet />
<div class="hub-summary" aria-live="polite">
<strong>{{ configuredConnectorCount() }}</strong>
<span>configured connectors</span>
<strong>{{ summaryCountLabel() }}</strong>
<span>{{ summaryCountDescription() }}</span>
</div>
</div>
</header>
@@ -102,15 +102,16 @@ interface IntegrationSetupStep {
</div>
<span
class="setup-step-card__status"
[class.setup-step-card__status--done]="step.configuredCount > 0"
[class.setup-step-card__status--pending]="step.configuredCount === 0"
[class.setup-step-card__status--done]="statsLoaded() && step.configuredCount > 0"
[class.setup-step-card__status--pending]="statsLoaded() && step.configuredCount === 0"
[class.setup-step-card__status--loading]="!statsLoaded()"
>
{{ step.configuredCount > 0 ? 'Done' : 'Not started' }}
{{ setupStepStatusLabel(step) }}
</span>
</div>
<p class="setup-step-card__why">{{ step.why }}</p>
<div class="setup-step-card__footer">
<span class="setup-step-card__count">{{ configuredText(step.configuredCount) }}</span>
<span class="setup-step-card__count">{{ setupStepConfiguredText(step) }}</span>
<a [routerLink]="step.route" class="setup-step-card__action">{{ step.actionLabel }}</a>
</div>
</li>
@@ -121,31 +122,31 @@ interface IntegrationSetupStep {
<nav class="tiles">
<a routerLink="registries" class="tile">
<span>Registries</span>
<strong>{{ stats().registries }}</strong>
<strong>{{ tileCountLabel(stats().registries) }}</strong>
</a>
<a routerLink="scm" class="tile">
<span>SCM</span>
<strong>{{ stats().scm }}</strong>
<strong>{{ tileCountLabel(stats().scm) }}</strong>
</a>
<a routerLink="ci" class="tile">
<span>CI/CD</span>
<strong>{{ stats().ci }}</strong>
<strong>{{ tileCountLabel(stats().ci) }}</strong>
</a>
<a routerLink="runtime-hosts" class="tile">
<span>Runtimes / Hosts</span>
<strong>{{ stats().runtimeHosts }}</strong>
<strong>{{ tileCountLabel(stats().runtimeHosts) }}</strong>
</a>
<a routerLink="advisory-vex-sources" class="tile">
<span>Advisory Sources</span>
<strong>{{ stats().advisorySources }}</strong>
<strong>{{ tileCountLabel(stats().advisorySources) }}</strong>
</a>
<a routerLink="advisory-vex-sources" class="tile">
<span>VEX Sources</span>
<strong>{{ stats().vexSources }}</strong>
<strong>{{ tileCountLabel(stats().vexSources) }}</strong>
</a>
<a routerLink="secrets" class="tile">
<span>Secrets</span>
<strong>{{ stats().secrets }}</strong>
<strong>{{ tileCountLabel(stats().secrets) }}</strong>
</a>
</nav>
@@ -155,7 +156,12 @@ interface IntegrationSetupStep {
<section class="activity" aria-live="polite">
<h2>Recent Activity</h2>
@if (configuredConnectorCount() === 0) {
@if (!statsLoaded()) {
<p class="activity__title">Loading integration summary</p>
<p class="activity__text">
Waiting for connector totals from the integrations service before summarizing setup progress.
</p>
} @else if (configuredConnectorCount() === 0) {
<p class="activity__title">No integration activity recorded yet</p>
<p class="activity__text">
Add your first connector, run Test Connection, or open the full activity timeline after the next sync.
@@ -338,6 +344,12 @@ interface IntegrationSetupStep {
border: 1px solid color-mix(in srgb, var(--color-status-warning-border) 90%, transparent);
}
.setup-step-card__status--loading {
background: color-mix(in srgb, var(--color-surface-secondary) 88%, transparent);
color: var(--color-text-secondary);
border: 1px solid color-mix(in srgb, var(--color-border-primary) 92%, transparent);
}
.setup-step-card__why {
margin: 0;
color: var(--color-text-secondary);
@@ -528,10 +540,34 @@ export class IntegrationHubComponent implements OnDestroy {
return Object.values(this.stats()).reduce((sum, value) => sum + value, 0);
}
summaryCountLabel(): string {
return this.statsLoaded() ? `${this.configuredConnectorCount()}` : '...';
}
summaryCountDescription(): string {
return this.statsLoaded() ? 'configured connectors' : 'loading connector counts';
}
configuredText(count: number): string {
return count === 1 ? '1 connector configured' : `${count} connectors configured`;
}
tileCountLabel(count: number): string {
return this.statsLoaded() ? `${count}` : '...';
}
setupStepStatusLabel(step: IntegrationSetupStep): string {
if (!this.statsLoaded()) {
return 'Loading';
}
return step.configuredCount > 0 ? 'Done' : 'Not started';
}
setupStepConfiguredText(step: IntegrationSetupStep): string {
return this.statsLoaded() ? this.configuredText(step.configuredCount) : 'Loading connectors...';
}
constructor() {
this.loadStats();
this.pageAction.set({ label: 'Add Integration', action: () => this.addIntegration() });
@@ -600,7 +636,7 @@ export class IntegrationHubComponent implements OnDestroy {
this.markStatsRequestResolved();
},
});
this.integrationService.list({ type: IntegrationType.RepoSource, pageSize: 1 }).subscribe({
this.integrationService.list({ type: IntegrationType.SecretsManager, pageSize: 1 }).subscribe({
next: (res) => {
this.updateStats({ secrets: res.totalCount });
this.markStatsRequestResolved();

View File

@@ -3,7 +3,7 @@
* Tests VEX-AI-005: Full statement details, evidence links, consensus status.
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SimpleChange } from '@angular/core';
import { of, throwError } from 'rxjs';
@@ -20,6 +20,12 @@ describe('VexStatementDetailPanelComponent', () => {
let component: VexStatementDetailPanelComponent;
let fixture: ComponentFixture<VexStatementDetailPanelComponent>;
let mockVexHubApi: jasmine.SpyObj<VexHubApi>;
const asElements = (selector: string): HTMLElement[] =>
Array.from(fixture.nativeElement.querySelectorAll(selector) as NodeListOf<HTMLElement>);
const flushAsync = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
};
const mockStatement: VexStatement = {
id: 'stmt-123',
@@ -33,10 +39,14 @@ describe('VexStatementDetailPanelComponent', () => {
issuerName: 'ACME Security Team',
issuerType: 'vendor' as VexIssuerType,
issuerTrustLevel: 'high',
sourceName: 'ACME Security Team',
sourceType: 'vendor' as VexIssuerType,
documentId: 'DOC-123',
publishedAt: '2024-01-15T10:30:00Z',
aiGenerated: false,
createdAt: new Date('2024-01-15T10:30:00Z'),
updatedAt: new Date('2024-01-16T14:00:00Z'),
version: '1.1',
createdAt: '2024-01-15T10:30:00Z',
updatedAt: '2024-01-16T14:00:00Z',
version: 1.1,
evidenceLinks: [
{ url: 'https://example.com/sbom', title: 'SBOM Reference', type: 'sbom' },
{ url: 'https://example.com/attestation', title: 'Signed Attestation', type: 'attestation' },
@@ -45,7 +55,6 @@ describe('VexStatementDetailPanelComponent', () => {
};
const mockConsensus: VexConsensusResult = {
cveId: 'CVE-2024-12345',
consensusStatus: 'agreed',
agreeing: 3,
conflicting: 1,
@@ -61,13 +70,13 @@ describe('VexStatementDetailPanelComponent', () => {
beforeEach(async () => {
mockVexHubApi = jasmine.createSpyObj<VexHubApi>('VexHubApi', [
'getStatement',
'getConsensus',
'getConsensusResult',
'searchStatements',
'getStats',
'createStatement',
]);
mockVexHubApi.getStatement.and.returnValue(of(mockStatement));
mockVexHubApi.getConsensus.and.returnValue(of(mockConsensus));
mockVexHubApi.getConsensusResult.and.returnValue(of(mockConsensus));
await TestBed.configureTestingModule({
imports: [VexStatementDetailPanelComponent],
@@ -128,41 +137,41 @@ describe('VexStatementDetailPanelComponent', () => {
});
describe('OnChanges Lifecycle', () => {
it('should load statement when visible changes to true', fakeAsync(() => {
it('should load statement when visible changes to true', async () => {
fixture.componentRef.setInput('statementId', 'stmt-123');
fixture.componentRef.setInput('visible', true);
component.ngOnChanges({
visible: new SimpleChange(false, true, false),
});
tick();
await flushAsync();
expect(mockVexHubApi.getStatement).toHaveBeenCalledWith('stmt-123');
}));
});
it('should not load when visible is false', fakeAsync(() => {
it('should not load when visible is false', async () => {
fixture.componentRef.setInput('statementId', 'stmt-123');
fixture.componentRef.setInput('visible', false);
component.ngOnChanges({
visible: new SimpleChange(true, false, false),
});
tick();
await flushAsync();
expect(mockVexHubApi.getStatement).not.toHaveBeenCalled();
}));
});
it('should not load when statementId is empty', fakeAsync(() => {
it('should not load when statementId is empty', async () => {
fixture.componentRef.setInput('statementId', '');
fixture.componentRef.setInput('visible', true);
component.ngOnChanges({
visible: new SimpleChange(false, true, false),
});
tick();
await flushAsync();
expect(mockVexHubApi.getStatement).not.toHaveBeenCalled();
}));
});
});
describe('Statement Loading', () => {
@@ -171,65 +180,59 @@ describe('VexStatementDetailPanelComponent', () => {
fixture.componentRef.setInput('statementId', 'stmt-123');
});
it('should set loading state during load', fakeAsync(() => {
component.loadStatement();
it('should set loading state during load', async () => {
const loadPromise = component.loadStatement();
expect(component.loading()).toBeTrue();
tick();
await loadPromise;
expect(component.loading()).toBeFalse();
}));
});
it('should load statement and consensus in parallel', fakeAsync(() => {
component.loadStatement();
tick();
it('should load statement and consensus in parallel', async () => {
await component.loadStatement();
expect(mockVexHubApi.getStatement).toHaveBeenCalledWith('stmt-123');
expect(mockVexHubApi.getConsensus).toHaveBeenCalledWith('stmt-123');
}));
expect(mockVexHubApi.getConsensusResult).toHaveBeenCalledWith('stmt-123');
});
it('should set statement after successful load', fakeAsync(() => {
component.loadStatement();
tick();
it('should set statement after successful load', async () => {
await component.loadStatement();
expect(component.statement()).toEqual(mockStatement);
}));
});
it('should set consensus after successful load', fakeAsync(() => {
component.loadStatement();
tick();
it('should set consensus after successful load', async () => {
await component.loadStatement();
expect(component.consensus()).toEqual(mockConsensus);
}));
});
it('should handle consensus load failure gracefully', fakeAsync(() => {
mockVexHubApi.getConsensus.and.returnValue(throwError(() => new Error('Not found')));
it('should handle consensus load failure gracefully', async () => {
mockVexHubApi.getConsensusResult.and.returnValue(throwError(() => new Error('Not found')));
component.loadStatement();
tick();
await component.loadStatement();
expect(component.statement()).toEqual(mockStatement);
expect(component.consensus()).toBeNull();
expect(component.error()).toBeNull();
}));
});
it('should set error on statement load failure', fakeAsync(() => {
it('should set error on statement load failure', async () => {
mockVexHubApi.getStatement.and.returnValue(throwError(() => new Error('Network error')));
component.loadStatement();
tick();
await component.loadStatement();
expect(component.error()).toBe('Network error');
}));
});
it('should not load when statementId is empty', fakeAsync(() => {
it('should not load when statementId is empty', async () => {
fixture.componentRef.setInput('statementId', '');
mockVexHubApi.getStatement.calls.reset();
component.loadStatement();
tick();
await component.loadStatement();
expect(mockVexHubApi.getStatement).not.toHaveBeenCalled();
}));
});
});
describe('Template Rendering - Panel Visibility', () => {
@@ -266,14 +269,14 @@ describe('VexStatementDetailPanelComponent', () => {
expect(loadingText.textContent).toContain('Loading statement');
});
it('should hide loading spinner when not loading', fakeAsync(() => {
it('should hide loading spinner when not loading', () => {
component.loading.set(false);
component.statement.set(mockStatement);
fixture.detectChanges();
const spinner = fixture.nativeElement.querySelector('.loading-spinner');
expect(spinner).toBeFalsy();
}));
});
});
describe('Template Rendering - Statement Content', () => {
@@ -360,10 +363,7 @@ describe('VexStatementDetailPanelComponent', () => {
});
it('should render evidence section', () => {
const evidenceSection = fixture.nativeElement.querySelectorAll('.detail-section h3');
const hasEvidence = Array.from(evidenceSection).some(
(el: Element) => el.textContent?.includes('Evidence')
);
const hasEvidence = asElements('.detail-section h3').some((el) => el.textContent?.includes('Evidence'));
expect(hasEvidence).toBeTrue();
});
@@ -402,10 +402,7 @@ describe('VexStatementDetailPanelComponent', () => {
});
it('should render consensus section', () => {
const sections = fixture.nativeElement.querySelectorAll('.detail-section h3');
const hasConsensus = Array.from(sections).some(
(el: Element) => el.textContent?.includes('Consensus')
);
const hasConsensus = asElements('.detail-section h3').some((el) => el.textContent?.includes('Consensus'));
expect(hasConsensus).toBeTrue();
});
@@ -444,35 +441,23 @@ describe('VexStatementDetailPanelComponent', () => {
});
it('should display statement ID', () => {
const metaRows = fixture.nativeElement.querySelectorAll('.meta-row');
const idRow = Array.from(metaRows).find(
(row: Element) => row.textContent?.includes('Statement ID')
);
const idRow = asElements('.meta-row').find((row) => row.textContent?.includes('Statement ID'));
expect(idRow).toBeTruthy();
expect(idRow!.textContent).toContain('VEXSTMT-2024-001');
});
it('should display created date', () => {
const metaRows = fixture.nativeElement.querySelectorAll('.meta-row');
const createdRow = Array.from(metaRows).find(
(row: Element) => row.textContent?.includes('Created')
);
const createdRow = asElements('.meta-row').find((row) => row.textContent?.includes('Created'));
expect(createdRow).toBeTruthy();
});
it('should display updated date when present', () => {
const metaRows = fixture.nativeElement.querySelectorAll('.meta-row');
const updatedRow = Array.from(metaRows).find(
(row: Element) => row.textContent?.includes('Last Updated')
);
const updatedRow = asElements('.meta-row').find((row) => row.textContent?.includes('Last Updated'));
expect(updatedRow).toBeTruthy();
});
it('should display version', () => {
const metaRows = fixture.nativeElement.querySelectorAll('.meta-row');
const versionRow = Array.from(metaRows).find(
(row: Element) => row.textContent?.includes('Version')
);
const versionRow = asElements('.meta-row').find((row) => row.textContent?.includes('Version'));
expect(versionRow).toBeTruthy();
expect(versionRow!.textContent).toContain('1.1');
});
@@ -539,10 +524,7 @@ describe('VexStatementDetailPanelComponent', () => {
});
it('should not show Create Response for not_affected statements', () => {
const buttons = fixture.nativeElement.querySelectorAll('.panel-footer .btn');
const createBtn = Array.from(buttons).find(
(btn: Element) => btn.textContent?.includes('Create Response')
);
const createBtn = asElements('.panel-footer .btn').find((btn) => btn.textContent?.includes('Create Response'));
expect(createBtn).toBeFalsy();
});
@@ -550,10 +532,7 @@ describe('VexStatementDetailPanelComponent', () => {
component.statement.set({ ...mockStatement, status: 'fixed' as VexStatementStatus });
fixture.detectChanges();
const buttons = fixture.nativeElement.querySelectorAll('.panel-footer .btn');
const createBtn = Array.from(buttons).find(
(btn: Element) => btn.textContent?.includes('Create Response')
);
const createBtn = asElements('.panel-footer .btn').find((btn) => btn.textContent?.includes('Create Response'));
expect(createBtn).toBeFalsy();
});
});
@@ -600,8 +579,9 @@ describe('VexStatementDetailPanelComponent', () => {
expect(spy).toHaveBeenCalledWith('CVE-2024-12345');
});
it('should retry loading on retry button click', fakeAsync(() => {
it('should retry loading on retry button click', async () => {
component.error.set('Network error');
component.statement.set(null);
fixture.detectChanges();
mockVexHubApi.getStatement.calls.reset();
@@ -609,10 +589,10 @@ describe('VexStatementDetailPanelComponent', () => {
const retryBtn = fixture.nativeElement.querySelector('.error-state .btn--primary');
retryBtn.click();
tick();
await flushAsync();
expect(mockVexHubApi.getStatement).toHaveBeenCalled();
}));
});
});
describe('Format Functions', () => {

View File

@@ -951,10 +951,10 @@ export class VexStatementDetailPanelComponent implements OnChanges {
this.error.set(null);
try {
const [stmt, cons] = await Promise.all([
firstValueFrom(this.vexHubApi.getStatement(id)),
firstValueFrom(this.vexHubApi.getConsensus(id)).catch(() => null),
]);
const stmt = await firstValueFrom(this.vexHubApi.getStatement(id));
const cons = stmt.productRef
? await firstValueFrom(this.vexHubApi.getConsensusResult(id)).catch(() => null)
: null;
this.statement.set(stmt);
this.consensus.set(cons);
} catch (err) {

View File

@@ -0,0 +1,74 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import { provideNoiseGatingApi } from '../../app/app.config';
import { AuthSessionStore } from '../../app/core/auth/auth-session.store';
import { AppConfigService } from '../../app/core/config/app-config.service';
import { NOISE_GATING_API, NoiseGatingApiHttpClient } from '../../app/core/api/noise-gating.client';
describe('provideNoiseGatingApi', () => {
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
...provideNoiseGatingApi(),
{
provide: AppConfigService,
useValue: {
config: {
apiBaseUrls: {
gateway: 'https://stella-ops.local',
authority: 'https://authority.stella-ops.local',
},
},
},
},
{
provide: AuthSessionStore,
useValue: {
getActiveTenantId: () => 'demo-prod',
},
},
],
});
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('binds the production noise-gating token to the real HTTP client', async () => {
const api = TestBed.inject(NOISE_GATING_API);
const client = TestBed.inject(NoiseGatingApiHttpClient);
expect(api).toBe(client);
const responsePromise = firstValueFrom(api.getGatingStatistics());
const request = httpMock.expectOne('https://stella-ops.local/api/v1/vexlens/gating/statistics');
expect(request.request.method).toBe('GET');
expect(request.request.headers.get('X-Stella-Ops-Tenant')).toBe('demo-prod');
request.flush({
totalSnapshots: 1,
totalEdgesProcessed: 10,
totalEdgesAfterDedup: 8,
averageEdgeReductionPercent: 20,
totalVerdicts: 4,
totalSurfaced: 3,
totalDamped: 1,
averageDampingPercent: 25,
computedAt: '2026-04-14T12:00:00Z',
});
const result = await responsePromise;
expect(result.totalSnapshots).toBe(1);
});
});

View File

@@ -0,0 +1,171 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { request as playwrightRequest } from '@playwright/test';
import type { APIRequestContext, APIResponse } from '@playwright/test';
import { expect, test } from './live-auth.fixture';
const POLICY_ENGINE_BASE_URL =
process.env['PLAYWRIGHT_POLICY_ENGINE_BASE_URL'] || 'http://policy-engine.stella-ops.local';
const POLICY_TENANT = process.env['PLAYWRIGHT_POLICY_TENANT'] || 'demo-prod';
const POLL_TIMEOUT_MS = Number.parseInt(process.env['PLAYWRIGHT_POLICY_ORCHESTRATOR_POLL_TIMEOUT_MS'] || '90000', 10);
const POLL_INTERVAL_MS = Number.parseInt(process.env['PLAYWRIGHT_POLICY_ORCHESTRATOR_POLL_INTERVAL_MS'] || '1000', 10);
const outputDirectory = path.join(process.cwd(), 'output', 'playwright');
const proofArtifactPath = path.join(outputDirectory, 'policy-orchestrator-live-proof.json');
type OrchestratorJobItem = {
component_purl: string;
advisory_id: string;
};
type OrchestratorJobRequest = {
tenant_id: string;
context_id: string;
policy_profile_hash: string;
batch_items: OrchestratorJobItem[];
priority: string;
trace_ref: string;
requested_at: string;
};
type OrchestratorJob = {
job_id: string;
tenant_id: string;
context_id: string;
policy_profile_hash: string;
requested_at: string;
priority: string;
batch_items: OrchestratorJobItem[];
status: string;
determinism_hash: string;
completed_at: string | null;
result_hash: string | null;
};
type WorkerResultItem = {
component_purl: string;
advisory_id: string;
status: string;
trace_ref: string;
};
type WorkerRunResult = {
job_id: string;
worker_id: string;
started_at: string;
completed_at: string;
result_hash: string;
results: WorkerResultItem[];
};
async function createPolicyEngineRequestContext(apiToken: string): Promise<APIRequestContext> {
return playwrightRequest.newContext({
baseURL: POLICY_ENGINE_BASE_URL,
ignoreHTTPSErrors: true,
extraHTTPHeaders: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
'X-Stella-Tenant': POLICY_TENANT,
},
});
}
async function expectOkResponse(response: APIResponse) {
const body = await response.text();
expect(response.ok(), `Expected ${response.url()} to succeed, got ${response.status()} ${response.statusText()} with body:\n${body}`).toBe(true);
return body;
}
async function pollForTerminalJob(ctx: APIRequestContext, jobId: string): Promise<OrchestratorJob> {
const startedAt = Date.now();
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
const response = await ctx.get(`/policy/orchestrator/jobs/${jobId}`, { timeout: 30_000 });
const body = await expectOkResponse(response);
const job = JSON.parse(body) as OrchestratorJob;
if (job.status === 'completed' || job.status === 'failed') {
return job;
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
throw new Error(`Timed out waiting ${POLL_TIMEOUT_MS}ms for orchestrator job ${jobId} to reach a terminal state.`);
}
test.describe('Policy orchestrator producer live proof', () => {
test('submits a queued job and materializes the worker result on the live compose host', async ({ apiToken }, testInfo) => {
testInfo.setTimeout(180_000);
mkdirSync(outputDirectory, { recursive: true });
const runId = process.env['E2E_RUN_ID'] || `policy-orchestrator-${Date.now()}`;
const requestPayload: OrchestratorJobRequest = {
tenant_id: POLICY_TENANT,
context_id: `ctx-${runId}`,
policy_profile_hash: 'profile-demo',
batch_items: [
{ component_purl: 'pkg:oci/demo/api@2.0.0', advisory_id: 'CVE-2026-1000' },
{ component_purl: 'pkg:oci/demo/api@2.0.0', advisory_id: 'CVE-2026-1001' },
],
priority: 'normal',
trace_ref: `playwright-${runId}`,
requested_at: '2026-04-15T12:00:00Z',
};
const ctx = await createPolicyEngineRequestContext(apiToken);
try {
const healthResponse = await ctx.get('/healthz', { timeout: 30_000 });
await expectOkResponse(healthResponse);
const submitResponse = await ctx.post('/policy/orchestrator/jobs', {
data: requestPayload,
timeout: 30_000,
});
const submitBody = await expectOkResponse(submitResponse);
const submitted = JSON.parse(submitBody) as OrchestratorJob;
expect(submitted.job_id).toBeTruthy();
expect(submitted.status).toBe('queued');
expect(submitted.tenant_id).toBe(POLICY_TENANT);
const terminalJob = await pollForTerminalJob(ctx, submitted.job_id);
expect(terminalJob.status, `Expected orchestrator job ${submitted.job_id} to complete successfully.`).toBe('completed');
expect(terminalJob.completed_at).toBeTruthy();
expect(terminalJob.result_hash).toBeTruthy();
const workerResultResponse = await ctx.get(`/policy/worker/jobs/${submitted.job_id}`, { timeout: 30_000 });
const workerResultBody = await expectOkResponse(workerResultResponse);
const workerResult = JSON.parse(workerResultBody) as WorkerRunResult;
expect(workerResult.job_id).toBe(submitted.job_id);
expect(workerResult.worker_id).toBe('policy-orchestrator-worker');
expect(workerResult.result_hash).toBe(terminalJob.result_hash);
expect(workerResult.results).toHaveLength(2);
for (const result of workerResult.results) {
expect(result.status).toBeTruthy();
expect(result.trace_ref).toBeTruthy();
}
const proof = {
checkedAtUtc: new Date().toISOString(),
policyEngineBaseUrl: POLICY_ENGINE_BASE_URL,
tenant: POLICY_TENANT,
runId,
requestPayload,
submitted,
terminalJob,
workerResult,
};
writeFileSync(proofArtifactPath, `${JSON.stringify(proof, null, 2)}\n`, 'utf8');
await testInfo.attach('policy-orchestrator-live-proof.json', {
path: proofArtifactPath,
contentType: 'application/json',
});
} finally {
await ctx.dispose();
}
});
});

View File

@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.spec.json",
"compilerOptions": {
"outDir": "./out-tsc/spec-vex",
"types": [
"vitest/globals",
"node"
]
},
"include": [
"src/test-setup.ts",
"src/app/core/api/vex-hub.client.spec.ts",
"src/app/features/vex-hub/vex-statement-detail-panel.component.spec.ts"
],
"exclude": [
"src/**/*.e2e.spec.ts"
]
}