diff --git a/src/Web/StellaOps.Web/angular.json b/src/Web/StellaOps.Web/angular.json index e0f37f72e..c3327c432 100644 --- a/src/Web/StellaOps.Web/angular.json +++ b/src/Web/StellaOps.Web/angular.json @@ -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": { diff --git a/src/Web/StellaOps.Web/package.json b/src/Web/StellaOps.Web/package.json index 9d7b99b1b..f16a01347 100644 --- a/src/Web/StellaOps.Web/package.json +++ b/src/Web/StellaOps.Web/package.json @@ -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", diff --git a/src/Web/StellaOps.Web/scripts/live-integrations-ui-bootstrap.mjs b/src/Web/StellaOps.Web/scripts/live-integrations-ui-bootstrap.mjs index 485874912..b3ee7843e 100644 --- a/src/Web/StellaOps.Web/scripts/live-integrations-ui-bootstrap.mjs +++ b/src/Web/StellaOps.Web/scripts/live-integrations-ui-bootstrap.mjs @@ -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) { diff --git a/src/Web/StellaOps.Web/scripts/run-policy-orchestrator-proof-e2e.mjs b/src/Web/StellaOps.Web/scripts/run-policy-orchestrator-proof-e2e.mjs new file mode 100644 index 000000000..a81a9dc60 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/run-policy-orchestrator-proof-e2e.mjs @@ -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); +} diff --git a/src/Web/StellaOps.Web/src/app/app.config.ts b/src/Web/StellaOps.Web/src/app/app.config.ts index cc913d63d..7fab88808 100644 --- a/src/Web/StellaOps.Web/src/app/app.config.ts +++ b/src/Web/StellaOps.Web/src/app/app.config.ts @@ -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, diff --git a/src/Web/StellaOps.Web/src/app/core/api/vex-hub.client.spec.ts b/src/Web/StellaOps.Web/src/app/core/api/vex-hub.client.spec.ts index d53a7b8f8..bbee284d6 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/vex-hub.client.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/vex-hub.client.spec.ts @@ -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; let authSessionSpy: jasmine.SpyObj; + 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'); }); }); }); diff --git a/src/Web/StellaOps.Web/src/app/core/api/vex-hub.client.ts b/src/Web/StellaOps.Web/src/app/core/api/vex-hub.client.ts index e1cb0f895..ef002f815 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/vex-hub.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/vex-hub.client.ts @@ -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('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(`${this.baseUrl}/search`, { headers, params: httpParams }).pipe( + return this.http.get(`${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(`${this.baseUrl}/statement/${encodeURIComponent(statementId)}`, { headers }).pipe( + return this.http.get(`${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 { 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(`${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 { const traceId = options.traceId ?? generateTraceId(); - const headers = this.buildHeaders(traceId); - - return this.http.get(`${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 { const traceId = options.traceId ?? generateTraceId(); - const headers = this.buildHeaders(traceId); - - return this.http.get(`${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 { const traceId = options.traceId ?? generateTraceId(); - const headers = this.buildHeaders(traceId); - - return this.http.get(`${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 { 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(`${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 { const traceId = options.traceId ?? generateTraceId(); - const headers = this.buildHeaders(traceId); - - return this.http.get(`${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 { + 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 { + 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 { + const conflicts = await this.fetchVexLensConflictsAsync(cveId, traceId); + return this.mapConflictDetails(conflicts); + } + + private async fetchConflictStatementsAsync(cveId: string, traceId: string): Promise { + 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 { + const { response, resolvedProductRef } = await this.computeVexLensConsensusResponseAsync(cveId, productRef, traceId); + return this.mapVexLensConsensus(response, cveId, resolvedProductRef); + } + + private async fetchVexLensConflictsAsync(cveId: string, traceId: string): Promise { + 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 { + 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( + `${this.vexLensBaseUrl}/consensus`, + { + vulnerabilityId: cveId, + productKey: resolvedProductRef, + storeResult: false, + emitEvent: false, + }, + { headers } + ) + ); + + return { response, resolvedProductRef }; + } + + private async listProductsForVulnerabilityAsync(cveId: string, traceId: string): Promise { + 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 { + 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}`); } diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.spec.ts index b3ec9bde1..01a40758d 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.spec.ts @@ -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(); diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts index c6dcc629e..ee1acf62b 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts @@ -38,8 +38,8 @@ interface IntegrationSetupStep {
- {{ configuredConnectorCount() }} - configured connectors + {{ summaryCountLabel() }} + {{ summaryCountDescription() }}
@@ -102,15 +102,16 @@ interface IntegrationSetupStep { - {{ step.configuredCount > 0 ? 'Done' : 'Not started' }} + {{ setupStepStatusLabel(step) }}

{{ step.why }}

@@ -121,31 +122,31 @@ interface IntegrationSetupStep { @@ -155,7 +156,12 @@ interface IntegrationSetupStep {

Recent Activity

- @if (configuredConnectorCount() === 0) { + @if (!statsLoaded()) { +

Loading integration summary

+

+ Waiting for connector totals from the integrations service before summarizing setup progress. +

+ } @else if (configuredConnectorCount() === 0) {

No integration activity recorded yet

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(); diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-detail-panel.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-detail-panel.component.spec.ts index 89d5a93b6..c2f857d96 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-detail-panel.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-detail-panel.component.spec.ts @@ -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; let mockVexHubApi: jasmine.SpyObj; + const asElements = (selector: string): HTMLElement[] => + Array.from(fixture.nativeElement.querySelectorAll(selector) as NodeListOf); + const flushAsync = async (): Promise => { + 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', [ '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', () => { diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-detail-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-detail-panel.component.ts index ffbd586e9..e7202ce7f 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-detail-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-detail-panel.component.ts @@ -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) { diff --git a/src/Web/StellaOps.Web/src/tests/triage/noise-gating-api.providers.spec.ts b/src/Web/StellaOps.Web/src/tests/triage/noise-gating-api.providers.spec.ts new file mode 100644 index 000000000..5c819a9f9 --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/triage/noise-gating-api.providers.spec.ts @@ -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); + }); +}); diff --git a/src/Web/StellaOps.Web/tests/e2e/integrations/policy-orchestrator.e2e.spec.ts b/src/Web/StellaOps.Web/tests/e2e/integrations/policy-orchestrator.e2e.spec.ts new file mode 100644 index 000000000..10fd09347 --- /dev/null +++ b/src/Web/StellaOps.Web/tests/e2e/integrations/policy-orchestrator.e2e.spec.ts @@ -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 { + 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 { + 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(); + } + }); +}); diff --git a/src/Web/StellaOps.Web/tsconfig.spec.vex.json b/src/Web/StellaOps.Web/tsconfig.spec.vex.json new file mode 100644 index 000000000..cc2b3060d --- /dev/null +++ b/src/Web/StellaOps.Web/tsconfig.spec.vex.json @@ -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" + ] +}