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:
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
18
src/Web/StellaOps.Web/tsconfig.spec.vex.json
Normal file
18
src/Web/StellaOps.Web/tsconfig.spec.vex.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user