feat: add Reachability Center and Why Drawer components with tests
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented ReachabilityCenterComponent for displaying asset reachability status with summary and filtering options.
- Added ReachabilityWhyDrawerComponent to show detailed reachability evidence and call paths.
- Created unit tests for both components to ensure functionality and correctness.
- Updated accessibility test results for the new components.
This commit is contained in:
master
2025-12-12 18:50:35 +02:00
parent efaf3cb789
commit 3f3473ee3a
320 changed files with 10635 additions and 3677 deletions

View File

@@ -4,10 +4,21 @@
| --- | --- | --- |
| WEB-AOC-19-002 | DONE (2025-11-30) | Added provenance builder, checksum utilities, and DSSE/CMS signature verification helpers with unit tests. |
| WEB-AOC-19-003 | DONE (2025-11-30) | Added client-side guard validator (forbidden/derived/unknown fields, provenance/signature checks) with unit fixtures. |
| WEB-AIAI-31-001 | DONE (2025-12-12) | Advisory AI gateway contract + samples shipped (`docs/api/gateway/advisory-ai.md`); web SDK client added (`src/app/core/api/advisory-ai.client.ts`). |
| WEB-AIAI-31-002 | DONE (2025-12-12) | SSE job event streaming implemented + unit spec (`src/app/core/api/advisory-ai.client.spec.ts`). |
| WEB-AIAI-31-003 | DONE (2025-12-12) | Telemetry headers + prompt hash support; documented guardrail surface for audit visibility. |
| WEB-CONSOLE-23-002 | DONE (2025-12-04) | console/status polling + run stream client/store/UI shipped; samples verified in `docs/api/console/samples/`. |
| WEB-CONSOLE-23-003 | DONE (2025-12-07) | Exports client/store/service + models shipped; targeted Karma specs green locally with CHROME_BIN override (`node ./node_modules/@angular/cli/bin/ng.js test --watch=false --browsers=ChromeHeadless --include console-export specs`). Backend manifest/limits v0.4 published; awaiting final Policy/DevOps sign-off but UI/client slice complete. |
| WEB-RISK-66-001 | BLOCKED (2025-12-03) | Same implementation landed; npm ci hangs so Angular tests cant run; waiting on stable install environment and gateway endpoints to validate. |
| WEB-EXC-25-001 | BLOCKED (2025-12-06) | Pending exception schema + policy scopes/audit rules; cannot wire CRUD until contracts land. |
| WEB-EXC-25-001 | DONE (2025-12-12) | Exception contract + sample updated (`docs/api/console/exception-schema.md`); `ExceptionApiHttpClient` enforces scopes + trace/tenant headers with unit spec. |
| WEB-EXC-25-002 | DONE (2025-12-12) | Contract + samples in `docs/api/gateway/policy-exceptions.md`; client + unit spec in `src/Web/StellaOps.Web/src/app/core/api/policy-exceptions.client.ts`. |
| WEB-EXC-25-003 | DONE (2025-12-12) | Contract + samples in `docs/api/gateway/exception-events.md`; client + unit spec in `src/Web/StellaOps.Web/src/app/core/api/exception-events.client.ts`. |
| WEB-LNM-21-001 | DONE (2025-12-12) | Contract + samples in `docs/api/gateway/advisories.md`; client + unit spec in `src/Web/StellaOps.Web/src/app/core/api/advisories.client.ts`. |
| WEB-LNM-21-002 | DONE (2025-12-12) | Contract + samples in `docs/api/gateway/vex-evidence.md`; client + unit spec in `src/Web/StellaOps.Web/src/app/core/api/vex-evidence.client.ts`. |
| WEB-LNM-21-003 | DONE (2025-12-12) | Contract + sample in `docs/api/gateway/policy-evidence.md`; composition client + deterministic mock + unit spec in `src/Web/StellaOps.Web/src/app/core/api/policy-evidence.client.ts`. |
| WEB-ORCH-32-001 | DONE (2025-12-12) | Contract + sample in `docs/api/gateway/orchestrator.md`; web SDK client + deterministic mock + unit spec in `src/Web/StellaOps.Web/src/app/core/api/orchestrator.client.ts`. |
| WEB-ORCH-33-001 | DONE (2025-12-12) | Orchestrator control SDK shipped (`src/app/core/api/orchestrator-control.*`) with unit spec; gateway contract + samples updated in `docs/api/gateway/orchestrator.md`. |
| WEB-ORCH-34-001 | DONE (2025-12-12) | Quota/metrics surfaces documented + sampled (`docs/api/gateway/orchestrator.md` + samples) and covered by `OrchestratorControlHttpClient` unit spec. |
| WEB-TEN-47-CONTRACT | DONE (2025-12-01) | Gateway tenant auth/ABAC contract doc v1.0 published (`docs/api/gateway/tenant-auth.md`). |
| WEB-VULN-29-LEDGER-DOC | DONE (2025-12-01) | Findings Ledger proxy contract doc v1.0 with idempotency + retries (`docs/api/gateway/findings-ledger-proxy.md`). |
| WEB-RISK-68-NOTIFY-DOC | DONE (2025-12-01) | Notifications severity transition event schema v1.0 published (`docs/api/gateway/notifications-severity.md`). |
@@ -25,3 +36,8 @@
| UI-POLICY-23-006 | DONE (2025-12-06) | Explain view route `/policy-studio/packs/:packId/explain/:runId` with trace + JSON/PDF export (uses offline-safe jsPDF shim). |
| UI-POLICY-23-001 | DONE (2025-12-05) | Workspace route `/policy-studio/packs` with pack list + quick actions; cached pack store with offline fallback. |
| CVSS-UI-190-011 | DONE (2025-12-07) | Added CVSS receipt viewer route (/cvss/receipts/:receiptId) with score badge, tabbed sections, stub client, and unit spec in src/Web/StellaOps.Web. |
| UI-POLICY-27-001 | DONE (2025-12-12) | Policy Studio RBAC guards + nav gating aligned to `policy:author/review/approve/operate/audit/simulate`; auth fixtures/e2e aligned; `ng test` + `playwright test` green. |
| UI-SIG-26-001 | DONE (2025-12-12) | Vulnerability Explorer reachability column/filter/tooltips with deterministic stub data; hooks Why drawer. |
| UI-SIG-26-002 | DONE (2025-12-12) | Reachability Why drawer with deterministic call paths/timeline/evidence (MockSignalsClient). |
| UI-SIG-26-003 | DONE (2025-12-12) | SBOM Graph reachability halo overlay + time slider + legend (deterministic overlay state). |
| UI-SIG-26-004 | DONE (2025-12-12) | Reachability Center view (coverage/missing/stale) using deterministic fixture rows; swap to upstream datasets when published. |

View File

@@ -1,17 +1,25 @@
import { defineConfig } from '@playwright/test';
const port = process.env.PLAYWRIGHT_PORT
? Number.parseInt(process.env.PLAYWRIGHT_PORT, 10)
: 4400;
export default defineConfig({
testDir: 'tests/e2e',
timeout: 30_000,
retries: process.env.CI ? 1 : 0,
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`,
trace: 'retain-on-failure',
},
import { defineConfig } from '@playwright/test';
// Prefer the same offline-friendly Chromium resolution as Karma/unit tests.
// Falls back to Playwright-managed installs when not available.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { resolveChromeBinary } = require('./scripts/chrome-path');
const port = process.env.PLAYWRIGHT_PORT
? Number.parseInt(process.env.PLAYWRIGHT_PORT, 10)
: 4400;
const chromiumExecutable = resolveChromeBinary(__dirname) as string | null;
export default defineConfig({
testDir: 'tests/e2e',
timeout: 30_000,
retries: process.env.CI ? 1 : 0,
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`,
trace: 'retain-on-failure',
...(chromiumExecutable ? { launchOptions: { executablePath: chromiumExecutable } } : {}),
},
webServer: {
command: 'npm run serve:test',
reuseExistingServer: !process.env.CI,

View File

@@ -27,39 +27,48 @@
<a routerLink="/risk" routerLinkActive="active">
Risk
</a>
<a routerLink="/vulnerabilities" routerLinkActive="active">
Vulnerabilities
</a>
<a routerLink="/graph" routerLinkActive="active">
SBOM Graph
</a>
<a routerLink="/reachability" routerLinkActive="active">
Reachability Center
</a>
<div class="nav-group" routerLinkActive="active">
<span>Policy Studio</span>
<div class="nav-group__menu">
<app-policy-pack-selector (packSelected)="onPackSelected($event)"></app-policy-pack-selector>
<a
[routerLink]="['/policy-studio/packs', selectedPack, 'editor']"
[class.nav-disabled]="!canAuthor"
[attr.aria-disabled]="!canAuthor"
[title]="canAuthor ? '' : 'Requires policy:author scope'"
[class.nav-disabled]="!canAuthor()"
[attr.aria-disabled]="!canAuthor()"
[title]="canAuthor() ? '' : 'Requires policy:author scope'"
>
Editor
</a>
<a
[routerLink]="['/policy-studio/packs', selectedPack, 'simulate']"
[class.nav-disabled]="!canSimulate"
[attr.aria-disabled]="!canSimulate"
[title]="canSimulate ? '' : 'Requires policy:simulate scope'"
[class.nav-disabled]="!canSimulate()"
[attr.aria-disabled]="!canSimulate()"
[title]="canSimulate() ? '' : 'Requires policy:simulate scope'"
>
Simulate
</a>
<a
[routerLink]="['/policy-studio/packs', selectedPack, 'approvals']"
[class.nav-disabled]="!canReview"
[attr.aria-disabled]="!canReview"
[title]="canReview ? '' : 'Requires policy:review scope'"
[class.nav-disabled]="!canReviewOrApprove()"
[attr.aria-disabled]="!canReviewOrApprove()"
[title]="canReviewOrApprove() ? '' : 'Requires policy:review or policy:approve scope'"
>
Approvals
</a>
<a
[routerLink]="['/policy-studio/packs', selectedPack, 'dashboard']"
[class.nav-disabled]="!canView"
[attr.aria-disabled]="!canView"
[title]="canView ? '' : 'Requires policy:read scope'"
[class.nav-disabled]="!canView()"
[attr.aria-disabled]="!canView()"
[title]="canView() ? '' : 'Requires policy:read scope'"
>
Dashboard
</a>

View File

@@ -37,6 +37,8 @@ export class AppComponent {
protected canAuthor = computed(() => this.authService.canAuthorPolicies?.() ?? false);
protected canSimulate = computed(() => this.authService.canSimulatePolicies?.() ?? false);
protected canReview = computed(() => this.authService.canReviewPolicies?.() ?? false);
protected canApprove = computed(() => this.authService.canApprovePolicies?.() ?? false);
protected canReviewOrApprove = computed(() => this.canReview() || this.canApprove());
readonly status = this.sessionStore.status;
readonly identity = this.sessionStore.identity;

View File

@@ -19,6 +19,12 @@ import {
NOTIFY_API_BASE_URL,
NOTIFY_TENANT_ID,
} from './core/api/notify.client';
import {
EXCEPTION_API,
EXCEPTION_API_BASE_URL,
ExceptionApiHttpClient,
MockExceptionApiService,
} from './core/api/exception.client';
import { VULNERABILITY_API, MockVulnerabilityApiService } from './core/api/vulnerability.client';
import { VULNERABILITY_API_BASE_URL, VulnerabilityHttpClient } from './core/api/vulnerability-http.client';
import { RISK_API, MockRiskApi } from './core/api/risk.client';
@@ -32,6 +38,52 @@ import { seedAuthSession, type StubAuthSession } from './testing';
import { CVSS_API_BASE_URL } from './core/api/cvss.client';
import { AUTH_SERVICE } from './core/auth';
import { AuthorityAuthService } from './core/auth/authority-auth.service';
import {
ADVISORY_AI_API,
ADVISORY_AI_API_BASE_URL,
AdvisoryAiHttpClient,
MockAdvisoryAiApiService,
} from './core/api/advisory-ai.client';
import {
ADVISORY_API,
ADVISORY_API_BASE_URL,
AdvisoryApiHttpClient,
MockAdvisoryApiService,
} from './core/api/advisories.client';
import {
VEX_EVIDENCE_API,
VEX_EVIDENCE_API_BASE_URL,
VexEvidenceHttpClient,
MockVexEvidenceClient,
} from './core/api/vex-evidence.client';
import {
POLICY_EXCEPTIONS_API,
POLICY_EXCEPTIONS_API_BASE_URL,
PolicyExceptionsHttpClient,
MockPolicyExceptionsApiService,
} from './core/api/policy-exceptions.client';
import {
POLICY_EVIDENCE_API,
PolicyEvidenceCompositeClient,
MockPolicyEvidenceApiService,
} from './core/api/policy-evidence.client';
import {
ORCHESTRATOR_API,
ORCHESTRATOR_API_BASE_URL,
OrchestratorHttpClient,
MockOrchestratorClient,
} from './core/api/orchestrator.client';
import {
ORCHESTRATOR_CONTROL_API,
OrchestratorControlHttpClient,
MockOrchestratorControlClient,
} from './core/api/orchestrator-control.client';
import {
EXCEPTION_EVENTS_API,
EXCEPTION_EVENTS_API_BASE_URL,
ExceptionEventsHttpClient,
MockExceptionEventsApiService,
} from './core/api/exception-events.client';
export const appConfig: ApplicationConfig = {
providers: [
@@ -163,6 +215,140 @@ export const appConfig: ApplicationConfig = {
provide: NOTIFY_API_BASE_URL,
useValue: '/api/v1/notify',
},
{
provide: ADVISORY_AI_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
},
},
AdvisoryAiHttpClient,
MockAdvisoryAiApiService,
{
provide: ADVISORY_AI_API,
deps: [AppConfigService, AdvisoryAiHttpClient, MockAdvisoryAiApiService],
useFactory: (config: AppConfigService, http: AdvisoryAiHttpClient, mock: MockAdvisoryAiApiService) =>
config.config.quickstartMode ? mock : http,
},
{
provide: ADVISORY_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
},
},
AdvisoryApiHttpClient,
MockAdvisoryApiService,
{
provide: ADVISORY_API,
deps: [AppConfigService, AdvisoryApiHttpClient, MockAdvisoryApiService],
useFactory: (config: AppConfigService, http: AdvisoryApiHttpClient, mock: MockAdvisoryApiService) =>
config.config.quickstartMode ? mock : http,
},
{
provide: VEX_EVIDENCE_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
},
},
VexEvidenceHttpClient,
MockVexEvidenceClient,
{
provide: VEX_EVIDENCE_API,
deps: [AppConfigService, VexEvidenceHttpClient, MockVexEvidenceClient],
useFactory: (config: AppConfigService, http: VexEvidenceHttpClient, mock: MockVexEvidenceClient) =>
config.config.quickstartMode ? mock : http,
},
{
provide: POLICY_EXCEPTIONS_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
},
},
PolicyExceptionsHttpClient,
MockPolicyExceptionsApiService,
{
provide: POLICY_EXCEPTIONS_API,
deps: [AppConfigService, PolicyExceptionsHttpClient, MockPolicyExceptionsApiService],
useFactory: (config: AppConfigService, http: PolicyExceptionsHttpClient, mock: MockPolicyExceptionsApiService) =>
config.config.quickstartMode ? mock : http,
},
PolicyEvidenceCompositeClient,
MockPolicyEvidenceApiService,
{
provide: POLICY_EVIDENCE_API,
deps: [AppConfigService, PolicyEvidenceCompositeClient, MockPolicyEvidenceApiService],
useFactory: (
config: AppConfigService,
composite: PolicyEvidenceCompositeClient,
mock: MockPolicyEvidenceApiService
) => (config.config.quickstartMode ? mock : composite),
},
{
provide: ORCHESTRATOR_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
},
},
OrchestratorHttpClient,
MockOrchestratorClient,
{
provide: ORCHESTRATOR_API,
deps: [AppConfigService, OrchestratorHttpClient, MockOrchestratorClient],
useFactory: (config: AppConfigService, http: OrchestratorHttpClient, mock: MockOrchestratorClient) =>
config.config.quickstartMode ? mock : http,
},
OrchestratorControlHttpClient,
MockOrchestratorControlClient,
{
provide: ORCHESTRATOR_CONTROL_API,
deps: [AppConfigService, OrchestratorControlHttpClient, MockOrchestratorControlClient],
useFactory: (
config: AppConfigService,
http: OrchestratorControlHttpClient,
mock: MockOrchestratorControlClient
) => (config.config.quickstartMode ? mock : http),
},
{
provide: EXCEPTION_EVENTS_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
},
},
ExceptionEventsHttpClient,
MockExceptionEventsApiService,
{
provide: EXCEPTION_EVENTS_API,
deps: [AppConfigService, ExceptionEventsHttpClient, MockExceptionEventsApiService],
useFactory: (config: AppConfigService, http: ExceptionEventsHttpClient, mock: MockExceptionEventsApiService) =>
config.config.quickstartMode ? mock : http,
},
{
provide: EXCEPTION_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
},
},
ExceptionApiHttpClient,
MockExceptionApiService,
{
provide: EXCEPTION_API,
deps: [AppConfigService, ExceptionApiHttpClient, MockExceptionApiService],
useFactory: (config: AppConfigService, http: ExceptionApiHttpClient, mock: MockExceptionApiService) =>
config.config.quickstartMode ? mock : http,
},
{
provide: CONSOLE_API_BASE_URL,
deps: [AppConfigService],

View File

@@ -7,6 +7,7 @@ import {
requirePolicySimulatorGuard,
requirePolicyReviewerGuard,
requirePolicyApproverGuard,
requirePolicyReviewOrApproveGuard,
requirePolicyViewerGuard,
} from './core/auth';
@@ -158,6 +159,30 @@ export const routes: Routes = [
(m) => m.RiskDashboardComponent
),
},
{
path: 'graph',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/graph/graph-explorer.component').then(
(m) => m.GraphExplorerComponent
),
},
{
path: 'reachability',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/reachability/reachability-center.component').then(
(m) => m.ReachabilityCenterComponent
),
},
{
path: 'vulnerabilities',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/vulnerabilities/vulnerability-explorer.component').then(
(m) => m.VulnerabilityExplorerComponent
),
},
{
path: 'vulnerabilities/:vulnId',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],

View File

@@ -0,0 +1,74 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { AdvisoryApiHttpClient, ADVISORY_API_BASE_URL } from './advisories.client';
class FakeAuthSessionStore {
getActiveTenantId(): string | null {
return 'tenant-default';
}
}
describe('AdvisoryApiHttpClient', () => {
let client: AdvisoryApiHttpClient;
let httpMock: HttpTestingController;
let tenantService: { authorize: jasmine.Spy };
beforeEach(() => {
tenantService = { authorize: jasmine.createSpy('authorize').and.returnValue(true) };
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
AdvisoryApiHttpClient,
{ provide: ADVISORY_API_BASE_URL, useValue: '/api' },
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
{ provide: TenantActivationService, useValue: tenantService as unknown as TenantActivationService },
],
});
client = TestBed.inject(AdvisoryApiHttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('adds tenant, project, trace, and caching headers when listing advisories', () => {
client
.listAdvisories({
tenantId: 'tenant-x',
projectId: 'proj-1',
traceId: 'trace-1',
ifNoneMatch: '"etag-1"',
search: 'demo',
severity: 'high',
limit: 25,
})
.subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/advisories' && r.params.get('search') === 'demo');
expect(req.request.method).toBe('GET');
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-x');
expect(req.request.headers.get('X-Stella-Project')).toBe('proj-1');
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-1');
expect(req.request.headers.get('X-Stella-Request-Id')).toBe('trace-1');
expect(req.request.headers.get('If-None-Match')).toBe('"etag-1"');
req.flush({ items: [], count: 0, continuationToken: null, etag: '"etag-2"', traceId: 'trace-1' });
});
it('rejects advisory fetch when scope authorization fails', (done) => {
tenantService.authorize.and.returnValue(false);
client.getAdvisory('CVE-2024-12345', { traceId: 'trace-2' }).subscribe({
next: () => done.fail('expected error'),
error: (err: unknown) => {
expect(String(err)).toContain('Unauthorized');
httpMock.expectNone('/api/advisories/CVE-2024-12345');
done();
},
});
});
});

View File

@@ -0,0 +1,196 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { AdvisoryDetail, AdvisoryListResponse, AdvisoryQueryOptions, AdvisorySummary } from './advisories.models';
import { generateTraceId } from './trace.util';
export interface AdvisoryApi {
listAdvisories(options?: AdvisoryQueryOptions): Observable<AdvisoryListResponse>;
getAdvisory(advisoryId: string, options?: Pick<AdvisoryQueryOptions, 'tenantId' | 'projectId' | 'traceId' | 'ifNoneMatch'>): Observable<AdvisoryDetail>;
}
export const ADVISORY_API = new InjectionToken<AdvisoryApi>('ADVISORY_API');
export const ADVISORY_API_BASE_URL = new InjectionToken<string>('ADVISORY_API_BASE_URL');
@Injectable({ providedIn: 'root' })
export class AdvisoryApiHttpClient implements AdvisoryApi {
private static readonly MAX_PAGE_SIZE = 200;
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(ADVISORY_API_BASE_URL) private readonly baseUrl: string
) {}
listAdvisories(options: AdvisoryQueryOptions = {}): Observable<AdvisoryListResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('advisory', 'read', ['advisory:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing advisory:read scope'));
}
if (options.limit && options.limit > AdvisoryApiHttpClient.MAX_PAGE_SIZE) {
return throwError(() => new Error(`Invalid limit: max ${AdvisoryApiHttpClient.MAX_PAGE_SIZE}`));
}
let params = new HttpParams();
if (options.search) params = params.set('search', options.search);
if (options.severity) params = params.set('severity', options.severity);
if (options.sortBy) params = params.set('sortBy', options.sortBy);
if (options.sortOrder) params = params.set('sortOrder', options.sortOrder);
if (options.limit) params = params.set('limit', String(options.limit));
if (options.continuationToken) params = params.set('continuationToken', options.continuationToken);
return this.http.get<AdvisoryListResponse>(`${this.baseUrl}/advisories`, {
params,
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
getAdvisory(
advisoryId: string,
options: Pick<AdvisoryQueryOptions, 'tenantId' | 'projectId' | 'traceId' | 'ifNoneMatch'> = {}
): Observable<AdvisoryDetail> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('advisory', 'read', ['advisory:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing advisory:read scope'));
}
return this.http.get<AdvisoryDetail>(`${this.baseUrl}/advisories/${encodeURIComponent(advisoryId)}`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('AdvisoryApiHttpClient requires an active tenant identifier.');
}
return tenant;
}
private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders {
let headers = new HttpHeaders({
'Content-Type': 'application/json',
'X-StellaOps-Tenant': tenantId,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
});
if (projectId) {
headers = headers.set('X-Stella-Project', projectId);
}
if (ifNoneMatch) {
headers = headers.set('If-None-Match', ifNoneMatch);
}
return headers;
}
}
@Injectable({ providedIn: 'root' })
export class MockAdvisoryApiService implements AdvisoryApi {
private readonly advisories: AdvisoryDetail[] = [
{
advisoryId: 'CVE-2024-12345',
source: 'cve',
title: 'Example advisory for offline demo',
severity: 'high',
publishedAt: '2025-12-01T00:00:00Z',
updatedAt: '2025-12-10T00:00:00Z',
cveIds: ['CVE-2024-12345'],
affectedPurls: ['pkg:npm/example@1.2.3'],
description: 'Deterministic sample advisory payload for Console and SDK development.',
references: [
{ label: 'Vendor bulletin', url: 'https://example.invalid/advisories/CVE-2024-12345' },
],
advisoryUrl: 'https://example.invalid/advisories/CVE-2024-12345',
etag: '"adv-CVE-2024-12345-v1"',
},
{
advisoryId: 'GHSA-aaaa-bbbb-cccc',
source: 'ghsa',
title: 'Example GHSA advisory',
severity: 'critical',
publishedAt: '2025-11-20T00:00:00Z',
updatedAt: '2025-12-05T00:00:00Z',
cveIds: ['CVE-2025-00001'],
affectedPurls: ['pkg:maven/com.example/demo@0.9.0'],
description: 'Deterministic GHSA sample advisory payload.',
references: [
{ label: 'GHSA', url: 'https://github.com/advisories/GHSA-aaaa-bbbb-cccc' },
],
advisoryUrl: 'https://github.com/advisories/GHSA-aaaa-bbbb-cccc',
etag: '"adv-GHSA-aaaa-bbbb-cccc-v1"',
},
];
listAdvisories(options: AdvisoryQueryOptions = {}): Observable<AdvisoryListResponse> {
let items: AdvisorySummary[] = this.advisories.map((a) => ({ ...a }));
if (options.severity) {
items = items.filter((a) => a.severity === options.severity);
}
if (options.search) {
const needle = options.search.toLowerCase();
items = items.filter(
(a) => a.advisoryId.toLowerCase().includes(needle) || a.title.toLowerCase().includes(needle)
);
}
const sortBy = options.sortBy ?? 'advisoryId';
const sortOrder = options.sortOrder ?? 'asc';
items.sort((a, b) => {
const aValue =
sortBy === 'publishedAt'
? a.publishedAt
: sortBy === 'updatedAt'
? a.updatedAt ?? a.publishedAt
: sortBy === 'severity'
? a.severity
: a.advisoryId;
const bValue =
sortBy === 'publishedAt'
? b.publishedAt
: sortBy === 'updatedAt'
? b.updatedAt ?? b.publishedAt
: sortBy === 'severity'
? b.severity
: b.advisoryId;
const cmp = String(aValue).localeCompare(String(bValue));
return sortOrder === 'desc' ? -cmp : cmp;
});
const limit = options.limit ?? items.length;
const offset = options.continuationToken ? Number.parseInt(options.continuationToken, 10) : 0;
const page = items.slice(offset, offset + limit);
const nextOffset = offset + page.length;
return of({
items: page,
count: page.length,
continuationToken: nextOffset < items.length ? String(nextOffset) : null,
etag: '"advisories-mock-v1"',
traceId: options.traceId ?? generateTraceId(),
});
}
getAdvisory(advisoryId: string, _options: Pick<AdvisoryQueryOptions, 'traceId'> = {}): Observable<AdvisoryDetail> {
const found = this.advisories.find((a) => a.advisoryId === advisoryId);
if (!found) {
return throwError(() => new Error(`Advisory not found: ${advisoryId}`));
}
return of({ ...found });
}
}

View File

@@ -0,0 +1,48 @@
export type AdvisorySeverity = 'critical' | 'high' | 'medium' | 'low' | 'none' | 'unknown';
export type AdvisorySource = 'cve' | 'ghsa' | 'vendor' | 'distro' | 'other';
export interface AdvisoryReference {
readonly label: string;
readonly url: string;
}
export interface AdvisorySummary {
readonly advisoryId: string;
readonly source: AdvisorySource;
readonly title: string;
readonly severity: AdvisorySeverity;
readonly publishedAt: string;
readonly updatedAt?: string;
readonly cveIds?: readonly string[];
readonly affectedPurls?: readonly string[];
readonly etag?: string;
}
export interface AdvisoryDetail extends AdvisorySummary {
readonly description?: string;
readonly references?: readonly AdvisoryReference[];
readonly advisoryUrl?: string;
}
export interface AdvisoryQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
readonly ifNoneMatch?: string;
readonly search?: string;
readonly severity?: AdvisorySeverity;
readonly sortBy?: 'updatedAt' | 'publishedAt' | 'severity' | 'advisoryId';
readonly sortOrder?: 'asc' | 'desc';
readonly limit?: number;
readonly continuationToken?: string;
}
export interface AdvisoryListResponse {
readonly items: readonly AdvisorySummary[];
readonly count: number;
readonly continuationToken: string | null;
readonly etag?: string;
readonly traceId?: string;
}

View File

@@ -0,0 +1,105 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { AdvisoryAiHttpClient, ADVISORY_AI_API_BASE_URL } from './advisory-ai.client';
import { EVENT_SOURCE_FACTORY } from './console-status.client';
class FakeAuthSessionStore {
getActiveTenantId(): string | null {
return 'tenant-default';
}
}
class FakeEventSource implements EventSource {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSED = 2;
readonly CONNECTING = FakeEventSource.CONNECTING;
readonly OPEN = FakeEventSource.OPEN;
readonly CLOSED = FakeEventSource.CLOSED;
public onopen: ((this: EventSource, ev: Event) => any) | null = null;
public onmessage: ((this: EventSource, ev: MessageEvent) => any) | null = null;
public onerror: ((this: EventSource, ev: Event) => any) | null = null;
readonly readyState = FakeEventSource.CONNECTING;
readonly withCredentials = false;
constructor(public readonly url: string) {}
addEventListener(): void {}
removeEventListener(): void {}
dispatchEvent(): boolean {
return true;
}
close(): void {}
}
describe('AdvisoryAiHttpClient', () => {
let client: AdvisoryAiHttpClient;
let httpMock: HttpTestingController;
let eventSourceFactory: jasmine.Spy<(url: string) => EventSource>;
beforeEach(() => {
eventSourceFactory = jasmine
.createSpy('eventSourceFactory')
.and.callFake((url: string) => new FakeEventSource(url) as unknown as EventSource);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
AdvisoryAiHttpClient,
{ provide: ADVISORY_AI_API_BASE_URL, useValue: '/api' },
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
{
provide: TenantActivationService,
useValue: { authorize: () => true } satisfies Partial<TenantActivationService>,
},
{ provide: EVENT_SOURCE_FACTORY, useValue: eventSourceFactory },
],
});
client = TestBed.inject(AdvisoryAiHttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('posts job request with tenant and trace headers', () => {
client
.startJob({ prompt: 'hello world', profile: 'standard' }, { traceId: 'trace-1' })
.subscribe();
const req = httpMock.expectOne('/api/advisory/ai/jobs');
expect(req.request.method).toBe('POST');
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-default');
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-1');
expect(req.request.headers.get('X-Stella-Request-Id')).toBe('trace-1');
expect(req.request.headers.get('X-StellaOps-AI-Profile')).toBe('standard');
expect(req.request.headers.get('X-StellaOps-Prompt-Hash')).toMatch(/^sha256:/);
req.flush({ jobId: 'job-1', status: 'queued', traceId: 'trace-1', createdAt: '2025-12-03T00:00:00Z' });
});
it('creates SSE stream URL with tenant param and closes on unsubscribe', () => {
const events: any[] = [];
const subscription = client.streamJobEvents('job-123').subscribe((evt) => events.push(evt));
expect(eventSourceFactory).toHaveBeenCalled();
const url = eventSourceFactory.calls.mostRecent().args[0];
expect(url).toContain('/api/advisory/ai/jobs/job-123/events?tenant=tenant-default');
expect(url).toContain('traceId=');
const fakeSource = eventSourceFactory.calls.mostRecent()
.returnValue as unknown as FakeEventSource;
const message = { data: JSON.stringify({ jobId: 'job-123', kind: 'status', at: '2025-12-03T00:00:00Z', status: 'queued' }) } as MessageEvent;
fakeSource.onmessage?.call(fakeSource as unknown as EventSource, message);
expect(events.length).toBe(1);
subscription.unsubscribe();
});
});

View File

@@ -0,0 +1,275 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { delay, map } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { EVENT_SOURCE_FACTORY, type EventSourceFactory } from './console-status.client';
import type {
AdvisoryAiJob,
AdvisoryAiJobEvent,
AdvisoryAiStartJobRequest,
AdvisoryAiStartJobResponse,
} from './advisory-ai.models';
import { generateTraceId } from './trace.util';
export interface AdvisoryAiRequestOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
}
/**
* Advisory AI API interface.
* Implements WEB-AIAI-31-001/002/003.
*/
export interface AdvisoryAiApi {
startJob(request: AdvisoryAiStartJobRequest, options?: AdvisoryAiRequestOptions): Observable<AdvisoryAiStartJobResponse>;
getJob(jobId: string, options?: AdvisoryAiRequestOptions): Observable<AdvisoryAiJob>;
cancelJob(jobId: string, options?: AdvisoryAiRequestOptions): Observable<void>;
streamJobEvents(jobId: string, options?: AdvisoryAiRequestOptions): Observable<AdvisoryAiJobEvent>;
}
export const ADVISORY_AI_API = new InjectionToken<AdvisoryAiApi>('ADVISORY_AI_API');
export const ADVISORY_AI_API_BASE_URL = new InjectionToken<string>('ADVISORY_AI_API_BASE_URL');
@Injectable({ providedIn: 'root' })
export class AdvisoryAiHttpClient implements AdvisoryAiApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(ADVISORY_AI_API_BASE_URL) private readonly baseUrl: string,
@Inject(EVENT_SOURCE_FACTORY) private readonly eventSourceFactory: EventSourceFactory
) {}
startJob(request: AdvisoryAiStartJobRequest, options: AdvisoryAiRequestOptions = {}): Observable<AdvisoryAiStartJobResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('advisory-ai', 'write', ['advisory-ai:write'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing advisory-ai:write scope'));
}
const headers = this.buildHeaders(tenant, traceId, request.prompt, request.profile, options.projectId);
return this.http.post<AdvisoryAiStartJobResponse>(`${this.baseUrl}/advisory/ai/jobs`, request, { headers }).pipe(
map((resp) => ({
...resp,
traceId: resp.traceId ?? traceId,
}))
);
}
getJob(jobId: string, options: AdvisoryAiRequestOptions = {}): Observable<AdvisoryAiJob> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('advisory-ai', 'read', ['advisory-ai:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing advisory-ai:read scope'));
}
const headers = this.buildHeaders(tenant, traceId, undefined, undefined, options.projectId);
return this.http.get<AdvisoryAiJob>(`${this.baseUrl}/advisory/ai/jobs/${encodeURIComponent(jobId)}`, { headers }).pipe(
map((resp) => ({
...resp,
traceId: resp.traceId ?? traceId,
}))
);
}
cancelJob(jobId: string, options: AdvisoryAiRequestOptions = {}): Observable<void> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('advisory-ai', 'write', ['advisory-ai:write'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing advisory-ai:write scope'));
}
const headers = this.buildHeaders(tenant, traceId, undefined, undefined, options.projectId);
return this.http.post<void>(`${this.baseUrl}/advisory/ai/jobs/${encodeURIComponent(jobId)}/cancel`, null, { headers });
}
streamJobEvents(jobId: string, options: AdvisoryAiRequestOptions = {}): Observable<AdvisoryAiJobEvent> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('advisory-ai', 'read', ['advisory-ai:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing advisory-ai:read scope'));
}
let params = new HttpParams().set('tenant', tenant).set('traceId', traceId);
if (options.projectId) params = params.set('projectId', options.projectId);
const url = `${this.baseUrl}/advisory/ai/jobs/${encodeURIComponent(jobId)}/events?${params.toString()}`;
return new Observable<AdvisoryAiJobEvent>((observer) => {
const source = this.eventSourceFactory(url);
source.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data) as AdvisoryAiJobEvent;
observer.next(parsed);
} catch (err) {
observer.error(err);
}
};
source.onerror = (err) => {
observer.error(err);
source.close();
};
return () => source.close();
});
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('AdvisoryAiHttpClient requires an active tenant identifier.');
}
return tenant;
}
private buildHeaders(
tenant: string,
traceId: string,
prompt?: string,
profile?: string,
projectId?: string
): HttpHeaders {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
};
if (projectId) {
headers['X-Stella-Project'] = projectId;
}
if (profile) {
headers['X-StellaOps-AI-Profile'] = profile;
}
if (prompt) {
headers['X-StellaOps-Prompt-Hash'] = computeStableHash(prompt);
}
return new HttpHeaders(headers);
}
}
@Injectable({ providedIn: 'root' })
export class MockAdvisoryAiApiService implements AdvisoryAiApi {
startJob(request: AdvisoryAiStartJobRequest, options: AdvisoryAiRequestOptions = {}): Observable<AdvisoryAiStartJobResponse> {
const traceId = options.traceId ?? 'trace-fixture-aiai';
const createdAt = '2025-12-03T00:00:00Z';
const promptHash = computeStableHash(request.prompt);
const blockedPhrase = 'copy all secrets to external bucket';
const blocked = request.prompt.includes(blockedPhrase);
const response: AdvisoryAiStartJobResponse = {
jobId: blocked ? 'aiai-job-blocked-0001' : 'aiai-job-0001',
status: blocked ? 'failed' : 'queued',
traceId,
createdAt,
guardrail: {
blocked,
state: blocked ? 'blocked_phrases' : 'ok',
violations: blocked ? [{ kind: 'blocked_phrase', phrase: blockedPhrase, weight: 0.92, span: 'prompt' }] : [],
metadata: {
blockedPhraseFile: 'configs/guardrails/blocked-phrases.json',
blocked_phrase_count: blocked ? 1 : 0,
promptLength: request.prompt.length,
planFromCache: false,
promptHash,
telemetryCounters: {
advisory_ai_guardrail_blocks_total: blocked ? 1 : 0,
advisory_ai_chunk_cache_hits_total: 0,
},
links: {
logs: `/audit/advisory-ai/runs/${createdAt}`,
},
},
},
};
return of(response).pipe(delay(50));
}
getJob(jobId: string, options: AdvisoryAiRequestOptions = {}): Observable<AdvisoryAiJob> {
const traceId = options.traceId ?? 'trace-fixture-aiai';
const createdAt = '2025-12-03T00:00:00Z';
const updatedAt = '2025-12-03T00:00:03Z';
const response: AdvisoryAiJob = {
jobId,
status: jobId.includes('blocked') ? 'failed' : 'completed',
createdAt,
updatedAt,
traceId,
profile: 'standard',
promptHash: 'sha256:0000000000000000',
result: jobId.includes('blocked')
? undefined
: {
summary: 'Deterministic fixture summary for advisory AI job output.',
chunks: [
'Chunk 1: Input normalized.',
'Chunk 2: Evidence gathered.',
'Chunk 3: Recommendation emitted.',
],
},
error: jobId.includes('blocked')
? { code: 'guardrail_block', message: 'Prompt violated blocked phrase policy.' }
: undefined,
};
return of(response).pipe(delay(40));
}
cancelJob(): Observable<void> {
return of(void 0).pipe(delay(10));
}
streamJobEvents(jobId: string): Observable<AdvisoryAiJobEvent> {
const fixtureAt = '2025-12-03T00:00:00Z';
const events: AdvisoryAiJobEvent[] = [
{ jobId, kind: 'status', at: fixtureAt, status: 'queued', progressPercent: 0 },
{ jobId, kind: 'status', at: '2025-12-03T00:00:01Z', status: 'running', progressPercent: 10 },
{ jobId, kind: 'chunk', at: '2025-12-03T00:00:02Z', chunkIndex: 0, chunk: 'Chunk 1: Input normalized.' },
{ jobId, kind: 'chunk', at: '2025-12-03T00:00:03Z', chunkIndex: 1, chunk: 'Chunk 2: Evidence gathered.' },
{ jobId, kind: 'done', at: '2025-12-03T00:00:04Z', status: 'completed', message: 'Done' },
];
return new Observable<AdvisoryAiJobEvent>((subscriber) => {
let i = 0;
const handle = setInterval(() => {
if (i >= events.length) {
clearInterval(handle);
subscriber.complete();
return;
}
subscriber.next(events[i]);
i += 1;
}, 25);
return () => clearInterval(handle);
});
}
}
function computeStableHash(value: string): string {
let hash = 0;
for (let i = 0; i < value.length; i++) {
const char = value.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash |= 0;
}
return `sha256:${Math.abs(hash).toString(16).padStart(16, '0')}`;
}

View File

@@ -0,0 +1,80 @@
export type AdvisoryAiJobStatus = 'queued' | 'running' | 'completed' | 'failed' | 'canceled';
export type AdvisoryAiGuardrailState = 'ok' | 'blocked_phrases' | 'token_budget' | 'rate_limited' | 'unknown';
export interface AdvisoryAiGuardrailViolation {
readonly kind: string;
readonly phrase?: string;
readonly weight?: number;
readonly span?: string;
}
export interface AdvisoryAiGuardrailMetadata {
readonly blockedPhraseFile?: string;
readonly blocked_phrase_count?: number;
readonly promptLength?: number;
readonly planFromCache?: boolean;
readonly links?: Record<string, string>;
readonly telemetryCounters?: Record<string, number>;
readonly promptHash?: string;
}
export interface AdvisoryAiGuardrail {
readonly blocked: boolean;
readonly state: AdvisoryAiGuardrailState | string;
readonly violations: readonly AdvisoryAiGuardrailViolation[];
readonly metadata: AdvisoryAiGuardrailMetadata;
}
export interface AdvisoryAiStartJobRequest {
readonly profile?: string;
readonly prompt: string;
readonly maxTokens?: number;
readonly dryRun?: boolean;
readonly context?: {
readonly sbomDigests?: readonly string[];
readonly vulnIds?: readonly string[];
readonly purls?: readonly string[];
};
}
export interface AdvisoryAiStartJobResponse {
readonly jobId: string;
readonly status: AdvisoryAiJobStatus;
readonly traceId: string;
readonly createdAt: string;
readonly guardrail?: AdvisoryAiGuardrail;
}
export interface AdvisoryAiJob {
readonly jobId: string;
readonly status: AdvisoryAiJobStatus;
readonly createdAt: string;
readonly updatedAt: string;
readonly traceId: string;
readonly profile?: string;
readonly promptHash?: string;
readonly guardrail?: AdvisoryAiGuardrail;
readonly result?: {
readonly summary?: string;
readonly chunks?: readonly string[];
};
readonly error?: {
readonly code?: string;
readonly message: string;
};
}
export type AdvisoryAiJobEventKind = 'status' | 'chunk' | 'error' | 'done';
export interface AdvisoryAiJobEvent {
readonly jobId: string;
readonly kind: AdvisoryAiJobEventKind;
readonly at: string;
readonly status?: AdvisoryAiJobStatus;
readonly progressPercent?: number;
readonly chunkIndex?: number;
readonly chunk?: string;
readonly message?: string;
}

View File

@@ -0,0 +1,76 @@
import { TestBed } from '@angular/core/testing';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { EVENT_SOURCE_FACTORY, type EventSourceFactory } from './console-status.client';
import { ExceptionEventsHttpClient, EXCEPTION_EVENTS_API_BASE_URL } from './exception-events.client';
class FakeAuthSessionStore {
getActiveTenantId(): string | null {
return 'tenant-default';
}
}
describe('ExceptionEventsHttpClient', () => {
let client: ExceptionEventsHttpClient;
let tenantService: { authorize: jasmine.Spy };
let eventSourceFactory: jasmine.Spy;
beforeEach(() => {
tenantService = { authorize: jasmine.createSpy('authorize').and.returnValue(true) };
eventSourceFactory = jasmine.createSpy('eventSourceFactory');
TestBed.configureTestingModule({
providers: [
ExceptionEventsHttpClient,
{ provide: EXCEPTION_EVENTS_API_BASE_URL, useValue: '/api' },
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
{ provide: TenantActivationService, useValue: tenantService as unknown as TenantActivationService },
{ provide: EVENT_SOURCE_FACTORY, useValue: eventSourceFactory as unknown as EventSourceFactory },
],
});
client = TestBed.inject(ExceptionEventsHttpClient);
});
it('creates an EventSource for the tenant and parses JSON events', (done) => {
const fakeSource: Partial<EventSource> = {
close: jasmine.createSpy('close'),
};
eventSourceFactory.and.returnValue(fakeSource);
client.streamEvents({ tenantId: 'tenant-x', traceId: 'trace-1' }).subscribe({
next: (event) => {
expect(event.type).toBe('exception.created');
expect(event.tenantId).toBe('tenant-x');
done();
},
error: (err) => done.fail(err),
});
expect(eventSourceFactory).toHaveBeenCalledWith('/api/exceptions/events?tenant=tenant-x&traceId=trace-1');
(fakeSource as any).onmessage?.({
data: JSON.stringify({
type: 'exception.created',
tenantId: 'tenant-x',
exceptionId: 'exc-1',
timestamp: '2025-12-10T00:00:00Z',
}),
} as MessageEvent);
});
it('rejects stream when scope authorization fails', (done) => {
tenantService.authorize.and.returnValue(false);
client.streamEvents({ traceId: 'trace-2' }).subscribe({
next: () => done.fail('expected error'),
error: (err) => {
expect(String(err)).toContain('Unauthorized');
expect(eventSourceFactory).not.toHaveBeenCalled();
done();
},
});
});
});

View File

@@ -0,0 +1,91 @@
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { EventSourceFactory, EVENT_SOURCE_FACTORY } from './console-status.client';
import { ExceptionEventDto } from './exception-events.models';
import { generateTraceId } from './trace.util';
export interface ExceptionEventStreamOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
}
export interface ExceptionEventsApi {
streamEvents(options?: ExceptionEventStreamOptions): Observable<ExceptionEventDto>;
}
export const EXCEPTION_EVENTS_API = new InjectionToken<ExceptionEventsApi>('EXCEPTION_EVENTS_API');
export const EXCEPTION_EVENTS_API_BASE_URL = new InjectionToken<string>('EXCEPTION_EVENTS_API_BASE_URL');
@Injectable({ providedIn: 'root' })
export class ExceptionEventsHttpClient implements ExceptionEventsApi {
constructor(
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(EXCEPTION_EVENTS_API_BASE_URL) private readonly baseUrl: string,
@Inject(EVENT_SOURCE_FACTORY) private readonly eventSourceFactory: EventSourceFactory
) {}
streamEvents(options: ExceptionEventStreamOptions = {}): Observable<ExceptionEventDto> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('exception', 'events', ['exception:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing exception:read scope'));
}
const params = new URLSearchParams({ tenant, traceId });
if (options.projectId) {
params.set('projectId', options.projectId);
}
const url = `${this.baseUrl}/exceptions/events?${params.toString()}`;
return new Observable<ExceptionEventDto>((observer) => {
const source = this.eventSourceFactory(url);
source.onmessage = (event) => {
try {
observer.next(JSON.parse(event.data) as ExceptionEventDto);
} catch (err) {
observer.error(err);
}
};
source.onerror = (err) => {
observer.error(err);
source.close();
};
return () => source.close();
});
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('ExceptionEventsHttpClient requires an active tenant identifier.');
}
return tenant;
}
}
@Injectable({ providedIn: 'root' })
export class MockExceptionEventsApiService implements ExceptionEventsApi {
streamEvents(options: ExceptionEventStreamOptions = {}): Observable<ExceptionEventDto> {
const traceId = options.traceId ?? generateTraceId();
return of({
type: 'exception.created',
tenantId: options.tenantId ?? 'tenant-default',
exceptionId: 'exc-001',
timestamp: '2025-12-10T00:00:00Z',
actor: 'user:demo',
traceId,
metadata: { message: 'Deterministic mock exception event.' },
});
}
}

View File

@@ -0,0 +1,20 @@
import { ExceptionStatus } from './exception.contract.models';
export type ExceptionEventType =
| 'exception.created'
| 'exception.updated'
| 'exception.status_changed'
| 'exception.deleted';
export interface ExceptionEventDto {
readonly type: ExceptionEventType;
readonly tenantId: string;
readonly exceptionId: string;
readonly timestamp: string;
readonly actor?: string;
readonly previousStatus?: ExceptionStatus;
readonly newStatus?: ExceptionStatus;
readonly traceId?: string;
readonly metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,65 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { ExceptionApiHttpClient, EXCEPTION_API_BASE_URL } from './exception.client';
class FakeAuthSessionStore {
getActiveTenantId(): string | null {
return 'tenant-default';
}
}
describe('ExceptionApiHttpClient', () => {
let client: ExceptionApiHttpClient;
let httpMock: HttpTestingController;
let tenantService: { authorize: jasmine.Spy };
beforeEach(() => {
tenantService = { authorize: jasmine.createSpy('authorize').and.returnValue(true) };
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
ExceptionApiHttpClient,
{ provide: EXCEPTION_API_BASE_URL, useValue: '/api' },
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
{ provide: TenantActivationService, useValue: tenantService as unknown as TenantActivationService },
],
});
client = TestBed.inject(ExceptionApiHttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('adds tenant, project, and trace headers when listing exceptions', () => {
client
.listExceptions({ status: 'approved', tenantId: 'tenant-x', projectId: 'proj-1', traceId: 'trace-1' })
.subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/exceptions' && r.params.get('status') === 'approved');
expect(req.request.method).toBe('GET');
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-x');
expect(req.request.headers.get('X-Stella-Project')).toBe('proj-1');
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-1');
expect(req.request.headers.get('X-Stella-Request-Id')).toBe('trace-1');
req.flush({ items: [], count: 0, continuationToken: null });
});
it('rejects stats request when scope authorization fails', (done) => {
tenantService.authorize.and.returnValue(false);
client.getStats({ traceId: 'trace-2' }).subscribe({
next: () => done.fail('expected error'),
error: (err: unknown) => {
expect(String(err)).toContain('Unauthorized');
httpMock.expectNone('/api/exceptions/stats');
done();
},
});
});
});

View File

@@ -1,43 +1,61 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable } from 'rxjs';
import {
Exception,
ExceptionsQueryOptions,
ExceptionsResponse,
ExceptionStats,
ExceptionStatusTransition,
} from './exception.models';
export interface ExceptionApi {
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse>;
getException(exceptionId: string): Observable<Exception>;
createException(exception: Partial<Exception>): Observable<Exception>;
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception>;
deleteException(exceptionId: string): Observable<void>;
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception>;
getStats(): Observable<ExceptionStats>;
}
export const EXCEPTION_API = new InjectionToken<ExceptionApi>('EXCEPTION_API');
export const EXCEPTION_API_BASE_URL = new InjectionToken<string>('EXCEPTION_API_BASE_URL');
@Injectable({ providedIn: 'root' })
export class ExceptionApiHttpClient implements ExceptionApi {
constructor(
private readonly http: HttpClient,
@Inject(EXCEPTION_API_BASE_URL) private readonly baseUrl: string
) {}
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
let params = new HttpParams();
if (options?.status) {
params = params.set('status', options.status);
}
if (options?.severity) {
params = params.set('severity', options.severity);
}
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import {
Exception,
ExceptionsQueryOptions,
ExceptionsResponse,
ExceptionStats,
ExceptionStatusTransition,
} from './exception.contract.models';
import { generateTraceId } from './trace.util';
export interface ExceptionRequestOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
}
export interface ExceptionApi {
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse>;
getException(exceptionId: string, options?: ExceptionRequestOptions): Observable<Exception>;
createException(exception: Partial<Exception>, options?: ExceptionRequestOptions): Observable<Exception>;
updateException(exceptionId: string, updates: Partial<Exception>, options?: ExceptionRequestOptions): Observable<Exception>;
deleteException(exceptionId: string, options?: ExceptionRequestOptions): Observable<void>;
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception>;
getStats(options?: ExceptionRequestOptions): Observable<ExceptionStats>;
}
export const EXCEPTION_API = new InjectionToken<ExceptionApi>('EXCEPTION_API');
export const EXCEPTION_API_BASE_URL = new InjectionToken<string>('EXCEPTION_API_BASE_URL');
@Injectable({ providedIn: 'root' })
export class ExceptionApiHttpClient implements ExceptionApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(EXCEPTION_API_BASE_URL) private readonly baseUrl: string
) {}
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
const tenant = this.resolveTenant(options?.tenantId);
const traceId = options?.traceId ?? generateTraceId();
if (!this.tenantService.authorize('exception', 'read', ['exception:read'], options?.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing exception:read scope'));
}
let params = new HttpParams();
if (options?.status) {
params = params.set('status', options.status);
}
if (options?.severity) {
params = params.set('severity', options.severity);
}
if (options?.search) {
params = params.set('search', options.search);
}
@@ -50,70 +68,134 @@ export class ExceptionApiHttpClient implements ExceptionApi {
if (options?.limit) {
params = params.set('limit', options.limit.toString());
}
if (options?.continuationToken) {
params = params.set('continuationToken', options.continuationToken);
}
return this.http.get<ExceptionsResponse>(`${this.baseUrl}/exceptions`, {
params,
headers: this.buildHeaders(),
});
}
getException(exceptionId: string): Observable<Exception> {
return this.http.get<Exception>(`${this.baseUrl}/exceptions/${exceptionId}`, {
headers: this.buildHeaders(),
});
}
createException(exception: Partial<Exception>): Observable<Exception> {
return this.http.post<Exception>(`${this.baseUrl}/exceptions`, exception, {
headers: this.buildHeaders(),
});
}
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception> {
return this.http.patch<Exception>(`${this.baseUrl}/exceptions/${exceptionId}`, updates, {
headers: this.buildHeaders(),
});
}
deleteException(exceptionId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/exceptions/${exceptionId}`, {
headers: this.buildHeaders(),
});
}
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
return this.http.post<Exception>(
`${this.baseUrl}/exceptions/${transition.exceptionId}/transition`,
{
newStatus: transition.newStatus,
comment: transition.comment,
},
{
headers: this.buildHeaders(),
}
);
}
getStats(): Observable<ExceptionStats> {
return this.http.get<ExceptionStats>(`${this.baseUrl}/exceptions/stats`, {
headers: this.buildHeaders(),
});
}
private buildHeaders(): HttpHeaders {
return new HttpHeaders({
'Content-Type': 'application/json',
});
}
}
if (options?.continuationToken) {
params = params.set('continuationToken', options.continuationToken);
}
return this.http.get<ExceptionsResponse>(`${this.baseUrl}/exceptions`, {
params,
headers: this.buildHeaders(tenant, traceId, options?.projectId),
});
}
getException(exceptionId: string, options: ExceptionRequestOptions = {}): Observable<Exception> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('exception', 'read', ['exception:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing exception:read scope'));
}
return this.http.get<Exception>(`${this.baseUrl}/exceptions/${exceptionId}`, {
headers: this.buildHeaders(tenant, traceId, options.projectId),
});
}
createException(exception: Partial<Exception>, options: ExceptionRequestOptions = {}): Observable<Exception> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('exception', 'write', ['exception:write'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing exception:write scope'));
}
return this.http.post<Exception>(`${this.baseUrl}/exceptions`, exception, {
headers: this.buildHeaders(tenant, traceId, options.projectId),
});
}
updateException(exceptionId: string, updates: Partial<Exception>, options: ExceptionRequestOptions = {}): Observable<Exception> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('exception', 'write', ['exception:write'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing exception:write scope'));
}
return this.http.patch<Exception>(`${this.baseUrl}/exceptions/${exceptionId}`, updates, {
headers: this.buildHeaders(tenant, traceId, options.projectId),
});
}
deleteException(exceptionId: string, options: ExceptionRequestOptions = {}): Observable<void> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('exception', 'write', ['exception:write'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing exception:write scope'));
}
return this.http.delete<void>(`${this.baseUrl}/exceptions/${exceptionId}`, {
headers: this.buildHeaders(tenant, traceId, options.projectId),
});
}
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
const tenant = this.resolveTenant(transition.tenantId);
const traceId = transition.traceId ?? generateTraceId();
const requiredScopes: ('exception:write' | 'exception:approve')[] =
transition.newStatus === 'approved' || transition.newStatus === 'rejected' || transition.newStatus === 'revoked'
? ['exception:approve']
: ['exception:write'];
if (!this.tenantService.authorize('exception', 'transition', requiredScopes, transition.projectId, traceId)) {
return throwError(() => new Error(`Unauthorized: missing ${requiredScopes.join(' or ')} scope`));
}
return this.http.post<Exception>(
`${this.baseUrl}/exceptions/${transition.exceptionId}/transition`,
{
newStatus: transition.newStatus,
comment: transition.comment,
},
{
headers: this.buildHeaders(tenant, traceId, transition.projectId),
}
);
}
getStats(options: ExceptionRequestOptions = {}): Observable<ExceptionStats> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('exception', 'read', ['exception:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing exception:read scope'));
}
return this.http.get<ExceptionStats>(`${this.baseUrl}/exceptions/stats`, {
headers: this.buildHeaders(tenant, traceId, options.projectId),
});
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('ExceptionApiHttpClient requires an active tenant identifier.');
}
return tenant;
}
private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-StellaOps-Tenant': tenantId,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
};
if (projectId) {
headers['X-Stella-Project'] = projectId;
}
return new HttpHeaders(headers);
}
}
/**
* Mock implementation for development and testing.
*/
@Injectable({ providedIn: 'root' })
export class MockExceptionApiService implements ExceptionApi {
export class MockExceptionApiService implements ExceptionApi {
private readonly mockExceptions: Exception[] = [
{
schemaVersion: '1.0',
@@ -244,7 +326,7 @@ export class MockExceptionApiService implements ExceptionApi {
},
];
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
let filtered = [...this.mockExceptions];
if (options?.status) {
@@ -300,28 +382,28 @@ export class MockExceptionApiService implements ExceptionApi {
subscriber.complete();
}, 300);
});
}
getException(exceptionId: string): Observable<Exception> {
return new Observable((subscriber) => {
const exception = this.mockExceptions.find((e) => e.exceptionId === exceptionId);
setTimeout(() => {
if (exception) {
subscriber.next(exception);
}
getException(exceptionId: string): Observable<Exception> {
return new Observable((subscriber) => {
const exception = this.mockExceptions.find((e) => e.exceptionId === exceptionId);
setTimeout(() => {
if (exception) {
subscriber.next(exception);
} else {
subscriber.error(new Error(`Exception ${exceptionId} not found`));
}
subscriber.complete();
}, 100);
});
}
createException(exception: Partial<Exception>): Observable<Exception> {
return new Observable((subscriber) => {
const newException: Exception = {
schemaVersion: '1.0',
exceptionId: `exc-${Math.random().toString(36).slice(2, 10)}`,
tenantId: 'tenant-dev',
});
}
createException(exception: Partial<Exception>): Observable<Exception> {
return new Observable((subscriber) => {
const newException: Exception = {
schemaVersion: '1.0',
exceptionId: `exc-${Math.random().toString(36).slice(2, 10)}`,
tenantId: 'tenant-dev',
name: exception.name ?? 'new-exception',
status: 'draft',
severity: exception.severity ?? 'medium',
@@ -342,15 +424,15 @@ export class MockExceptionApiService implements ExceptionApi {
subscriber.next(newException);
subscriber.complete();
}, 200);
});
}
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception> {
return new Observable((subscriber) => {
const index = this.mockExceptions.findIndex((e) => e.exceptionId === exceptionId);
if (index === -1) {
subscriber.error(new Error(`Exception ${exceptionId} not found`));
return;
});
}
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception> {
return new Observable((subscriber) => {
const index = this.mockExceptions.findIndex((e) => e.exceptionId === exceptionId);
if (index === -1) {
subscriber.error(new Error(`Exception ${exceptionId} not found`));
return;
}
const updated = {
@@ -365,21 +447,21 @@ export class MockExceptionApiService implements ExceptionApi {
subscriber.next(updated);
subscriber.complete();
}, 200);
});
}
deleteException(exceptionId: string): Observable<void> {
return new Observable((subscriber) => {
const index = this.mockExceptions.findIndex((e) => e.exceptionId === exceptionId);
if (index !== -1) {
this.mockExceptions.splice(index, 1);
}
});
}
deleteException(exceptionId: string): Observable<void> {
return new Observable((subscriber) => {
const index = this.mockExceptions.findIndex((e) => e.exceptionId === exceptionId);
if (index !== -1) {
this.mockExceptions.splice(index, 1);
}
setTimeout(() => {
subscriber.next();
subscriber.complete();
}, 200);
});
}
});
}
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
return this.updateException(transition.exceptionId, {
@@ -387,12 +469,12 @@ export class MockExceptionApiService implements ExceptionApi {
});
}
getStats(): Observable<ExceptionStats> {
return new Observable((subscriber) => {
const byStatus: Record<string, number> = {
draft: 0,
pending_review: 0,
approved: 0,
getStats(): Observable<ExceptionStats> {
return new Observable((subscriber) => {
const byStatus: Record<string, number> = {
draft: 0,
pending_review: 0,
approved: 0,
rejected: 0,
expired: 0,
revoked: 0,

View File

@@ -0,0 +1,115 @@
export type ExceptionStatus =
| 'draft'
| 'pending_review'
| 'approved'
| 'rejected'
| 'expired'
| 'revoked';
export type ExceptionSeverity = 'critical' | 'high' | 'medium' | 'low';
export type ExceptionType = 'vulnerability' | 'license' | 'policy' | 'entropy' | 'determinism';
export type ExceptionScopeType = 'global' | 'tenant' | 'asset' | 'component';
export interface ExceptionScope {
readonly type: ExceptionScopeType;
readonly tenantId?: string;
readonly assetIds?: readonly string[];
readonly componentPurls?: readonly string[];
readonly vulnIds?: readonly string[];
// Legacy wizard fields (kept for compatibility until Exception Center is reworked).
readonly images?: readonly string[];
readonly cves?: readonly string[];
readonly packages?: readonly string[];
readonly licenses?: readonly string[];
readonly policyRules?: readonly string[];
readonly environments?: readonly string[];
}
export interface ExceptionJustification {
readonly template?: string;
readonly text: string;
}
export interface ExceptionTimebox {
readonly startDate: string;
readonly endDate: string;
readonly autoRenew?: boolean;
}
export interface ExceptionApproval {
readonly approvalId: string;
readonly approvedBy: string;
readonly approvedAt: string;
readonly comment?: string;
}
export interface ExceptionAuditEntry {
readonly auditId: string;
readonly action: string;
readonly actor: string;
readonly timestamp: string;
readonly previousStatus?: ExceptionStatus;
readonly newStatus?: ExceptionStatus;
readonly metadata?: Record<string, unknown>;
}
export interface Exception {
readonly schemaVersion: string;
readonly exceptionId: string;
readonly tenantId: string;
readonly name: string;
readonly displayName?: string;
readonly description?: string;
readonly type?: ExceptionType;
readonly status: ExceptionStatus;
readonly severity: ExceptionSeverity;
readonly scope: ExceptionScope;
readonly justification: ExceptionJustification;
readonly timebox: ExceptionTimebox;
readonly approvals?: readonly ExceptionApproval[];
readonly auditTrail?: readonly ExceptionAuditEntry[];
readonly labels?: Record<string, string>;
readonly createdBy: string;
readonly createdAt: string;
readonly updatedBy?: string;
readonly updatedAt?: string;
}
export interface ExceptionsQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
readonly status?: ExceptionStatus;
readonly severity?: ExceptionSeverity;
readonly search?: string;
readonly sortBy?: 'createdAt' | 'updatedAt' | 'name' | 'severity' | 'status';
readonly sortOrder?: 'asc' | 'desc';
readonly limit?: number;
readonly continuationToken?: string;
}
export interface ExceptionsResponse {
readonly items: readonly Exception[];
readonly count: number;
readonly continuationToken: string | null;
}
export interface ExceptionStats {
readonly total: number;
readonly byStatus: Record<ExceptionStatus, number>;
readonly bySeverity: Record<ExceptionSeverity, number>;
readonly expiringWithin7Days: number;
readonly pendingApproval: number;
}
export interface ExceptionStatusTransition {
readonly exceptionId: string;
readonly newStatus: ExceptionStatus;
readonly comment?: string;
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
}

View File

@@ -204,7 +204,11 @@ export class FindingsLedgerHttpClient implements FindingsLedgerApi {
readonly pendingActions = this._pendingActions.asReadonly();
private get baseUrl(): string {
return this.config.apiBaseUrls.ledger ?? this.config.apiBaseUrls.gateway;
return (
this.config.apiBaseUrls.ledger ??
this.config.apiBaseUrls.gateway ??
this.config.apiBaseUrls.scanner
);
}
submitAction(request: LedgerWorkflowRequest, options?: LedgerActionQueryOptions): Observable<LedgerWorkflowResponse> {

View File

@@ -0,0 +1,161 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { ORCHESTRATOR_API_BASE_URL } from './orchestrator.client';
import { MockOrchestratorControlClient, OrchestratorControlHttpClient } from './orchestrator-control.client';
class FakeAuthSessionStore {
getActiveTenantId(): string | null {
return 'tenant-default';
}
}
describe('OrchestratorControlHttpClient', () => {
let client: OrchestratorControlHttpClient;
let httpMock: HttpTestingController;
let tenantService: { authorize: jasmine.Spy };
beforeEach(() => {
tenantService = { authorize: jasmine.createSpy('authorize').and.returnValue(true) };
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
OrchestratorControlHttpClient,
{ provide: ORCHESTRATOR_API_BASE_URL, useValue: '/api' },
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
{ provide: TenantActivationService, useValue: tenantService as unknown as TenantActivationService },
],
});
client = TestBed.inject(OrchestratorControlHttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('adds tenant, project, trace, and caching headers when listing quotas', () => {
client
.listQuotas({
tenantId: 'tenant-x',
projectId: 'proj-1',
traceId: 'trace-1',
ifNoneMatch: '"etag-1"',
jobType: 'pack-run',
paused: false,
limit: 25,
continuationToken: 'cursor-1',
})
.subscribe();
const req = httpMock.expectOne(
(r) => r.url === '/api/orchestrator/quotas' && r.params.get('jobType') === 'pack-run'
);
expect(req.request.method).toBe('GET');
expect(req.request.params.get('paused')).toBe('false');
expect(req.request.params.get('limit')).toBe('25');
expect(req.request.params.get('continuationToken')).toBe('cursor-1');
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-x');
expect(req.request.headers.get('X-Stella-Project')).toBe('proj-1');
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-1');
expect(req.request.headers.get('X-Stella-Request-Id')).toBe('trace-1');
expect(req.request.headers.get('If-None-Match')).toBe('"etag-1"');
req.flush({ items: [], count: 0, continuationToken: null, etag: '"etag-2"', traceId: 'trace-1' });
});
it('marks mutating quota requests with operator metadata sentinel header', () => {
client
.createQuota(
{ jobType: 'pack-run', maxActive: 1, maxPerHour: 10, burstCapacity: 2, refillRate: 0.5 },
{ tenantId: 'tenant-x', traceId: 'trace-2' }
)
.subscribe();
const req = httpMock.expectOne('/api/orchestrator/quotas');
expect(req.request.method).toBe('POST');
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-x');
expect(req.request.headers.get('X-Stella-Require-Operator')).toBe('1');
req.flush({
quotaId: 'q-1',
tenantId: 'tenant-x',
jobType: 'pack-run',
maxActive: 1,
maxPerHour: 10,
burstCapacity: 2,
refillRate: 0.5,
currentTokens: 2,
currentActive: 0,
currentHourCount: 0,
paused: false,
pauseReason: null,
quotaTicket: null,
createdAt: '2025-12-12T00:00:00Z',
updatedAt: '2025-12-12T00:00:00Z',
updatedBy: 'user:test',
});
});
it('marks backfill operations with operator metadata sentinel header', () => {
client.replayDeadLetterEntry('entry-1', { tenantId: 'tenant-x', traceId: 'trace-3' }).subscribe();
const req = httpMock.expectOne('/api/orchestrator/deadletter/entry-1/replay');
expect(req.request.method).toBe('POST');
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-x');
expect(req.request.headers.get('X-Stella-Require-Operator')).toBe('1');
req.flush({ success: true, newJobId: 'job-1', errorMessage: null, updatedEntry: null, traceId: 'trace-3' });
});
it('rejects quota listing when scope authorization fails', (done) => {
tenantService.authorize.and.returnValue(false);
client.listQuotas({ traceId: 'trace-4' }).subscribe({
next: () => done.fail('expected error'),
error: (err: unknown) => {
expect(String(err)).toContain('Unauthorized');
httpMock.expectNone('/api/orchestrator/quotas');
done();
},
});
});
it('rejects quotas listing when limit exceeds max page size', (done) => {
client.listQuotas({ traceId: 'trace-5', limit: 500 }).subscribe({
next: () => done.fail('expected error'),
error: (err: unknown) => {
expect(String(err)).toContain('Invalid limit');
httpMock.expectNone('/api/orchestrator/quotas');
done();
},
});
});
});
describe('MockOrchestratorControlClient', () => {
it('pauses quotas deterministically and persists the update', (done) => {
const mock = new MockOrchestratorControlClient();
mock
.pauseQuota('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', { reason: 'Pause requested', ticket: 'OPS-9' }, { traceId: 'trace-6' })
.subscribe({
next: (paused) => {
expect(paused.paused).toBe(true);
expect(paused.pauseReason).toBe('Pause requested');
expect(paused.quotaTicket).toBe('OPS-9');
mock.getQuota(paused.quotaId, { traceId: 'trace-7' }).subscribe({
next: (stored) => {
expect(stored.paused).toBe(true);
expect(stored.pauseReason).toBe('Pause requested');
expect(stored.quotaTicket).toBe('OPS-9');
done();
},
error: (err: unknown) => done.fail(String(err)),
});
},
error: (err: unknown) => done.fail(String(err)),
});
});
});

View File

@@ -0,0 +1,740 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { ORCHESTRATOR_API_BASE_URL } from './orchestrator.client';
import {
CreateOrchestratorQuotaRequest,
OrchestratorBatchReplayResultResponse,
OrchestratorCancelPackRunRequest,
OrchestratorCancelPackRunResponse,
OrchestratorControlRequestOptions,
OrchestratorDeadLetterStatsResponse,
OrchestratorDeadLetterSummaryListResponse,
OrchestratorJobSummary,
OrchestratorQuota,
OrchestratorQuotaListResponse,
OrchestratorQuotaQueryOptions,
OrchestratorQuotaSummary,
OrchestratorReplayBatchRequest,
OrchestratorReplayPendingRequest,
OrchestratorReplayResultResponse,
OrchestratorRetryPackRunRequest,
OrchestratorRetryPackRunResponse,
PauseOrchestratorQuotaRequest,
UpdateOrchestratorQuotaRequest,
} from './orchestrator-control.models';
import { generateTraceId } from './trace.util';
export interface OrchestratorControlApi {
listQuotas(options?: OrchestratorQuotaQueryOptions): Observable<OrchestratorQuotaListResponse>;
getQuota(quotaId: string, options?: OrchestratorControlRequestOptions): Observable<OrchestratorQuota>;
createQuota(request: CreateOrchestratorQuotaRequest, options?: OrchestratorControlRequestOptions): Observable<OrchestratorQuota>;
updateQuota(
quotaId: string,
request: UpdateOrchestratorQuotaRequest,
options?: OrchestratorControlRequestOptions
): Observable<OrchestratorQuota>;
deleteQuota(quotaId: string, options?: OrchestratorControlRequestOptions): Observable<void>;
pauseQuota(
quotaId: string,
request: PauseOrchestratorQuotaRequest,
options?: OrchestratorControlRequestOptions
): Observable<OrchestratorQuota>;
resumeQuota(quotaId: string, options?: OrchestratorControlRequestOptions): Observable<OrchestratorQuota>;
getQuotaSummary(options?: OrchestratorControlRequestOptions): Observable<OrchestratorQuotaSummary>;
getJobSummary(options?: Pick<OrchestratorControlRequestOptions, 'tenantId' | 'projectId' | 'traceId' | 'ifNoneMatch'>): Observable<OrchestratorJobSummary>;
getDeadLetterStats(options?: OrchestratorControlRequestOptions): Observable<OrchestratorDeadLetterStatsResponse>;
getDeadLetterSummary(options?: OrchestratorControlRequestOptions): Observable<OrchestratorDeadLetterSummaryListResponse>;
replayDeadLetterEntry(entryId: string, options?: OrchestratorControlRequestOptions): Observable<OrchestratorReplayResultResponse>;
replayDeadLetterBatch(
request: OrchestratorReplayBatchRequest,
options?: OrchestratorControlRequestOptions
): Observable<OrchestratorBatchReplayResultResponse>;
replayDeadLetterPending(
request: OrchestratorReplayPendingRequest,
options?: OrchestratorControlRequestOptions
): Observable<OrchestratorBatchReplayResultResponse>;
cancelPackRun(
packRunId: string,
request: OrchestratorCancelPackRunRequest,
options?: OrchestratorControlRequestOptions
): Observable<OrchestratorCancelPackRunResponse>;
retryPackRun(
packRunId: string,
request: OrchestratorRetryPackRunRequest,
options?: OrchestratorControlRequestOptions
): Observable<OrchestratorRetryPackRunResponse>;
}
export const ORCHESTRATOR_CONTROL_API = new InjectionToken<OrchestratorControlApi>('ORCHESTRATOR_CONTROL_API');
const OPERATOR_METADATA_SENTINEL_HEADER = 'X-Stella-Require-Operator';
@Injectable({ providedIn: 'root' })
export class OrchestratorControlHttpClient implements OrchestratorControlApi {
private static readonly MAX_PAGE_SIZE = 200;
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(ORCHESTRATOR_API_BASE_URL) private readonly baseUrl: string
) {}
listQuotas(options: OrchestratorQuotaQueryOptions = {}): Observable<OrchestratorQuotaListResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'quota.read', ['orch:quota'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:quota scope'));
}
if (options.limit && options.limit > OrchestratorControlHttpClient.MAX_PAGE_SIZE) {
return throwError(() => new Error(`Invalid limit: max ${OrchestratorControlHttpClient.MAX_PAGE_SIZE}`));
}
let params = new HttpParams();
if (options.jobType) params = params.set('jobType', options.jobType);
if (typeof options.paused === 'boolean') params = params.set('paused', String(options.paused));
if (options.limit) params = params.set('limit', String(options.limit));
if (options.continuationToken) params = params.set('continuationToken', options.continuationToken);
return this.http.get<OrchestratorQuotaListResponse>(`${this.baseUrl}/orchestrator/quotas`, {
params,
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
getQuota(quotaId: string, options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorQuota> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'quota.read', ['orch:quota'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:quota scope'));
}
return this.http.get<OrchestratorQuota>(`${this.baseUrl}/orchestrator/quotas/${encodeURIComponent(quotaId)}`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
createQuota(request: CreateOrchestratorQuotaRequest, options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorQuota> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'quota.create', ['orch:quota'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:quota scope'));
}
return this.http.post<OrchestratorQuota>(`${this.baseUrl}/orchestrator/quotas`, request, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true),
});
}
updateQuota(
quotaId: string,
request: UpdateOrchestratorQuotaRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorQuota> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'quota.update', ['orch:quota'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:quota scope'));
}
return this.http.put<OrchestratorQuota>(`${this.baseUrl}/orchestrator/quotas/${encodeURIComponent(quotaId)}`, request, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true),
});
}
deleteQuota(quotaId: string, options: OrchestratorControlRequestOptions = {}): Observable<void> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'quota.delete', ['orch:quota'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:quota scope'));
}
return this.http.delete<void>(`${this.baseUrl}/orchestrator/quotas/${encodeURIComponent(quotaId)}`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true),
});
}
pauseQuota(
quotaId: string,
request: PauseOrchestratorQuotaRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorQuota> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'quota.pause', ['orch:quota'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:quota scope'));
}
return this.http.post<OrchestratorQuota>(
`${this.baseUrl}/orchestrator/quotas/${encodeURIComponent(quotaId)}/pause`,
request,
{
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true),
}
);
}
resumeQuota(quotaId: string, options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorQuota> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'quota.resume', ['orch:quota'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:quota scope'));
}
return this.http.post<OrchestratorQuota>(
`${this.baseUrl}/orchestrator/quotas/${encodeURIComponent(quotaId)}/resume`,
{},
{
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true),
}
);
}
getQuotaSummary(options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorQuotaSummary> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'quota.summary', ['orch:quota'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:quota scope'));
}
return this.http.get<OrchestratorQuotaSummary>(`${this.baseUrl}/orchestrator/quotas/summary`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
getJobSummary(
options: Pick<OrchestratorControlRequestOptions, 'tenantId' | 'projectId' | 'traceId' | 'ifNoneMatch'> = {}
): Observable<OrchestratorJobSummary> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'read', ['orch:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:read scope'));
}
return this.http.get<OrchestratorJobSummary>(`${this.baseUrl}/orchestrator/jobs/summary`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
getDeadLetterStats(options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorDeadLetterStatsResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'operate', ['orch:operate'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:operate scope'));
}
return this.http.get<OrchestratorDeadLetterStatsResponse>(`${this.baseUrl}/orchestrator/deadletter/stats`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
getDeadLetterSummary(options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorDeadLetterSummaryListResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'operate', ['orch:operate'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:operate scope'));
}
return this.http.get<OrchestratorDeadLetterSummaryListResponse>(`${this.baseUrl}/orchestrator/deadletter/summary`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
replayDeadLetterEntry(entryId: string, options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorReplayResultResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'backfill', ['orch:backfill'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:backfill scope'));
}
return this.http.post<OrchestratorReplayResultResponse>(
`${this.baseUrl}/orchestrator/deadletter/${encodeURIComponent(entryId)}/replay`,
{},
{ headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true) }
);
}
replayDeadLetterBatch(
request: OrchestratorReplayBatchRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorBatchReplayResultResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'backfill', ['orch:backfill'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:backfill scope'));
}
if (!request.entryIds.length) {
return throwError(() => new Error('Replay batch requires at least one entryId.'));
}
return this.http.post<OrchestratorBatchReplayResultResponse>(`${this.baseUrl}/orchestrator/deadletter/replay/batch`, request, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true),
});
}
replayDeadLetterPending(
request: OrchestratorReplayPendingRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorBatchReplayResultResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'backfill', ['orch:backfill'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:backfill scope'));
}
return this.http.post<OrchestratorBatchReplayResultResponse>(
`${this.baseUrl}/orchestrator/deadletter/replay/pending`,
request,
{ headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true) }
);
}
cancelPackRun(
packRunId: string,
request: OrchestratorCancelPackRunRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorCancelPackRunResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'operate', ['orch:operate'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:operate scope'));
}
return this.http.post<OrchestratorCancelPackRunResponse>(
`${this.baseUrl}/orchestrator/pack-runs/${encodeURIComponent(packRunId)}/cancel`,
request,
{ headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true) }
);
}
retryPackRun(
packRunId: string,
request: OrchestratorRetryPackRunRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorRetryPackRunResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'backfill', ['orch:backfill'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:backfill scope'));
}
return this.http.post<OrchestratorRetryPackRunResponse>(
`${this.baseUrl}/orchestrator/pack-runs/${encodeURIComponent(packRunId)}/retry`,
request,
{ headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true) }
);
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('OrchestratorControlHttpClient requires an active tenant identifier.');
}
return tenant;
}
private buildHeaders(
tenantId: string,
traceId: string,
projectId?: string,
ifNoneMatch?: string,
requireOperatorMetadata: boolean = false
): HttpHeaders {
let headers = new HttpHeaders({
'Content-Type': 'application/json',
'X-StellaOps-Tenant': tenantId,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
});
if (projectId) {
headers = headers.set('X-Stella-Project', projectId);
}
if (ifNoneMatch) {
headers = headers.set('If-None-Match', ifNoneMatch);
}
if (requireOperatorMetadata) {
headers = headers.set(OPERATOR_METADATA_SENTINEL_HEADER, '1');
}
return headers;
}
}
@Injectable({ providedIn: 'root' })
export class MockOrchestratorControlClient implements OrchestratorControlApi {
private quotas: OrchestratorQuota[] = [
{
quotaId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
tenantId: 'tenant-default',
jobType: 'pack-run',
maxActive: 10,
maxPerHour: 200,
burstCapacity: 20,
refillRate: 1,
currentTokens: 12.5,
currentActive: 2,
currentHourCount: 18,
paused: false,
pauseReason: null,
quotaTicket: null,
createdAt: '2025-11-01T00:00:00Z',
updatedAt: '2025-12-10T00:00:00Z',
updatedBy: 'user:demo',
},
{
quotaId: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
tenantId: 'tenant-default',
jobType: 'export',
maxActive: 3,
maxPerHour: 60,
burstCapacity: 6,
refillRate: 0.5,
currentTokens: 1.5,
currentActive: 3,
currentHourCount: 58,
paused: true,
pauseReason: 'Maintenance window',
quotaTicket: 'OPS-1234',
createdAt: '2025-11-15T00:00:00Z',
updatedAt: '2025-12-11T00:00:00Z',
updatedBy: 'user:ops',
},
];
listQuotas(options: OrchestratorQuotaQueryOptions = {}): Observable<OrchestratorQuotaListResponse> {
let items = this.quotas.map((q) => ({ ...q }));
if (options.jobType) {
items = items.filter((q) => q.jobType === options.jobType);
}
if (typeof options.paused === 'boolean') {
items = items.filter((q) => q.paused === options.paused);
}
items.sort((a, b) => {
const jobTypeCmp = String(a.jobType ?? '').localeCompare(String(b.jobType ?? ''));
return jobTypeCmp !== 0 ? jobTypeCmp : a.quotaId.localeCompare(b.quotaId);
});
const limit = options.limit ?? items.length;
const offset = options.continuationToken ? Number.parseInt(options.continuationToken, 10) : 0;
const page = items.slice(offset, offset + limit);
const nextOffset = offset + page.length;
return of({
items: page,
count: page.length,
continuationToken: nextOffset < items.length ? String(nextOffset) : null,
etag: '"orch-quotas-mock-v1"',
traceId: options.traceId ?? generateTraceId(),
});
}
getQuota(quotaId: string, _options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorQuota> {
const found = this.quotas.find((q) => q.quotaId === quotaId);
if (!found) {
return throwError(() => new Error(`Quota not found: ${quotaId}`));
}
void _options;
return of({ ...found });
}
createQuota(request: CreateOrchestratorQuotaRequest, options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorQuota> {
const traceId = options.traceId ?? generateTraceId();
const quota: OrchestratorQuota = {
quotaId: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
tenantId: options.tenantId ?? 'tenant-default',
jobType: request.jobType ?? 'custom',
maxActive: request.maxActive,
maxPerHour: request.maxPerHour,
burstCapacity: request.burstCapacity,
refillRate: request.refillRate,
currentTokens: request.burstCapacity,
currentActive: 0,
currentHourCount: 0,
paused: false,
pauseReason: null,
quotaTicket: null,
createdAt: '2025-12-12T00:00:00Z',
updatedAt: '2025-12-12T00:00:00Z',
updatedBy: 'user:demo',
};
this.quotas = [...this.quotas, quota];
void traceId;
return of({ ...quota });
}
updateQuota(
quotaId: string,
request: UpdateOrchestratorQuotaRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorQuota> {
void options;
const index = this.quotas.findIndex((q) => q.quotaId === quotaId);
if (index < 0) {
return throwError(() => new Error(`Quota not found: ${quotaId}`));
}
const existing = this.quotas[index];
const updated: OrchestratorQuota = {
...existing,
maxActive: request.maxActive ?? existing.maxActive,
maxPerHour: request.maxPerHour ?? existing.maxPerHour,
burstCapacity: request.burstCapacity ?? existing.burstCapacity,
refillRate: request.refillRate ?? existing.refillRate,
updatedAt: '2025-12-12T00:00:00Z',
updatedBy: 'user:demo',
};
this.quotas = [...this.quotas.slice(0, index), updated, ...this.quotas.slice(index + 1)];
return of({ ...updated });
}
deleteQuota(quotaId: string, _options: OrchestratorControlRequestOptions = {}): Observable<void> {
void _options;
this.quotas = this.quotas.filter((q) => q.quotaId !== quotaId);
return of(void 0);
}
pauseQuota(
quotaId: string,
request: PauseOrchestratorQuotaRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorQuota> {
const existing = this.quotas.find((q) => q.quotaId === quotaId);
if (!existing) {
return throwError(() => new Error(`Quota not found: ${quotaId}`));
}
const updated: OrchestratorQuota = {
...existing,
paused: true,
pauseReason: request.reason,
quotaTicket: request.ticket ?? null,
updatedAt: '2025-12-12T00:00:00Z',
updatedBy: 'user:ops',
};
this.quotas = this.quotas.map((q) => (q.quotaId === quotaId ? updated : q));
void options;
return of({ ...updated });
}
resumeQuota(quotaId: string, options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorQuota> {
const existing = this.quotas.find((q) => q.quotaId === quotaId);
if (!existing) {
return throwError(() => new Error(`Quota not found: ${quotaId}`));
}
const updated: OrchestratorQuota = {
...existing,
paused: false,
pauseReason: null,
quotaTicket: null,
updatedAt: '2025-12-12T00:00:00Z',
updatedBy: 'user:ops',
};
this.quotas = this.quotas.map((q) => (q.quotaId === quotaId ? updated : q));
void options;
return of({ ...updated });
}
getQuotaSummary(options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorQuotaSummary> {
const quotas = this.quotas.map((q) => ({ ...q })).sort((a, b) => (a.jobType ?? '').localeCompare(b.jobType ?? ''));
const pausedQuotas = quotas.filter((q) => q.paused).length;
const utilization = quotas.map((q) => ({
quotaId: q.quotaId,
jobType: q.jobType ?? null,
tokenUtilization: q.burstCapacity > 0 ? Number((1 - q.currentTokens / q.burstCapacity).toFixed(4)) : 0,
concurrencyUtilization: q.maxActive > 0 ? Number((q.currentActive / q.maxActive).toFixed(4)) : 0,
hourlyUtilization: q.maxPerHour > 0 ? Number((q.currentHourCount / q.maxPerHour).toFixed(4)) : 0,
paused: q.paused,
}));
const avgToken = utilization.length ? utilization.reduce((sum, u) => sum + u.tokenUtilization, 0) / utilization.length : 0;
const avgConcurrency = utilization.length
? utilization.reduce((sum, u) => sum + u.concurrencyUtilization, 0) / utilization.length
: 0;
return of({
totalQuotas: quotas.length,
pausedQuotas,
averageTokenUtilization: Number(avgToken.toFixed(4)),
averageConcurrencyUtilization: Number(avgConcurrency.toFixed(4)),
quotas: utilization,
traceId: options.traceId ?? generateTraceId(),
});
}
getJobSummary(_options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorJobSummary> {
void _options;
return of({
totalJobs: 12,
pendingJobs: 2,
scheduledJobs: 3,
leasedJobs: 1,
succeededJobs: 5,
failedJobs: 1,
canceledJobs: 0,
timedOutJobs: 0,
traceId: _options.traceId ?? generateTraceId(),
});
}
getDeadLetterStats(options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorDeadLetterStatsResponse> {
return of({
totalEntries: 4,
pendingEntries: 2,
replayingEntries: 0,
replayedEntries: 1,
resolvedEntries: 1,
exhaustedEntries: 0,
expiredEntries: 0,
retryableEntries: 3,
byCategory: { quota_exceeded: 2, upstream_error: 2 },
topErrorCodes: { quota_exceeded: 2, upstream_timeout: 1, invalid_payload: 1 },
topJobTypes: { 'pack-run': 2, export: 2 },
traceId: options.traceId ?? generateTraceId(),
});
}
getDeadLetterSummary(options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorDeadLetterSummaryListResponse> {
return of({
items: [
{
errorCode: 'quota_exceeded',
category: 'quota_exceeded',
entryCount: 2,
retryableCount: 2,
oldestEntry: '2025-12-10T00:00:00Z',
sampleReason: 'Quota exceeded for job type export.',
},
{
errorCode: 'upstream_timeout',
category: 'upstream_error',
entryCount: 1,
retryableCount: 1,
oldestEntry: '2025-12-11T00:00:00Z',
sampleReason: 'Upstream service timeout.',
},
],
traceId: options.traceId ?? generateTraceId(),
});
}
replayDeadLetterEntry(entryId: string, options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorReplayResultResponse> {
void entryId;
return of({
success: true,
newJobId: 'dddddddd-dddd-dddd-dddd-dddddddddddd',
errorMessage: null,
updatedEntry: {
entryId: 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee',
originalJobId: 'ffffffff-ffff-ffff-ffff-ffffffffffff',
runId: null,
sourceId: null,
jobType: 'export',
status: 'Replayed',
errorCode: 'quota_exceeded',
failureReason: 'Quota exceeded for job type export.',
remediationHint: 'Increase quota or retry later.',
category: 'quota_exceeded',
isRetryable: true,
canReplay: false,
failedAt: '2025-12-10T00:00:00Z',
createdAt: '2025-12-10T00:00:00Z',
expiresAt: '2026-01-10T00:00:00Z',
resolvedAt: null,
},
traceId: options.traceId ?? generateTraceId(),
});
}
replayDeadLetterBatch(
request: OrchestratorReplayBatchRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorBatchReplayResultResponse> {
void request;
return of({
attempted: 2,
succeeded: 2,
failed: 0,
results: [
{ success: true, newJobId: 'dddddddd-dddd-dddd-dddd-dddddddddddd', errorMessage: null, updatedEntry: null },
{ success: true, newJobId: '11111111-2222-3333-4444-555555555555', errorMessage: null, updatedEntry: null },
],
traceId: options.traceId ?? generateTraceId(),
});
}
replayDeadLetterPending(
request: OrchestratorReplayPendingRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorBatchReplayResultResponse> {
void request;
return this.replayDeadLetterBatch({ entryIds: ['a', 'b'] }, options);
}
cancelPackRun(
packRunId: string,
request: OrchestratorCancelPackRunRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorCancelPackRunResponse> {
void packRunId;
return of({
packRunId: '99999999-9999-9999-9999-999999999999',
status: 'canceled',
reason: request.reason,
canceledAt: '2025-12-12T00:00:00Z',
traceId: options.traceId ?? generateTraceId(),
});
}
retryPackRun(
packRunId: string,
_request: OrchestratorRetryPackRunRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorRetryPackRunResponse> {
void packRunId;
void _request;
return of({
originalPackRunId: '99999999-9999-9999-9999-999999999999',
newPackRunId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
status: 'scheduled',
createdAt: '2025-12-12T00:00:00Z',
traceId: options.traceId ?? generateTraceId(),
});
}
}

View File

@@ -0,0 +1,197 @@
export interface OrchestratorQuota {
readonly quotaId: string;
readonly tenantId: string;
readonly jobType?: string | null;
readonly maxActive: number;
readonly maxPerHour: number;
readonly burstCapacity: number;
readonly refillRate: number;
readonly currentTokens: number;
readonly currentActive: number;
readonly currentHourCount: number;
readonly paused: boolean;
readonly pauseReason?: string | null;
readonly quotaTicket?: string | null;
readonly createdAt: string;
readonly updatedAt: string;
readonly updatedBy: string;
}
export interface OrchestratorQuotaUtilization {
readonly quotaId: string;
readonly jobType?: string | null;
readonly tokenUtilization: number;
readonly concurrencyUtilization: number;
readonly hourlyUtilization: number;
readonly paused: boolean;
}
export interface OrchestratorQuotaSummary {
readonly totalQuotas: number;
readonly pausedQuotas: number;
readonly averageTokenUtilization: number;
readonly averageConcurrencyUtilization: number;
readonly quotas: readonly OrchestratorQuotaUtilization[];
readonly traceId?: string;
}
export interface CreateOrchestratorQuotaRequest {
readonly jobType?: string | null;
readonly maxActive: number;
readonly maxPerHour: number;
readonly burstCapacity: number;
readonly refillRate: number;
}
export interface UpdateOrchestratorQuotaRequest {
readonly maxActive?: number;
readonly maxPerHour?: number;
readonly burstCapacity?: number;
readonly refillRate?: number;
}
export interface PauseOrchestratorQuotaRequest {
readonly reason: string;
readonly ticket?: string | null;
}
export interface OrchestratorQuotaQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
readonly ifNoneMatch?: string;
readonly jobType?: string;
readonly paused?: boolean;
readonly limit?: number;
readonly continuationToken?: string;
}
export interface OrchestratorQuotaListResponse {
readonly items: readonly OrchestratorQuota[];
readonly count: number;
readonly continuationToken: string | null;
readonly etag?: string;
readonly traceId?: string;
}
export interface OrchestratorJobSummary {
readonly totalJobs: number;
readonly pendingJobs: number;
readonly scheduledJobs: number;
readonly leasedJobs: number;
readonly succeededJobs: number;
readonly failedJobs: number;
readonly canceledJobs: number;
readonly timedOutJobs: number;
readonly traceId?: string;
}
export interface OrchestratorDeadLetterSummary {
readonly errorCode: string;
readonly category: string;
readonly entryCount: number;
readonly retryableCount: number;
readonly oldestEntry: string;
readonly sampleReason?: string | null;
}
export interface OrchestratorDeadLetterSummaryListResponse {
readonly items: readonly OrchestratorDeadLetterSummary[];
readonly traceId?: string;
}
export interface OrchestratorDeadLetterStatsResponse {
readonly totalEntries: number;
readonly pendingEntries: number;
readonly replayingEntries: number;
readonly replayedEntries: number;
readonly resolvedEntries: number;
readonly exhaustedEntries: number;
readonly expiredEntries: number;
readonly retryableEntries: number;
readonly byCategory: Readonly<Record<string, number>>;
readonly topErrorCodes: Readonly<Record<string, number>>;
readonly topJobTypes: Readonly<Record<string, number>>;
readonly traceId?: string;
}
export interface OrchestratorDeadLetterEntry {
readonly entryId: string;
readonly originalJobId: string;
readonly runId?: string | null;
readonly sourceId?: string | null;
readonly jobType: string;
readonly status: string;
readonly errorCode: string;
readonly failureReason: string;
readonly remediationHint?: string | null;
readonly category: string;
readonly isRetryable: boolean;
readonly canReplay: boolean;
readonly failedAt: string;
readonly createdAt: string;
readonly expiresAt: string;
readonly resolvedAt?: string | null;
}
export interface OrchestratorReplayResult {
readonly success: boolean;
readonly newJobId?: string | null;
readonly errorMessage?: string | null;
readonly updatedEntry?: OrchestratorDeadLetterEntry | null;
}
export interface OrchestratorReplayResultResponse extends OrchestratorReplayResult {
readonly traceId?: string;
}
export interface OrchestratorBatchReplayResultResponse {
readonly attempted: number;
readonly succeeded: number;
readonly failed: number;
readonly results: readonly OrchestratorReplayResult[];
readonly traceId?: string;
}
export interface OrchestratorReplayBatchRequest {
readonly entryIds: readonly string[];
}
export interface OrchestratorReplayPendingRequest {
readonly errorCode?: string | null;
readonly category?: string | null;
readonly maxCount?: number | null;
}
export interface OrchestratorCancelPackRunRequest {
readonly reason: string;
}
export interface OrchestratorCancelPackRunResponse {
readonly packRunId: string;
readonly status: string;
readonly reason: string;
readonly canceledAt: string;
readonly traceId?: string;
}
export interface OrchestratorRetryPackRunRequest {
readonly parameters?: string | null;
readonly idempotencyKey?: string | null;
}
export interface OrchestratorRetryPackRunResponse {
readonly originalPackRunId: string;
readonly newPackRunId: string;
readonly status: string;
readonly createdAt: string;
readonly traceId?: string;
}
export interface OrchestratorControlRequestOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
readonly ifNoneMatch?: string;
}

View File

@@ -0,0 +1,75 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { OrchestratorHttpClient, ORCHESTRATOR_API_BASE_URL } from './orchestrator.client';
class FakeAuthSessionStore {
getActiveTenantId(): string | null {
return 'tenant-default';
}
}
describe('OrchestratorHttpClient', () => {
let client: OrchestratorHttpClient;
let httpMock: HttpTestingController;
let tenantService: { authorize: jasmine.Spy };
beforeEach(() => {
tenantService = { authorize: jasmine.createSpy('authorize').and.returnValue(true) };
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
OrchestratorHttpClient,
{ provide: ORCHESTRATOR_API_BASE_URL, useValue: '/api' },
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
{ provide: TenantActivationService, useValue: tenantService as unknown as TenantActivationService },
],
});
client = TestBed.inject(OrchestratorHttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('adds tenant, project, trace, and caching headers when listing sources', () => {
client
.listSources({
tenantId: 'tenant-x',
projectId: 'proj-1',
traceId: 'trace-1',
ifNoneMatch: '"etag-1"',
sourceType: 'concelier',
enabled: true,
limit: 25,
})
.subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/orchestrator/sources' && r.params.get('sourceType') === 'concelier');
expect(req.request.method).toBe('GET');
expect(req.request.params.get('enabled')).toBe('true');
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-x');
expect(req.request.headers.get('X-Stella-Project')).toBe('proj-1');
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-1');
expect(req.request.headers.get('X-Stella-Request-Id')).toBe('trace-1');
expect(req.request.headers.get('If-None-Match')).toBe('"etag-1"');
req.flush({ items: [], count: 0, continuationToken: null, etag: '"etag-2"', traceId: 'trace-1' });
});
it('rejects orchestrator source fetch when scope authorization fails', (done) => {
tenantService.authorize.and.returnValue(false);
client.getSource('11111111-1111-1111-1111-111111111111', { traceId: 'trace-2' }).subscribe({
next: () => done.fail('expected error'),
error: (err: unknown) => {
expect(String(err)).toContain('Unauthorized');
httpMock.expectNone('/api/orchestrator/sources/11111111-1111-1111-1111-111111111111');
done();
},
});
});
});

View File

@@ -0,0 +1,165 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { OrchestratorQueryOptions, OrchestratorSource, OrchestratorSourcesResponse } from './orchestrator.models';
import { generateTraceId } from './trace.util';
export interface OrchestratorApi {
listSources(options?: OrchestratorQueryOptions): Observable<OrchestratorSourcesResponse>;
getSource(sourceId: string, options?: Pick<OrchestratorQueryOptions, 'tenantId' | 'projectId' | 'traceId' | 'ifNoneMatch'>): Observable<OrchestratorSource>;
}
export const ORCHESTRATOR_API = new InjectionToken<OrchestratorApi>('ORCHESTRATOR_API');
export const ORCHESTRATOR_API_BASE_URL = new InjectionToken<string>('ORCHESTRATOR_API_BASE_URL');
@Injectable({ providedIn: 'root' })
export class OrchestratorHttpClient implements OrchestratorApi {
private static readonly MAX_PAGE_SIZE = 200;
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(ORCHESTRATOR_API_BASE_URL) private readonly baseUrl: string
) {}
listSources(options: OrchestratorQueryOptions = {}): Observable<OrchestratorSourcesResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'read', ['orch:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:read scope'));
}
if (options.limit && options.limit > OrchestratorHttpClient.MAX_PAGE_SIZE) {
return throwError(() => new Error(`Invalid limit: max ${OrchestratorHttpClient.MAX_PAGE_SIZE}`));
}
let params = new HttpParams();
if (options.sourceType) params = params.set('sourceType', options.sourceType);
if (typeof options.enabled === 'boolean') params = params.set('enabled', String(options.enabled));
if (options.limit) params = params.set('limit', String(options.limit));
if (options.continuationToken) params = params.set('continuationToken', options.continuationToken);
return this.http.get<OrchestratorSourcesResponse>(`${this.baseUrl}/orchestrator/sources`, {
params,
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
getSource(
sourceId: string,
options: Pick<OrchestratorQueryOptions, 'tenantId' | 'projectId' | 'traceId' | 'ifNoneMatch'> = {}
): Observable<OrchestratorSource> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'read', ['orch:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:read scope'));
}
return this.http.get<OrchestratorSource>(`${this.baseUrl}/orchestrator/sources/${encodeURIComponent(sourceId)}`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('OrchestratorHttpClient requires an active tenant identifier.');
}
return tenant;
}
private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders {
let headers = new HttpHeaders({
'Content-Type': 'application/json',
'X-StellaOps-Tenant': tenantId,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
});
if (projectId) {
headers = headers.set('X-Stella-Project', projectId);
}
if (ifNoneMatch) {
headers = headers.set('If-None-Match', ifNoneMatch);
}
return headers;
}
}
@Injectable({ providedIn: 'root' })
export class MockOrchestratorClient implements OrchestratorApi {
private readonly sources: OrchestratorSource[] = [
{
sourceId: '11111111-1111-1111-1111-111111111111',
name: 'concelier-ingest',
sourceType: 'concelier',
enabled: true,
paused: false,
pauseReason: null,
pauseTicket: null,
createdAt: '2025-11-01T00:00:00Z',
updatedAt: '2025-12-10T00:00:00Z',
updatedBy: 'user:demo',
},
{
sourceId: '22222222-2222-2222-2222-222222222222',
name: 'export-center',
sourceType: 'export',
enabled: true,
paused: true,
pauseReason: 'Maintenance window',
pauseTicket: 'OPS-1234',
createdAt: '2025-11-15T00:00:00Z',
updatedAt: '2025-12-11T00:00:00Z',
updatedBy: 'user:ops',
},
];
listSources(options: OrchestratorQueryOptions = {}): Observable<OrchestratorSourcesResponse> {
let items = this.sources.map((s) => ({ ...s }));
if (options.sourceType) {
items = items.filter((s) => s.sourceType === options.sourceType);
}
if (typeof options.enabled === 'boolean') {
items = items.filter((s) => s.enabled === options.enabled);
}
items.sort((a, b) => {
const nameCmp = a.name.localeCompare(b.name);
return nameCmp !== 0 ? nameCmp : a.sourceId.localeCompare(b.sourceId);
});
const limit = options.limit ?? items.length;
const offset = options.continuationToken ? Number.parseInt(options.continuationToken, 10) : 0;
const page = items.slice(offset, offset + limit);
const nextOffset = offset + page.length;
return of({
items: page,
count: page.length,
continuationToken: nextOffset < items.length ? String(nextOffset) : null,
etag: '"orch-sources-mock-v1"',
traceId: options.traceId ?? generateTraceId(),
});
}
getSource(sourceId: string, options: Pick<OrchestratorQueryOptions, 'traceId'> = {}): Observable<OrchestratorSource> {
const found = this.sources.find((s) => s.sourceId === sourceId);
if (!found) {
return throwError(() => new Error(`Orchestrator source not found: ${sourceId}`));
}
void options;
return of({ ...found });
}
}

View File

@@ -0,0 +1,32 @@
export interface OrchestratorSource {
readonly sourceId: string;
readonly name: string;
readonly sourceType: string;
readonly enabled: boolean;
readonly paused: boolean;
readonly pauseReason?: string | null;
readonly pauseTicket?: string | null;
readonly createdAt: string;
readonly updatedAt: string;
readonly updatedBy: string;
}
export interface OrchestratorQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
readonly ifNoneMatch?: string;
readonly sourceType?: string;
readonly enabled?: boolean;
readonly limit?: number;
readonly continuationToken?: string;
}
export interface OrchestratorSourcesResponse {
readonly items: readonly OrchestratorSource[];
readonly count: number;
readonly continuationToken: string | null;
readonly etag?: string;
readonly traceId?: string;
}

View File

@@ -0,0 +1,151 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { ADVISORY_API, type AdvisoryApi } from './advisories.client';
import { POLICY_EXCEPTIONS_API, type PolicyExceptionsApi } from './policy-exceptions.client';
import { PolicyEvidenceCompositeClient } from './policy-evidence.client';
import { VEX_EVIDENCE_API, type VexEvidenceApi } from './vex-evidence.client';
describe('PolicyEvidenceCompositeClient', () => {
let client: PolicyEvidenceCompositeClient;
let policyApi: jasmine.SpyObj<PolicyExceptionsApi>;
let advisoryApi: jasmine.SpyObj<AdvisoryApi>;
let vexApi: jasmine.SpyObj<VexEvidenceApi>;
beforeEach(() => {
policyApi = jasmine.createSpyObj<PolicyExceptionsApi>('PolicyExceptionsApi', ['getEffective', 'simulate']);
advisoryApi = jasmine.createSpyObj<AdvisoryApi>('AdvisoryApi', ['listAdvisories', 'getAdvisory']);
vexApi = jasmine.createSpyObj<VexEvidenceApi>('VexEvidenceApi', ['listStatements', 'getStatement', 'getEvidence', 'exportStatement']);
policyApi.getEffective.and.returnValue(
of({
policyVersion: 'sha256:test',
items: [
{ findingId: 'b', status: 'affected' },
{ findingId: 'a', status: 'quieted' },
],
continuationToken: null,
traceId: 'trace-1',
})
);
advisoryApi.listAdvisories.and.returnValue(
of({
items: [
{
advisoryId: 'Z-1',
source: 'cve',
title: 'Zed',
severity: 'high',
publishedAt: '2025-01-01T00:00:00Z',
cveIds: ['CVE-2024-12345'],
affectedPurls: ['pkg:npm/example@1.2.3'],
},
{
advisoryId: 'A-1',
source: 'cve',
title: 'Aye',
severity: 'high',
publishedAt: '2025-01-01T00:00:00Z',
cveIds: ['CVE-2024-99999'],
},
],
count: 2,
continuationToken: null,
traceId: 'trace-1',
})
);
vexApi.listStatements.and.callFake((options: any) =>
of({
items:
options.vulnId === 'CVE-2024-12345'
? [
{
statementId: 'vex-b',
vulnId: 'CVE-2024-12345',
status: 'not_affected',
updatedAt: '2025-01-01T00:00:00Z',
},
{
statementId: 'vex-a',
vulnId: 'CVE-2024-12345',
status: 'not_affected',
updatedAt: '2025-01-01T00:00:00Z',
},
]
: [],
count: 0,
continuationToken: null,
traceId: 'trace-1',
})
);
TestBed.configureTestingModule({
providers: [
PolicyEvidenceCompositeClient,
{ provide: POLICY_EXCEPTIONS_API, useValue: policyApi },
{ provide: ADVISORY_API, useValue: advisoryApi },
{ provide: VEX_EVIDENCE_API, useValue: vexApi },
],
});
client = TestBed.inject(PolicyEvidenceCompositeClient);
});
it('builds deterministic linksets and forwards trace ids', (done) => {
client
.getComponentEvidence(
{
findings: [
{ findingId: 'b', vulnId: 'CVE-2024-12345', componentPurl: 'pkg:npm/example@1.2.3' },
{ findingId: 'a', vulnId: 'CVE-2024-12345', componentPurl: 'pkg:npm/example@1.2.3' },
],
includeExceptionMetadata: true,
maxAdvisories: 10,
maxVexStatements: 10,
},
{ traceId: 'trace-1', tenantId: 'tenant-x', projectId: 'proj-1' }
)
.subscribe({
next: (result) => {
expect(result.traceId).toBe('trace-1');
expect(result.findings.map((f) => f.findingId)).toEqual(['a', 'b']);
expect(result.policy.items.map((i) => i.findingId)).toEqual(['a', 'b']);
expect(result.advisories.map((a) => a.advisoryId)).toEqual(['A-1', 'Z-1']);
expect(result.vexStatements.map((v) => v.statementId)).toEqual(['vex-a', 'vex-b']);
expect(result.linksets.map((l) => l.findingId)).toEqual(['a', 'b']);
expect(result.linksets[0].advisoryIds).toEqual(['Z-1']);
expect(result.linksets[0].vexStatementIds).toEqual(['vex-a', 'vex-b']);
expect(policyApi.getEffective).toHaveBeenCalled();
expect(advisoryApi.listAdvisories).toHaveBeenCalledWith(
jasmine.objectContaining({
traceId: 'trace-1',
tenantId: 'tenant-x',
projectId: 'proj-1',
})
);
expect(vexApi.listStatements).toHaveBeenCalledWith(
jasmine.objectContaining({
vulnId: 'CVE-2024-12345',
traceId: 'trace-1',
})
);
done();
},
error: (err: unknown) => done.fail(String(err)),
});
});
it('rejects empty requests', (done) => {
client.getComponentEvidence({ findings: [] }).subscribe({
next: () => done.fail('expected error'),
error: (err: unknown) => {
expect(String(err)).toContain('at least one finding');
done();
},
});
});
});

View File

@@ -0,0 +1,322 @@
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, forkJoin, of, throwError } from 'rxjs';
import { map } from 'rxjs/operators';
import { ADVISORY_API, AdvisoryApi } from './advisories.client';
import { AdvisorySummary } from './advisories.models';
import { POLICY_EXCEPTIONS_API, PolicyExceptionsApi } from './policy-exceptions.client';
import { PolicyEffectiveResponse, PolicyFindingRef } from './policy-exceptions.models';
import {
PolicyEvidenceComponentRequest,
PolicyEvidenceComponentResponse,
PolicyEvidenceFindingLinkset,
PolicyEvidenceRequestOptions,
} from './policy-evidence.models';
import { generateTraceId } from './trace.util';
import { VEX_EVIDENCE_API, VexEvidenceApi } from './vex-evidence.client';
import { VexStatementSummary } from './vex-evidence.models';
export interface PolicyEvidenceApi {
getComponentEvidence(
request: PolicyEvidenceComponentRequest,
options?: PolicyEvidenceRequestOptions
): Observable<PolicyEvidenceComponentResponse>;
}
export const POLICY_EVIDENCE_API = new InjectionToken<PolicyEvidenceApi>('POLICY_EVIDENCE_API');
@Injectable({ providedIn: 'root' })
export class PolicyEvidenceCompositeClient implements PolicyEvidenceApi {
private static readonly MAX_FINDINGS = 500;
private static readonly MAX_ADVISORIES = 200;
private static readonly MAX_VEX_STATEMENTS = 200;
constructor(
@Inject(POLICY_EXCEPTIONS_API) private readonly policyApi: PolicyExceptionsApi,
@Inject(ADVISORY_API) private readonly advisoryApi: AdvisoryApi,
@Inject(VEX_EVIDENCE_API) private readonly vexApi: VexEvidenceApi
) {}
getComponentEvidence(
request: PolicyEvidenceComponentRequest,
options: PolicyEvidenceRequestOptions = {}
): Observable<PolicyEvidenceComponentResponse> {
if (!request.findings.length) {
return throwError(() => new Error('PolicyEvidenceCompositeClient requires at least one finding.'));
}
if (request.findings.length > PolicyEvidenceCompositeClient.MAX_FINDINGS) {
return throwError(() => new Error(`Too many findings: max ${PolicyEvidenceCompositeClient.MAX_FINDINGS}`));
}
const traceId = options.traceId ?? generateTraceId();
const findings = request.findings
.slice()
.sort((a, b) => a.findingId.localeCompare(b.findingId));
const advisoryLimit = this.normalizeLimit(request.maxAdvisories, PolicyEvidenceCompositeClient.MAX_ADVISORIES, 50);
const vexLimit = this.normalizeLimit(request.maxVexStatements, PolicyEvidenceCompositeClient.MAX_VEX_STATEMENTS, 50);
const componentPurls = this.uniqueStrings(findings.map((f) => f.componentPurl));
const vulnIds = this.uniqueStrings(findings.map((f) => f.vulnId));
const searchTerm = componentPurls[0] ?? vulnIds[0] ?? '';
const policy$ = this.policyApi.getEffective(
{
findings,
includeExceptionMetadata: request.includeExceptionMetadata,
},
{
tenantId: options.tenantId,
projectId: options.projectId,
traceId,
}
);
const advisories$ = searchTerm
? this.advisoryApi.listAdvisories({
tenantId: options.tenantId,
projectId: options.projectId,
traceId,
search: searchTerm,
sortBy: 'advisoryId',
sortOrder: 'asc',
limit: advisoryLimit,
})
: of({ items: [], count: 0, continuationToken: null, traceId });
const vex$ =
vulnIds.length > 0
? forkJoin(
vulnIds.map((vulnId) =>
this.vexApi.listStatements({
tenantId: options.tenantId,
projectId: options.projectId,
traceId,
vulnId,
limit: vexLimit,
})
)
).pipe(
map((responses) => ({
items: responses.flatMap((r) => r.items),
etags: responses.map((r) => r.etag).filter((v): v is string => typeof v === 'string'),
traceId,
}))
)
: this.vexApi
.listStatements({
tenantId: options.tenantId,
projectId: options.projectId,
traceId,
search: searchTerm,
limit: vexLimit,
})
.pipe(map((r) => ({ items: r.items, etags: [r.etag].filter((v): v is string => typeof v === 'string'), traceId })));
return forkJoin({ policy: policy$, advisories: advisories$, vex: vex$ }).pipe(
map(({ policy, advisories, vex }) => {
const normalizedPolicy = this.normalizePolicy(policy, traceId);
const advisorySummaries = this.normalizeAdvisories(advisories.items);
const vexStatements = this.normalizeVexStatements(vex.items, vexLimit);
const linksets = this.buildLinksets(findings, advisorySummaries, vexStatements);
return {
findings,
policy: normalizedPolicy,
advisories: advisorySummaries,
vexStatements,
linksets,
traceId,
};
})
);
}
private normalizeLimit(limit: number | undefined, max: number, fallback: number): number {
const value = limit ?? fallback;
if (value <= 0) return fallback;
return Math.min(value, max);
}
private uniqueStrings(values: (string | undefined)[]): string[] {
const set = new Set<string>();
for (const value of values) {
const normalized = value?.trim();
if (normalized) set.add(normalized);
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}
private normalizePolicy(policy: PolicyEffectiveResponse, traceId: string): PolicyEffectiveResponse {
return {
...policy,
items: policy.items.slice().sort((a, b) => a.findingId.localeCompare(b.findingId)),
traceId: policy.traceId ?? traceId,
};
}
private normalizeAdvisories(items: readonly AdvisorySummary[]): AdvisorySummary[] {
return items
.slice()
.map((a) => ({ ...a }))
.sort((a, b) => a.advisoryId.localeCompare(b.advisoryId));
}
private normalizeVexStatements(items: readonly VexStatementSummary[], limit: number): VexStatementSummary[] {
const mapById = new Map<string, VexStatementSummary>();
for (const item of items) {
if (!mapById.has(item.statementId)) {
mapById.set(item.statementId, { ...item });
}
}
return Array.from(mapById.values())
.sort((a, b) => a.statementId.localeCompare(b.statementId))
.slice(0, limit);
}
private buildLinksets(
findings: readonly PolicyFindingRef[],
advisories: readonly AdvisorySummary[],
vexStatements: readonly VexStatementSummary[]
): PolicyEvidenceFindingLinkset[] {
return findings.map((finding) => {
const advisoryIds = this.matchAdvisories(finding, advisories);
const vexStatementIds = this.matchVexStatements(finding, vexStatements);
return {
findingId: finding.findingId,
vulnId: finding.vulnId,
advisoryIds,
vexStatementIds,
};
});
}
private matchAdvisories(finding: PolicyFindingRef, advisories: readonly AdvisorySummary[]): string[] {
const match = new Set<string>();
const vulnId = finding.vulnId?.trim();
const componentPurl = finding.componentPurl?.trim();
for (const advisory of advisories) {
if (vulnId) {
if (advisory.advisoryId === vulnId) match.add(advisory.advisoryId);
if (advisory.cveIds?.includes(vulnId)) match.add(advisory.advisoryId);
}
if (componentPurl && advisory.affectedPurls?.includes(componentPurl)) {
match.add(advisory.advisoryId);
}
}
return Array.from(match).sort((a, b) => a.localeCompare(b));
}
private matchVexStatements(finding: PolicyFindingRef, vexStatements: readonly VexStatementSummary[]): string[] {
const vulnId = finding.vulnId?.trim();
if (!vulnId) return [];
return vexStatements
.filter((s) => s.vulnId === vulnId)
.map((s) => s.statementId)
.sort((a, b) => a.localeCompare(b));
}
}
@Injectable({ providedIn: 'root' })
export class MockPolicyEvidenceApiService implements PolicyEvidenceApi {
private readonly response: PolicyEvidenceComponentResponse = {
findings: [
{
findingId: 'finding-1',
vulnId: 'CVE-2024-12345',
componentPurl: 'pkg:npm/example@1.2.3',
assetId: 'asset::registry.local/ops/auth',
},
],
policy: {
policyVersion: 'sha256:policy-demo',
items: [
{
findingId: 'finding-1',
status: 'affected',
severityBand: 'High',
severityScore: 7.5,
exceptions: [
{
schemaVersion: '1.0',
exceptionId: 'exc-001',
tenantId: 'tenant-default',
name: 'temporary-risk-acceptance',
displayName: 'Temporary Risk Acceptance',
status: 'approved',
severity: 'high',
scope: {
type: 'component',
componentPurls: ['pkg:npm/example@1.2.3'],
vulnIds: ['CVE-2024-12345'],
},
justification: {
template: 'risk-accepted',
text: 'Approved for demo tenant while remediation is planned.',
},
timebox: {
startDate: '2025-12-01T00:00:00Z',
endDate: '2025-12-31T23:59:59Z',
},
createdBy: 'user:demo',
createdAt: '2025-12-01T00:00:00Z',
updatedBy: 'user:demo',
updatedAt: '2025-12-10T00:00:00Z',
},
],
},
],
continuationToken: null,
traceId: 'trace-sample-6',
},
advisories: [
{
advisoryId: 'CVE-2024-12345',
source: 'cve',
title: 'Example advisory for offline demo',
severity: 'high',
publishedAt: '2025-12-01T00:00:00Z',
updatedAt: '2025-12-10T00:00:00Z',
cveIds: ['CVE-2024-12345'],
affectedPurls: ['pkg:npm/example@1.2.3'],
etag: '"adv-CVE-2024-12345-v1"',
},
],
vexStatements: [
{
statementId: 'vex::tenant-default::CVE-2024-12345::001',
vulnId: 'CVE-2024-12345',
productId: 'asset::registry.local/ops/auth',
status: 'not_affected',
justification: 'Component not present in runtime image.',
updatedAt: '2025-12-10T00:00:00Z',
etag: '"vex-001-v1"',
},
],
linksets: [
{
findingId: 'finding-1',
vulnId: 'CVE-2024-12345',
advisoryIds: ['CVE-2024-12345'],
vexStatementIds: ['vex::tenant-default::CVE-2024-12345::001'],
},
],
traceId: 'trace-sample-6',
etag: '"policy-evidence-mock-v1"',
};
getComponentEvidence(_request: PolicyEvidenceComponentRequest, options: PolicyEvidenceRequestOptions = {}): Observable<PolicyEvidenceComponentResponse> {
void _request;
void options;
return of({ ...this.response });
}
}

View File

@@ -0,0 +1,34 @@
import { AdvisorySummary } from './advisories.models';
import { PolicyEffectiveResponse, PolicyFindingRef } from './policy-exceptions.models';
import { VexStatementSummary } from './vex-evidence.models';
export interface PolicyEvidenceComponentRequest {
readonly findings: readonly PolicyFindingRef[];
readonly includeExceptionMetadata?: boolean;
readonly maxAdvisories?: number;
readonly maxVexStatements?: number;
}
export interface PolicyEvidenceFindingLinkset {
readonly findingId: string;
readonly vulnId?: string;
readonly advisoryIds: readonly string[];
readonly vexStatementIds: readonly string[];
}
export interface PolicyEvidenceComponentResponse {
readonly findings: readonly PolicyFindingRef[];
readonly policy: PolicyEffectiveResponse;
readonly advisories: readonly AdvisorySummary[];
readonly vexStatements: readonly VexStatementSummary[];
readonly linksets: readonly PolicyEvidenceFindingLinkset[];
readonly traceId: string;
readonly etag?: string;
}
export interface PolicyEvidenceRequestOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
}

View File

@@ -0,0 +1,68 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { PolicyExceptionsHttpClient, POLICY_EXCEPTIONS_API_BASE_URL } from './policy-exceptions.client';
class FakeAuthSessionStore {
getActiveTenantId(): string | null {
return 'tenant-default';
}
}
describe('PolicyExceptionsHttpClient', () => {
let client: PolicyExceptionsHttpClient;
let httpMock: HttpTestingController;
let tenantService: { authorize: jasmine.Spy };
beforeEach(() => {
tenantService = { authorize: jasmine.createSpy('authorize').and.returnValue(true) };
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
PolicyExceptionsHttpClient,
{ provide: POLICY_EXCEPTIONS_API_BASE_URL, useValue: '/api' },
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
{ provide: TenantActivationService, useValue: tenantService as unknown as TenantActivationService },
],
});
client = TestBed.inject(PolicyExceptionsHttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('adds tenant, project, and trace headers for policy effective', () => {
client
.getEffective(
{ findings: [{ findingId: 'finding-1' }], includeExceptionMetadata: true },
{ tenantId: 'tenant-x', projectId: 'proj-1', traceId: 'trace-1' }
)
.subscribe();
const req = httpMock.expectOne('/api/policy/effective');
expect(req.request.method).toBe('POST');
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-x');
expect(req.request.headers.get('X-Stella-Project')).toBe('proj-1');
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-1');
expect(req.request.headers.get('X-Stella-Request-Id')).toBe('trace-1');
req.flush({ policyVersion: 'sha256:test', items: [], continuationToken: null, traceId: 'trace-1' });
});
it('rejects simulate request when scope authorization fails', (done) => {
tenantService.authorize.and.returnValue(false);
client.simulate({ findings: [{ findingId: 'finding-1' }] }, { traceId: 'trace-2' }).subscribe({
next: () => done.fail('expected error'),
error: (err: unknown) => {
expect(String(err)).toContain('Unauthorized');
httpMock.expectNone('/api/policy/simulate');
done();
},
});
});
});

View File

@@ -0,0 +1,173 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { Exception } from './exception.contract.models';
import {
PolicyEffectiveRequest,
PolicyEffectiveResponse,
PolicySimulateRequest,
PolicySimulateResponse,
} from './policy-exceptions.models';
import { generateTraceId } from './trace.util';
export interface PolicyExceptionsApi {
getEffective(request: PolicyEffectiveRequest, options?: PolicyExceptionsRequestOptions): Observable<PolicyEffectiveResponse>;
simulate(request: PolicySimulateRequest, options?: PolicyExceptionsRequestOptions): Observable<PolicySimulateResponse>;
}
export interface PolicyExceptionsRequestOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
}
export const POLICY_EXCEPTIONS_API = new InjectionToken<PolicyExceptionsApi>('POLICY_EXCEPTIONS_API');
export const POLICY_EXCEPTIONS_API_BASE_URL = new InjectionToken<string>('POLICY_EXCEPTIONS_API_BASE_URL');
@Injectable({ providedIn: 'root' })
export class PolicyExceptionsHttpClient implements PolicyExceptionsApi {
private static readonly MAX_FINDINGS = 500;
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(POLICY_EXCEPTIONS_API_BASE_URL) private readonly baseUrl: string
) {}
getEffective(request: PolicyEffectiveRequest, options: PolicyExceptionsRequestOptions = {}): Observable<PolicyEffectiveResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('policy', 'effective', ['policy:read', 'exception:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing policy:read and exception:read scopes'));
}
if (request.findings.length > PolicyExceptionsHttpClient.MAX_FINDINGS) {
return throwError(() => new Error(`Too many findings: max ${PolicyExceptionsHttpClient.MAX_FINDINGS}`));
}
return this.http.post<PolicyEffectiveResponse>(`${this.baseUrl}/policy/effective`, request, {
headers: this.buildHeaders(tenant, traceId, options.projectId),
});
}
simulate(request: PolicySimulateRequest, options: PolicyExceptionsRequestOptions = {}): Observable<PolicySimulateResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('policy', 'simulate', ['policy:simulate', 'exception:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing policy:simulate and exception:read scopes'));
}
if (request.findings.length > PolicyExceptionsHttpClient.MAX_FINDINGS) {
return throwError(() => new Error(`Too many findings: max ${PolicyExceptionsHttpClient.MAX_FINDINGS}`));
}
return this.http.post<PolicySimulateResponse>(`${this.baseUrl}/policy/simulate`, request, {
headers: this.buildHeaders(tenant, traceId, options.projectId),
});
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('PolicyExceptionsHttpClient requires an active tenant identifier.');
}
return tenant;
}
private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-StellaOps-Tenant': tenantId,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
};
if (projectId) {
headers['X-Stella-Project'] = projectId;
}
return new HttpHeaders(headers);
}
}
@Injectable({ providedIn: 'root' })
export class MockPolicyExceptionsApiService implements PolicyExceptionsApi {
private readonly mockException: Exception = {
schemaVersion: '1.0',
exceptionId: 'exc-001',
tenantId: 'tenant-default',
name: 'temporary-risk-acceptance',
displayName: 'Temporary Risk Acceptance',
description: 'Deterministic stub exception used for policy/effective and policy/simulate demos.',
status: 'approved',
severity: 'high',
scope: {
type: 'component',
componentPurls: ['pkg:npm/example@1.2.3'],
vulnIds: ['CVE-2024-12345'],
},
justification: {
template: 'risk-accepted',
text: 'Approved for demo tenant while remediation is planned.',
},
timebox: {
startDate: '2025-12-01T00:00:00Z',
endDate: '2025-12-31T23:59:59Z',
},
createdBy: 'user:demo',
createdAt: '2025-12-01T00:00:00Z',
updatedBy: 'user:demo',
updatedAt: '2025-12-10T00:00:00Z',
};
getEffective(request: PolicyEffectiveRequest, options: PolicyExceptionsRequestOptions = {}): Observable<PolicyEffectiveResponse> {
const traceId = options.traceId ?? generateTraceId();
const items = request.findings
.slice()
.sort((a, b) => a.findingId.localeCompare(b.findingId))
.map((finding) => ({
findingId: finding.findingId,
status: 'affected',
severityBand: 'High' as const,
severityScore: 7.5,
exceptions: request.includeExceptionMetadata ? [this.mockException] : [],
}));
return of({
policyVersion: 'sha256:policy-demo',
items,
continuationToken: null,
traceId,
});
}
simulate(request: PolicySimulateRequest, options: PolicyExceptionsRequestOptions = {}): Observable<PolicySimulateResponse> {
const traceId = options.traceId ?? generateTraceId();
const applyOverride = request.exceptionOverrides?.some((o) => o.mode === 'apply') ?? false;
const items = request.findings
.slice()
.sort((a, b) => a.findingId.localeCompare(b.findingId))
.map((finding) => ({
findingId: finding.findingId,
status: applyOverride ? 'quieted' : 'affected',
severityBand: applyOverride ? 'None' as const : ('High' as const),
severityScore: applyOverride ? 0 : 7.5,
exceptions: applyOverride ? [this.mockException] : [],
}));
return of({
policyVersion: 'sha256:policy-demo',
items,
continuationToken: null,
traceId,
});
}
}

View File

@@ -0,0 +1,50 @@
import { Exception } from './exception.contract.models';
export interface PolicyFindingRef {
readonly findingId: string;
readonly vulnId?: string;
readonly componentPurl?: string;
readonly assetId?: string;
}
export interface PolicyEffectiveRequest {
readonly findings: readonly PolicyFindingRef[];
readonly includeExceptionMetadata?: boolean;
readonly limit?: number;
readonly continuationToken?: string;
}
export interface PolicyEffectiveResult {
readonly findingId: string;
readonly status: string;
readonly severityBand?: 'Critical' | 'High' | 'Medium' | 'Low' | 'None' | 'Unknown';
readonly severityScore?: number;
readonly exceptions?: readonly Exception[];
}
export interface PolicyEffectiveResponse {
readonly policyVersion: string;
readonly items: readonly PolicyEffectiveResult[];
readonly continuationToken: string | null;
readonly traceId?: string;
}
export interface ExceptionSimulationOverride {
readonly mode: 'apply' | 'ignore';
readonly exception: Partial<Exception>;
}
export interface PolicySimulateRequest {
readonly findings: readonly PolicyFindingRef[];
readonly exceptionOverrides?: readonly ExceptionSimulationOverride[];
readonly limit?: number;
readonly continuationToken?: string;
}
export interface PolicySimulateResponse {
readonly policyVersion: string;
readonly items: readonly PolicyEffectiveResult[];
readonly continuationToken: string | null;
readonly traceId?: string;
}

View File

@@ -4,7 +4,15 @@ import { Observable, catchError, map, throwError } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
import { RiskApi } from './risk.client';
import { RiskQueryOptions, RiskResultPage, RiskStats } from './risk.models';
import {
AggregatedRiskStatus,
RiskExplanationUrl,
RiskProfile,
RiskQueryOptions,
RiskResultPage,
RiskStats,
SeverityTransitionEvent,
} from './risk.models';
import { generateTraceId } from './trace.util';
export const RISK_API_BASE_URL = new InjectionToken<string>('RISK_API_BASE_URL');
@@ -62,6 +70,73 @@ export class RiskHttpClient implements RiskApi {
);
}
get(
riskId: string,
options?: Pick<RiskQueryOptions, 'tenantId' | 'projectId' | 'traceId'>
): Observable<RiskProfile> {
const tenant = this.resolveTenant(options?.tenantId);
const traceId = options?.traceId ?? generateTraceId();
const headers = this.buildHeaders(tenant, options?.projectId, traceId);
return this.http
.get<RiskProfile>(`${this.baseUrl}/risk/${encodeURIComponent(riskId)}`, { headers })
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}
getExplanationUrl(
riskId: string,
options?: Pick<RiskQueryOptions, 'tenantId' | 'projectId' | 'traceId'>
): Observable<RiskExplanationUrl> {
const tenant = this.resolveTenant(options?.tenantId);
const traceId = options?.traceId ?? generateTraceId();
const headers = this.buildHeaders(tenant, options?.projectId, traceId);
return this.http
.get<RiskExplanationUrl>(`${this.baseUrl}/risk/${encodeURIComponent(riskId)}/explanation-url`, { headers })
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}
getAggregatedStatus(
options: Pick<RiskQueryOptions, 'tenantId' | 'projectId' | 'traceId'>
): Observable<AggregatedRiskStatus> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(tenant, options.projectId, traceId);
return this.http
.get<AggregatedRiskStatus>(`${this.baseUrl}/risk/aggregated-status`, { headers })
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}
getRecentTransitions(
options: Pick<RiskQueryOptions, 'tenantId' | 'projectId' | 'traceId'> & { limit?: number }
): Observable<SeverityTransitionEvent[]> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(tenant, options.projectId, traceId);
let params = new HttpParams();
if (options.limit) params = params.set('limit', options.limit);
return this.http
.get<SeverityTransitionEvent[]>(`${this.baseUrl}/risk/transitions/recent`, { headers, params })
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}
subscribeToTransitions(
_options: Pick<RiskQueryOptions, 'tenantId' | 'projectId'>
): Observable<SeverityTransitionEvent> {
return throwError(() => new Error('Risk transition streaming is not implemented in RiskHttpClient.'));
}
emitTransitionEvent(
event: SeverityTransitionEvent
): Observable<{ emitted: boolean; eventId: string }> {
return this.http
.post<{ emitted: boolean; eventId: string }>(`${this.baseUrl}/risk/transitions`, event)
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}
private normalizeError(err: unknown): Error {
if (err instanceof RateLimitError) return err;
if (err instanceof HttpErrorResponse && err.status === 429) {

View File

@@ -219,7 +219,11 @@ export class SignalsHttpClient implements SignalsApi {
private readonly cacheTtlMs = 120000; // 2 minutes
private get baseUrl(): string {
return this.config.apiBaseUrls.signals ?? this.config.apiBaseUrls.gateway;
return (
this.config.apiBaseUrls.signals ??
this.config.apiBaseUrls.gateway ??
this.config.apiBaseUrls.scanner
);
}
getCallGraphs(options?: SignalsQueryOptions): Observable<CallGraphsResponse> {

View File

@@ -198,7 +198,11 @@ export class VexConsensusHttpClient implements VexConsensusApi {
readonly streamStats = this._streamStats.asReadonly();
private get baseUrl(): string {
return this.config.apiBaseUrls.vex ?? this.config.apiBaseUrls.gateway;
return (
this.config.apiBaseUrls.vex ??
this.config.apiBaseUrls.gateway ??
this.config.apiBaseUrls.scanner
);
}
listStatements(options?: VexConsensusQueryOptions): Observable<VexConsensusResponse> {

View File

@@ -0,0 +1,87 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { VexEvidenceHttpClient, VEX_EVIDENCE_API_BASE_URL } from './vex-evidence.client';
class FakeAuthSessionStore {
getActiveTenantId(): string | null {
return 'tenant-default';
}
}
describe('VexEvidenceHttpClient', () => {
let client: VexEvidenceHttpClient;
let httpMock: HttpTestingController;
let tenantService: { authorize: jasmine.Spy };
beforeEach(() => {
tenantService = { authorize: jasmine.createSpy('authorize').and.returnValue(true) };
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
VexEvidenceHttpClient,
{ provide: VEX_EVIDENCE_API_BASE_URL, useValue: '/api' },
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
{ provide: TenantActivationService, useValue: tenantService as unknown as TenantActivationService },
],
});
client = TestBed.inject(VexEvidenceHttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('adds tenant, project, trace, and caching headers when listing statements', () => {
client
.listStatements({
tenantId: 'tenant-x',
projectId: 'proj-1',
traceId: 'trace-1',
ifNoneMatch: '"etag-1"',
vulnId: 'CVE-2024-12345',
status: 'not_affected',
limit: 10,
})
.subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/vex/statements' && r.params.get('vulnId') === 'CVE-2024-12345');
expect(req.request.method).toBe('GET');
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-x');
expect(req.request.headers.get('X-Stella-Project')).toBe('proj-1');
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-1');
expect(req.request.headers.get('X-Stella-Request-Id')).toBe('trace-1');
expect(req.request.headers.get('If-None-Match')).toBe('"etag-1"');
req.flush({ items: [], count: 0, continuationToken: null, etag: '"etag-2"', traceId: 'trace-1' });
});
it('maps 404 responses to ERR_AGG_NOT_FOUND', (done) => {
client.getStatement('missing', { traceId: 'trace-2' }).subscribe({
next: () => done.fail('expected error'),
error: (err: unknown) => {
expect(String(err)).toContain('ERR_AGG_NOT_FOUND');
done();
},
});
const req = httpMock.expectOne('/api/vex/statements/missing');
req.flush({ message: 'not found' }, { status: 404, statusText: 'Not Found' });
});
it('rejects export when scope authorization fails', (done) => {
tenantService.authorize.and.callFake((_resource: string, action: string) => action !== 'export');
client.exportStatement('stmt-1', 'json', { traceId: 'trace-3' }).subscribe({
next: () => done.fail('expected error'),
error: (err: unknown) => {
expect(String(err)).toContain('Unauthorized');
httpMock.expectNone('/api/vex/statements/stmt-1/export');
done();
},
});
});
});

View File

@@ -0,0 +1,283 @@
import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import {
AggregatorErrorCode,
VexEvidenceResponse,
VexExportResponse,
VexQueryOptions,
VexStatementDetail,
VexStatementsResponse,
VexStatementSummary,
} from './vex-evidence.models';
import { generateTraceId } from './trace.util';
export interface VexEvidenceApi {
listStatements(options?: VexQueryOptions): Observable<VexStatementsResponse>;
getStatement(statementId: string, options?: Pick<VexQueryOptions, 'tenantId' | 'projectId' | 'traceId' | 'ifNoneMatch'>): Observable<VexStatementDetail>;
getEvidence(statementId: string, options?: Pick<VexQueryOptions, 'tenantId' | 'projectId' | 'traceId' | 'ifNoneMatch'>): Observable<VexEvidenceResponse>;
exportStatement(
statementId: string,
format: VexExportResponse['format'],
options?: Pick<VexQueryOptions, 'tenantId' | 'projectId' | 'traceId'>
): Observable<VexExportResponse>;
}
export const VEX_EVIDENCE_API = new InjectionToken<VexEvidenceApi>('VEX_EVIDENCE_API');
export const VEX_EVIDENCE_API_BASE_URL = new InjectionToken<string>('VEX_EVIDENCE_API_BASE_URL');
@Injectable({ providedIn: 'root' })
export class VexEvidenceHttpClient implements VexEvidenceApi {
private static readonly MAX_PAGE_SIZE = 200;
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(VEX_EVIDENCE_API_BASE_URL) private readonly baseUrl: string
) {}
listStatements(options: VexQueryOptions = {}): Observable<VexStatementsResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('vex', 'read', ['vex:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing vex:read scope'));
}
if (options.limit && options.limit > VexEvidenceHttpClient.MAX_PAGE_SIZE) {
return throwError(() => new Error(`Invalid limit: max ${VexEvidenceHttpClient.MAX_PAGE_SIZE}`));
}
let params = new HttpParams();
if (options.vulnId) params = params.set('vulnId', options.vulnId);
if (options.status) params = params.set('status', options.status);
if (options.search) params = params.set('search', options.search);
if (options.limit) params = params.set('limit', String(options.limit));
if (options.continuationToken) params = params.set('continuationToken', options.continuationToken);
return this.http
.get<VexStatementsResponse>(`${this.baseUrl}/vex/statements`, {
params,
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
})
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
getStatement(
statementId: string,
options: Pick<VexQueryOptions, 'tenantId' | 'projectId' | 'traceId' | 'ifNoneMatch'> = {}
): Observable<VexStatementDetail> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('vex', 'read', ['vex:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing vex:read scope'));
}
return this.http
.get<VexStatementDetail>(`${this.baseUrl}/vex/statements/${encodeURIComponent(statementId)}`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
})
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
getEvidence(
statementId: string,
options: Pick<VexQueryOptions, 'tenantId' | 'projectId' | 'traceId' | 'ifNoneMatch'> = {}
): Observable<VexEvidenceResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('vex', 'read', ['vex:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing vex:read scope'));
}
return this.http
.get<VexEvidenceResponse>(`${this.baseUrl}/vex/statements/${encodeURIComponent(statementId)}/evidence`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
})
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
exportStatement(
statementId: string,
format: VexExportResponse['format'],
options: Pick<VexQueryOptions, 'tenantId' | 'projectId' | 'traceId'> = {}
): Observable<VexExportResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('vex', 'export', ['vex:export'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing vex:export scope'));
}
const params = new HttpParams().set('format', format);
return this.http
.get<VexExportResponse>(`${this.baseUrl}/vex/statements/${encodeURIComponent(statementId)}/export`, {
params,
headers: this.buildHeaders(tenant, traceId, options.projectId),
})
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('VexEvidenceHttpClient requires an active tenant identifier.');
}
return tenant;
}
private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders {
let headers = new HttpHeaders({
'Content-Type': 'application/json',
'X-StellaOps-Tenant': tenantId,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
});
if (projectId) {
headers = headers.set('X-Stella-Project', projectId);
}
if (ifNoneMatch) {
headers = headers.set('If-None-Match', ifNoneMatch);
}
return headers;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof HttpErrorResponse) {
const code = this.mapStatusToCode(err.status);
const message =
typeof err.error?.message === 'string'
? err.error.message
: typeof err.error === 'string'
? err.error
: err.message;
return new Error(`[${traceId}] ${code}: ${message}`);
}
if (err instanceof Error) {
return new Error(`[${traceId}] ERR_AGG_UNKNOWN: ${err.message}`);
}
return new Error(`[${traceId}] ERR_AGG_UNKNOWN: Unknown error`);
}
private mapStatusToCode(status: number): AggregatorErrorCode {
if (status === 400) return 'ERR_AGG_BAD_REQUEST';
if (status === 401 || status === 403) return 'ERR_AGG_UNAUTHORIZED';
if (status === 404) return 'ERR_AGG_NOT_FOUND';
if (status === 429) return 'ERR_AGG_RATE_LIMIT';
if (status >= 500) return 'ERR_AGG_UPSTREAM';
return 'ERR_AGG_UNKNOWN';
}
}
@Injectable({ providedIn: 'root' })
export class MockVexEvidenceClient implements VexEvidenceApi {
private readonly statements: VexStatementDetail[] = [
{
statementId: 'vex::tenant-default::CVE-2024-12345::001',
vulnId: 'CVE-2024-12345',
status: 'not_affected',
justification: 'Component not present in runtime image.',
productId: 'asset::registry.local/ops/auth',
issuer: 'vendor:example',
affectedPurls: ['pkg:npm/example@1.2.3'],
references: [{ label: 'Analysis report', url: 'https://example.invalid/vex/report/001' }],
updatedAt: '2025-12-10T00:00:00Z',
etag: '"vex-001-v1"',
},
];
listStatements(options: VexQueryOptions = {}): Observable<VexStatementsResponse> {
let items: VexStatementSummary[] = this.statements.map((s) => ({ ...s }));
if (options.vulnId) {
items = items.filter((s) => s.vulnId === options.vulnId);
}
if (options.status) {
items = items.filter((s) => s.status === options.status);
}
if (options.search) {
const needle = options.search.toLowerCase();
items = items.filter(
(s) => s.statementId.toLowerCase().includes(needle) || s.vulnId.toLowerCase().includes(needle)
);
}
items.sort((a, b) => a.statementId.localeCompare(b.statementId));
const limit = options.limit ?? items.length;
const offset = options.continuationToken ? Number.parseInt(options.continuationToken, 10) : 0;
const page = items.slice(offset, offset + limit);
const nextOffset = offset + page.length;
return of({
items: page,
count: page.length,
continuationToken: nextOffset < items.length ? String(nextOffset) : null,
etag: '"vex-mock-v1"',
traceId: options.traceId ?? generateTraceId(),
});
}
getStatement(statementId: string, options: Pick<VexQueryOptions, 'traceId'> = {}): Observable<VexStatementDetail> {
const found = this.statements.find((s) => s.statementId === statementId);
if (!found) {
return throwError(() => new Error(`Statement not found: ${statementId}`));
}
void options;
return of({ ...found });
}
getEvidence(statementId: string, options: Pick<VexQueryOptions, 'traceId'> = {}): Observable<VexEvidenceResponse> {
const found = this.statements.find((s) => s.statementId === statementId);
if (!found) {
return throwError(() => new Error(`Statement not found: ${statementId}`));
}
return of({
statementId,
items: [
{
evidenceId: 'evidence::001',
kind: 'document',
url: 'https://example.invalid/evidence/001.json?sig=mock',
sha256: 'sha256:0000000000000000000000000000000000000000000000000000000000000000',
expiresAt: '2025-12-31T23:59:59Z',
},
],
count: 1,
etag: `"${statementId}-evidence-v1"`,
traceId: options.traceId ?? generateTraceId(),
});
}
exportStatement(
statementId: string,
format: VexExportResponse['format'],
options: Pick<VexQueryOptions, 'traceId'> = {}
): Observable<VexExportResponse> {
return of({
statementId,
format,
url: `https://example.invalid/vex/export/${encodeURIComponent(statementId)}.${format}?sig=mock`,
sha256: 'sha256:1111111111111111111111111111111111111111111111111111111111111111',
dsseUrl: `https://example.invalid/vex/export/${encodeURIComponent(statementId)}.${format}.dsse?sig=mock`,
expiresAt: '2025-12-31T23:59:59Z',
traceId: options.traceId ?? generateTraceId(),
});
}
}

View File

@@ -0,0 +1,73 @@
export type VexStatus = 'affected' | 'not_affected' | 'fixed' | 'under_investigation' | 'unknown';
export type AggregatorErrorCode =
| 'ERR_AGG_BAD_REQUEST'
| 'ERR_AGG_UNAUTHORIZED'
| 'ERR_AGG_NOT_FOUND'
| 'ERR_AGG_RATE_LIMIT'
| 'ERR_AGG_UPSTREAM'
| 'ERR_AGG_UNKNOWN';
export interface VexStatementSummary {
readonly statementId: string;
readonly vulnId: string;
readonly productId?: string;
readonly status: VexStatus;
readonly justification?: string;
readonly updatedAt: string;
readonly etag?: string;
}
export interface VexStatementDetail extends VexStatementSummary {
readonly issuer?: string;
readonly affectedPurls?: readonly string[];
readonly references?: readonly { label: string; url: string }[];
}
export interface VexEvidenceRef {
readonly evidenceId: string;
readonly kind: 'document' | 'sbom' | 'attestation' | 'link' | 'log' | 'other';
readonly url: string;
readonly sha256?: string;
readonly dsseUrl?: string;
readonly expiresAt?: string;
}
export interface VexQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
readonly ifNoneMatch?: string;
readonly vulnId?: string;
readonly status?: VexStatus;
readonly search?: string;
readonly limit?: number;
readonly continuationToken?: string;
}
export interface VexStatementsResponse {
readonly items: readonly VexStatementSummary[];
readonly count: number;
readonly continuationToken: string | null;
readonly etag?: string;
readonly traceId?: string;
}
export interface VexEvidenceResponse {
readonly statementId: string;
readonly items: readonly VexEvidenceRef[];
readonly count: number;
readonly etag?: string;
readonly traceId?: string;
}
export interface VexExportResponse {
readonly statementId: string;
readonly format: 'json' | 'ndjson' | 'spdx' | 'cyclonedx';
readonly url: string;
readonly sha256?: string;
readonly dsseUrl?: string;
readonly expiresAt?: string;
readonly traceId?: string;
}

View File

@@ -166,7 +166,7 @@ export class VulnExportOrchestratorService implements VulnExportOrchestratorApi
readonly activeJobs = computed(() => Array.from(this._activeJobs().values()));
private get baseUrl(): string {
return this.config.apiBaseUrls.gateway;
return this.config.apiBaseUrls.gateway ?? this.config.apiBaseUrls.scanner;
}
startExport(request: VulnExportRequest, options?: ExportOrchestrationOptions): Observable<ExportJob> {

View File

@@ -2,10 +2,15 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/
import { TestBed } from '@angular/core/testing';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { VulnerabilityHttpClient, VULNERABILITY_API_BASE_URL } from './vulnerability-http.client';
import { VulnerabilitiesResponse } from './vulnerability.models';
class MockAuthSessionStore {
session(): any {
return null;
}
getActiveTenantId(): string | null {
return 'tenant-dev';
}
@@ -14,6 +19,10 @@ class MockAuthSessionStore {
describe('VulnerabilityHttpClient', () => {
let client: VulnerabilityHttpClient;
let httpMock: HttpTestingController;
const tenantServiceStub = {
authorize: () => true,
activeTenantId: () => null,
} as unknown as TenantActivationService;
beforeEach(() => {
TestBed.configureTestingModule({
@@ -22,6 +31,7 @@ describe('VulnerabilityHttpClient', () => {
VulnerabilityHttpClient,
{ provide: VULNERABILITY_API_BASE_URL, useValue: 'https://api.example.local' },
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
{ provide: TenantActivationService, useValue: tenantServiceStub },
],
});

View File

@@ -36,9 +36,9 @@ export interface VulnerabilityApi {
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse>;
}
export const VULNERABILITY_API = new InjectionToken<VulnerabilityApi>('VULNERABILITY_API');
const MOCK_VULNERABILITIES: Vulnerability[] = [
export const VULNERABILITY_API = new InjectionToken<VulnerabilityApi>('VULNERABILITY_API');
const MOCK_VULNERABILITIES: Vulnerability[] = [
{
vulnId: 'vuln-001',
cveId: 'CVE-2021-44228',
@@ -262,15 +262,42 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [
},
],
hasException: false,
},
];
@Injectable({ providedIn: 'root' })
export class MockVulnerabilityApiService implements VulnerabilityApi {
},
];
function deriveReachability(vuln: Vulnerability): { status: 'reachable' | 'unreachable' | 'unknown'; score: number } {
const purls = vuln.affectedComponents.map((c) => c.purl);
if (purls.some((p) => p.includes('jsonwebtoken'))) {
return { status: 'reachable', score: 0.88 };
}
if (purls.some((p) => p.includes('log4j-core'))) {
return { status: 'unreachable', score: 0.95 };
}
if (purls.some((p) => p.includes('golang.org/x/net') || p.includes('nghttp2'))) {
return { status: 'reachable', score: 0.72 };
}
return { status: 'unknown', score: 0.0 };
}
function withReachability(vuln: Vulnerability): Vulnerability {
const reachability = deriveReachability(vuln);
return {
...vuln,
reachabilityScore: reachability.score,
reachabilityStatus: reachability.status,
};
}
@Injectable({ providedIn: 'root' })
export class MockVulnerabilityApiService implements VulnerabilityApi {
private mockExports = new Map<string, VulnExportResponse>();
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
let items = [...MOCK_VULNERABILITIES];
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
let items = [...MOCK_VULNERABILITIES];
if (options?.severity && options.severity !== 'all') {
items = items.filter((v) => v.severity === options.severity);
@@ -284,44 +311,47 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
items = items.filter((v) => v.hasException === options.hasException);
}
if (options?.search) {
const search = options.search.toLowerCase();
items = items.filter(
(v) =>
v.cveId.toLowerCase().includes(search) ||
v.title.toLowerCase().includes(search) ||
v.description?.toLowerCase().includes(search)
);
}
const total = items.length;
const offset = options?.offset ?? 0;
const limit = options?.limit ?? 50;
items = items.slice(offset, offset + limit);
if (options?.search) {
const search = options.search.toLowerCase();
items = items.filter(
(v) =>
v.cveId.toLowerCase().includes(search) ||
v.title.toLowerCase().includes(search) ||
v.description?.toLowerCase().includes(search)
);
}
if (options?.reachability && options.reachability !== 'all') {
items = items.filter((v) => deriveReachability(v).status === options.reachability);
}
const total = items.length;
const offset = options?.offset ?? 0;
const limit = options?.limit ?? 50;
items = items.slice(offset, offset + limit);
const traceId = options?.traceId ?? `mock-trace-${Date.now()}`;
return of({
items,
total,
hasMore: offset + items.length < total,
etag: `"vuln-list-${Date.now()}"`,
traceId,
}).pipe(delay(200));
}
getVulnerability(vulnId: string, _options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<Vulnerability> {
const vuln = MOCK_VULNERABILITIES.find((v) => v.vulnId === vulnId);
if (!vuln) {
throw new Error(`Vulnerability ${vulnId} not found`);
}
return of({
...vuln,
etag: `"vuln-${vulnId}-${Date.now()}"`,
reachabilityScore: Math.random() * 0.5 + 0.5,
reachabilityStatus: 'reachable' as const,
}).pipe(delay(100));
}
return of({
items: options?.includeReachability ? items.map(withReachability) : items,
total,
hasMore: offset + items.length < total,
etag: `"vuln-list-${Date.now()}"`,
traceId,
}).pipe(delay(200));
}
getVulnerability(vulnId: string, _options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<Vulnerability> {
const vuln = MOCK_VULNERABILITIES.find((v) => v.vulnId === vulnId);
if (!vuln) {
throw new Error(`Vulnerability ${vulnId} not found`);
}
return of({
...withReachability(vuln),
etag: `"vuln-${vulnId}-etag"`,
}).pipe(delay(100));
}
getStats(_options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats> {
const vulns = MOCK_VULNERABILITIES;

View File

@@ -162,11 +162,11 @@ export const requirePolicyViewerGuard: CanMatchFn = requireScopesGuard(
);
/**
* Guard requiring policy:author and policy:edit scopes for policy authoring.
* Allows creating and editing policy drafts.
* Guard requiring policy:author scope for policy authoring.
* Allows creating and editing policy drafts (per Policy Studio RBAC contract).
*/
export const requirePolicyAuthorGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.POLICY_READ, StellaOpsScopes.POLICY_AUTHOR, StellaOpsScopes.POLICY_EDIT],
[StellaOpsScopes.POLICY_READ, StellaOpsScopes.POLICY_AUTHOR],
'/console/profile'
);
@@ -189,11 +189,11 @@ export const requirePolicyApproverGuard: CanMatchFn = requireScopesGuard(
);
/**
* Guard requiring policy:operate and policy:activate scopes for policy operations.
* Allows activating and running policies in environments.
* Guard requiring policy:operate scope for policy operations.
* Allows operating policies in environments.
*/
export const requirePolicyOperatorGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.POLICY_READ, StellaOpsScopes.POLICY_OPERATE, StellaOpsScopes.POLICY_ACTIVATE],
[StellaOpsScopes.POLICY_READ, StellaOpsScopes.POLICY_OPERATE],
'/console/profile'
);

View File

@@ -15,14 +15,22 @@ export {
MockAuthService,
} from './auth.service';
export {
requireAuthGuard,
requireScopesGuard,
requireAnyScopeGuard,
requireOrchViewerGuard,
requireOrchOperatorGuard,
requireOrchQuotaGuard,
} from './auth.guard';
export {
requireAuthGuard,
requireScopesGuard,
requireAnyScopeGuard,
requireOrchViewerGuard,
requireOrchOperatorGuard,
requireOrchQuotaGuard,
requirePolicyReviewOrApproveGuard,
requirePolicyViewerGuard,
requirePolicyAuthorGuard,
requirePolicyReviewerGuard,
requirePolicyApproverGuard,
requirePolicyOperatorGuard,
requirePolicySimulatorGuard,
requirePolicyAuditGuard,
} from './auth.guard';
export {
TenantActivationService,

View File

@@ -47,16 +47,23 @@ export const StellaOpsScopes = {
POLICY_PROMOTE: 'policy:promote', // Requires interactive auth
POLICY_AUDIT: 'policy:audit',
// Exception scopes
EXCEPTION_READ: 'exception:read',
EXCEPTION_WRITE: 'exception:write',
EXCEPTION_APPROVE: 'exception:approve',
// Release scopes
RELEASE_READ: 'release:read',
RELEASE_WRITE: 'release:write',
RELEASE_PUBLISH: 'release:publish',
RELEASE_BYPASS: 'release:bypass',
// Exception scopes
EXCEPTION_READ: 'exception:read',
EXCEPTION_WRITE: 'exception:write',
EXCEPTION_APPROVE: 'exception:approve',
// Advisory scopes
ADVISORY_READ: 'advisory:read',
// VEX scopes
VEX_READ: 'vex:read',
VEX_EXPORT: 'vex:export',
// Release scopes
RELEASE_READ: 'release:read',
RELEASE_WRITE: 'release:write',
RELEASE_PUBLISH: 'release:publish',
RELEASE_BYPASS: 'release:bypass',
// AOC scopes
AOC_READ: 'aoc:read',
@@ -148,15 +155,12 @@ export const ScopeGroups = {
StellaOpsScopes.UI_READ,
] as const,
POLICY_AUTHOR: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_AUTHOR,
StellaOpsScopes.POLICY_EDIT,
StellaOpsScopes.POLICY_WRITE,
StellaOpsScopes.POLICY_SUBMIT,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
POLICY_AUTHOR: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_AUTHOR,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
POLICY_REVIEWER: [
StellaOpsScopes.POLICY_READ,
@@ -173,33 +177,24 @@ export const ScopeGroups = {
StellaOpsScopes.UI_READ,
] as const,
POLICY_OPERATOR: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_OPERATE,
StellaOpsScopes.POLICY_ACTIVATE,
StellaOpsScopes.POLICY_RUN,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
POLICY_OPERATOR: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_OPERATE,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
POLICY_ADMIN: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_AUTHOR,
StellaOpsScopes.POLICY_EDIT,
StellaOpsScopes.POLICY_WRITE,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_SUBMIT,
StellaOpsScopes.POLICY_APPROVE,
StellaOpsScopes.POLICY_OPERATE,
StellaOpsScopes.POLICY_ACTIVATE,
StellaOpsScopes.POLICY_RUN,
StellaOpsScopes.POLICY_PUBLISH,
StellaOpsScopes.POLICY_PROMOTE,
StellaOpsScopes.POLICY_AUDIT,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
} as const;
POLICY_ADMIN: [
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_AUTHOR,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_APPROVE,
StellaOpsScopes.POLICY_OPERATE,
StellaOpsScopes.POLICY_AUDIT,
StellaOpsScopes.POLICY_SIMULATE,
StellaOpsScopes.UI_READ,
] as const,
} as const;
/**
* Human-readable labels for scopes.
@@ -232,13 +227,16 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
'policy:publish': 'Publish Policy Versions',
'policy:promote': 'Promote Between Environments',
'policy:audit': 'Audit Policy Activity',
'exception:read': 'View Exceptions',
'exception:write': 'Create Exceptions',
'exception:approve': 'Approve Exceptions',
'release:read': 'View Releases',
'release:write': 'Create Releases',
'release:publish': 'Publish Releases',
'release:bypass': 'Bypass Release Gates',
'exception:read': 'View Exceptions',
'exception:write': 'Create Exceptions',
'exception:approve': 'Approve Exceptions',
'advisory:read': 'View Advisories',
'vex:read': 'View VEX Evidence',
'vex:export': 'Export VEX Evidence',
'release:read': 'View Releases',
'release:write': 'Create Releases',
'release:publish': 'Publish Releases',
'release:bypass': 'Bypass Release Gates',
'aoc:read': 'View AOC Status',
'aoc:verify': 'Trigger AOC Verification',
// Orchestrator scope labels (UI-ORCH-32-001)

View File

@@ -8,27 +8,36 @@ import { AuthSessionStore } from './auth-session.store';
* Scope required for an operation.
*/
export type TenantScope =
| 'tenant:read'
| 'tenant:write'
| 'tenant:admin'
| 'project:read'
| 'project:write'
| 'project:admin'
| 'policy:read'
| 'policy:write'
| 'policy:activate'
| 'risk:read'
| 'risk:write'
| 'vuln:read'
| 'vuln:write'
| 'vuln:triage'
| 'export:read'
| 'export:write'
| 'audit:read'
| 'audit:write'
| 'user:read'
| 'user:write'
| 'user:admin';
| 'admin'
| 'ui.read'
| `${(
| 'tenant'
| 'project'
| 'policy'
| 'risk'
| 'vuln'
| 'export'
| 'audit'
| 'user'
| 'orch'
| 'graph'
| 'signals'
| 'advisory'
| 'advisory-ai'
| 'ledger'
| 'exception'
| 'aoc'
| 'sbom'
| 'attest'
| 'release'
| 'scanner'
| 'airgap'
| 'admin'
| 'console'
| 'evidence'
| 'timeline'
| 'vex'
)}:${string}`;
/**
* Decision result for an authorization check.

View File

@@ -1,7 +1,7 @@
import { Injectable, inject, signal, computed } from '@angular/core';
import { Subject } from 'rxjs';
import { TenantActivationService } from './tenant-activation.service';
import { TenantActivationService, TenantScope } from './tenant-activation.service';
import { AuthSessionStore } from './auth-session.store';
/**
@@ -135,7 +135,7 @@ export class TenantPersistenceService {
resourceProjectId?: string
): TenantPersistenceCheck {
const activeTenantId = this.tenantService.activeTenantId();
const activeProjectId = this.tenantService.activeProjectId();
const activeProjectId = this.tenantService.activeProjectId() ?? undefined;
// Must have active tenant context
if (!activeTenantId) {
@@ -280,7 +280,7 @@ export class TenantPersistenceService {
): PersistenceAuditMetadata {
const session = this.authStore.session();
const tenantId = this.tenantService.activeTenantId() ?? 'unknown';
const projectId = this.tenantService.activeProjectId();
const projectId = this.tenantService.activeProjectId() ?? undefined;
return {
tenantId,
@@ -352,9 +352,9 @@ export class TenantPersistenceService {
.replace(/^-|-$/g, '');
}
private getRequiredWriteScope(resourceType: string): string {
private getRequiredWriteScope(resourceType: string): TenantScope {
// Map resource types to required write scopes
const scopeMap: Record<string, string> = {
const scopeMap: Record<string, TenantScope> = {
policy: 'policy:write',
risk: 'risk:write',
vulnerability: 'vuln:write',
@@ -365,7 +365,7 @@ export class TenantPersistenceService {
export: 'export:write',
};
return scopeMap[resourceType.toLowerCase()] ?? `${resourceType.toLowerCase()}:write`;
return scopeMap[resourceType.toLowerCase()] ?? (`${resourceType.toLowerCase()}:write` as TenantScope);
}
private emitAuditEvent(params: {

View File

@@ -24,12 +24,20 @@ export interface AuthorityConfig {
readonly refreshLeewaySeconds?: number;
}
export interface ApiBaseUrlConfig {
readonly scanner: string;
readonly policy: string;
readonly concelier: string;
readonly excitor?: string;
readonly attestor: string;
export interface ApiBaseUrlConfig {
/**
* Optional API gateway base URL for cross-cutting endpoints.
* When omitted, clients should fall back to module-specific base URLs (e.g. `scanner`).
*/
readonly gateway?: string;
readonly ledger?: string;
readonly vex?: string;
readonly signals?: string;
readonly scanner: string;
readonly policy: string;
readonly concelier: string;
readonly excitor?: string;
readonly attestor: string;
readonly authority: string;
readonly notify?: string;
readonly scheduler?: string;

View File

@@ -1,11 +1,11 @@
import { HttpClient } from '@angular/common/http';
import {
Inject,
Injectable,
Optional,
computed,
signal,
} from '@angular/core';
import { HttpBackend, HttpClient } from '@angular/common/http';
import {
Inject,
Injectable,
Optional,
computed,
signal,
} from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
@@ -24,16 +24,21 @@ const DEFAULT_QUICKSTART = false;
providedIn: 'root',
})
export class AppConfigService {
private readonly configSignal = signal<AppConfig | null>(null);
private readonly authoritySignal = computed<AuthorityConfig | null>(() => {
const config = this.configSignal();
return config?.authority ?? null;
});
constructor(
private readonly http: HttpClient,
@Optional() @Inject(APP_CONFIG) private readonly staticConfig: AppConfig | null
) {}
private readonly http: HttpClient;
private readonly configSignal = signal<AppConfig | null>(null);
private readonly authoritySignal = computed<AuthorityConfig | null>(() => {
const config = this.configSignal();
return config?.authority ?? null;
});
constructor(
httpBackend: HttpBackend,
@Optional() @Inject(APP_CONFIG) private readonly staticConfig: AppConfig | null
) {
// Use a raw HttpClient instance to avoid DI cycles with HTTP interceptors
// that themselves depend on AppConfigService.
this.http = new HttpClient(httpBackend);
}
/**
* Loads application configuration either from the injected static value or via HTTP fetch.

View File

@@ -30,10 +30,10 @@
<h2>Policy Studio roles & scopes</h2>
</header>
<ul>
<li><strong>Author</strong>: policy:read, policy:author, policy:edit, policy:submit, policy:simulate</li>
<li><strong>Author</strong>: policy:read, policy:author, policy:simulate</li>
<li><strong>Reviewer</strong>: policy:read, policy:review, policy:simulate</li>
<li><strong>Approver</strong>: policy:read, policy:review, policy:approve, policy:simulate</li>
<li><strong>Operator</strong>: policy:read, policy:operate, policy:activate, policy:run, policy:simulate</li>
<li><strong>Approver</strong>: policy:read, policy:approve, policy:simulate</li>
<li><strong>Operator</strong>: policy:read, policy:operate, policy:simulate</li>
<li><strong>Audit</strong>: policy:read, policy:audit</li>
<li><strong>Admin</strong>: policy:author/review/approve/operate/audit/simulate/read (or admin)</li>
</ul>
@@ -41,7 +41,7 @@
Use this list to verify your token covers the flows you need (editor, simulate, approvals, dashboard, audit exports).
</p>
<p class="console-profile__hint">
For Cypress/e2e, load stub sessions from <code>testing/auth-fixtures.ts</code> (author/reviewer/approver/operator/audit) and seed <code>AuthSessionStore</code> before navigating.
For e2e, load stub sessions from <code>testing/auth-fixtures.ts</code> (author/reviewer/approver/operator/audit) and seed <code>AuthSessionStore</code> before navigating.
</p>
</section>

View File

@@ -39,7 +39,7 @@
<h3>Run Stream</h3>
<label>
Run ID
<input type="text" [value]="runId()" (input)="runId.set(($event.target as HTMLInputElement).value)" (change)="startRunStream()" />
<input type="text" [value]="runId()" (input)="runId.set($any($event.target).value)" (change)="startRunStream()" />
</label>
</header>
<div class="events">

View File

@@ -20,27 +20,27 @@
<div class="draft-inline__vulns" *ngIf="context.vulnIds?.length">
<span class="draft-inline__vulns-label">Vulnerabilities:</span>
<div class="draft-inline__vulns-list">
<span class="vuln-chip" *ngFor="let vulnId of context.vulnIds | slice:0:5">
{{ vulnId }}
</span>
<span class="vuln-chip vuln-chip--more" *ngIf="context.vulnIds.length > 5">
+{{ context.vulnIds.length - 5 }} more
</span>
</div>
</div>
<span class="vuln-chip" *ngFor="let vulnId of context.vulnIds | slice:0:5">
{{ vulnId }}
</span>
<span class="vuln-chip vuln-chip--more" *ngIf="(context.vulnIds?.length ?? 0) > 5">
+{{ (context.vulnIds?.length ?? 0) - 5 }} more
</span>
</div>
</div>
<!-- Components Preview -->
<div class="draft-inline__components" *ngIf="context.componentPurls?.length">
<span class="draft-inline__components-label">Components:</span>
<div class="draft-inline__components-list">
<span class="component-chip" *ngFor="let purl of context.componentPurls | slice:0:3">
{{ purl | slice:-40 }}
</span>
<span class="component-chip component-chip--more" *ngIf="context.componentPurls.length > 3">
+{{ context.componentPurls.length - 3 }} more
</span>
</div>
</div>
<span class="component-chip" *ngFor="let purl of context.componentPurls | slice:0:3">
{{ purl | slice:-40 }}
</span>
<span class="component-chip component-chip--more" *ngIf="(context.componentPurls?.length ?? 0) > 3">
+{{ (context.componentPurls?.length ?? 0) - 3 }} more
</span>
</div>
</div>
<form [formGroup]="draftForm" class="draft-inline__form">
<!-- Name -->

View File

@@ -17,16 +17,15 @@ import {
} from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import {
EXCEPTION_API,
ExceptionApi,
MockExceptionApiService,
} from '../../core/api/exception.client';
import {
Exception,
ExceptionScope,
ExceptionSeverity,
} from '../../core/api/exception.models';
import {
EXCEPTION_API,
ExceptionApi,
} from '../../core/api/exception.client';
import {
Exception,
ExceptionSeverity,
ExceptionScopeType,
} from '../../core/api/exception.contract.models';
export interface ExceptionDraftContext {
readonly vulnIds?: readonly string[];
@@ -57,14 +56,11 @@ const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] =
selector: 'app-exception-draft-inline',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './exception-draft-inline.component.html',
styleUrls: ['./exception-draft-inline.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: EXCEPTION_API, useClass: MockExceptionApiService },
],
})
export class ExceptionDraftInlineComponent implements OnInit {
templateUrl: './exception-draft-inline.component.html',
styleUrls: ['./exception-draft-inline.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionDraftInlineComponent implements OnInit {
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
private readonly formBuilder = inject(NonNullableFormBuilder);
@@ -92,12 +88,12 @@ export class ExceptionDraftInlineComponent implements OnInit {
timeboxDays: this.formBuilder.control(30),
});
readonly scopeType = computed<ExceptionScope>(() => {
if (this.context?.componentPurls?.length) return 'component';
if (this.context?.assetIds?.length) return 'asset';
if (this.context?.tenantId) return 'tenant';
return 'global';
});
readonly scopeType = computed<ExceptionScopeType>(() => {
if (this.context?.componentPurls?.length) return 'component';
if (this.context?.assetIds?.length) return 'asset';
if (this.context?.tenantId) return 'tenant';
return 'global';
});
readonly scopeSummary = computed(() => {
const ctx = this.context;

View File

@@ -16,6 +16,8 @@ import {
signal,
} from '@angular/core';
import type { GraphOverlayState, ReachabilityOverlayData } from './graph-overlays.component';
export interface CanvasNode {
readonly id: string;
readonly type: 'asset' | 'component' | 'vulnerability';
@@ -229,7 +231,7 @@ const VIEWPORT_PADDING = 100;
@for (node of visibleNodes(); track node.id) {
<g
class="node-group"
[class.node-group--selected]="selectedNodeId() === node.id"
[class.node-group--selected]="selectedNodeId === node.id"
[class.node-group--highlighted]="isNodeHighlighted(node)"
[class.node-group--excepted]="node.hasException"
[attr.transform]="'translate(' + node.x + ',' + node.y + ')'"
@@ -240,6 +242,24 @@ const VIEWPORT_PADDING = 100;
role="button"
[attr.aria-label]="getNodeAriaLabel(node)"
>
@if (getReachabilityData(node.id); as reach) {
<rect
class="reachability-halo"
x="-6"
y="-6"
[attr.width]="node.width + 12"
[attr.height]="node.height + 12"
rx="12"
fill="none"
[attr.stroke]="getReachabilityHaloStroke(reach.status)"
stroke-width="3"
stroke-dasharray="5 4"
opacity="0.85"
>
<title>{{ reach.status }} ({{ (reach.confidence * 100).toFixed(0) }}%) · {{ reach.observedAt }}</title>
</rect>
}
<!-- Node background -->
<rect
class="node-bg"
@@ -585,6 +605,10 @@ const VIEWPORT_PADDING = 100;
transition: filter 0.15s ease, stroke 0.15s ease;
}
.reachability-halo {
pointer-events: none;
}
.node-name {
fill: #1e293b;
pointer-events: none;
@@ -653,6 +677,7 @@ export class GraphCanvasComponent implements OnChanges, AfterViewInit, OnDestroy
@Input() nodes: CanvasNode[] = [];
@Input() edges: CanvasEdge[] = [];
@Input() selectedNodeId: string | null = null;
@Input() overlayState: GraphOverlayState | null = null;
@Output() nodeSelected = new EventEmitter<string>();
@Output() canvasClicked = new EventEmitter<void>();
@@ -1153,6 +1178,22 @@ export class GraphCanvasComponent implements OnChanges, AfterViewInit, OnDestroy
}
}
getReachabilityData(nodeId: string): ReachabilityOverlayData | null {
if (!this.overlayState) return null;
return this.overlayState.reachability.get(nodeId) ?? null;
}
getReachabilityHaloStroke(status: ReachabilityOverlayData['status']): string {
switch (status) {
case 'reachable':
return '#22c55e';
case 'unreachable':
return '#94a3b8';
default:
return '#f59e0b';
}
}
getMinimapNodeColor(node: LayoutNode): string {
switch (node.type) {
case 'asset': return '#3b82f6';

View File

@@ -108,14 +108,15 @@
<!-- Canvas View -->
<div class="canvas-view" *ngIf="viewMode() === 'canvas'">
<div class="canvas-view__main">
<app-graph-canvas
[nodes]="canvasNodes()"
[edges]="canvasEdges()"
[selectedNodeId]="selectedNodeId()"
(nodeSelected)="selectNode($event)"
(canvasClicked)="clearSelection()"
></app-graph-canvas>
</div>
<app-graph-canvas
[nodes]="canvasNodes()"
[edges]="canvasEdges()"
[selectedNodeId]="selectedNodeId()"
[overlayState]="overlayState()"
(nodeSelected)="selectNode($event)"
(canvasClicked)="clearSelection()"
></app-graph-canvas>
</div>
<div class="canvas-view__sidebar">
<app-graph-overlays
[nodeIds]="nodeIds()"

View File

@@ -11,7 +11,7 @@ import {
signal,
} from '@angular/core';
export type OverlayType = 'policy' | 'evidence' | 'license' | 'exposure';
export type OverlayType = 'policy' | 'evidence' | 'license' | 'exposure' | 'reachability';
export interface OverlayConfig {
type: OverlayType;
@@ -53,24 +53,47 @@ export interface ExposureOverlayData {
riskScore?: number;
}
export interface ReachabilityOverlayData {
nodeId: string;
status: 'reachable' | 'unreachable' | 'unknown';
confidence: number;
observedAt: string;
}
export interface GraphOverlayState {
policy: Map<string, PolicyOverlayData>;
evidence: Map<string, EvidenceOverlayData>;
license: Map<string, LicenseOverlayData>;
exposure: Map<string, ExposureOverlayData>;
reachability: Map<string, ReachabilityOverlayData>;
}
// Mock overlay data generators
function stableHash(input: string): number {
let hash = 2166136261;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function fraction(hash: number): number {
return (hash % 1000) / 999;
}
function generateMockPolicyData(nodeIds: string[]): Map<string, PolicyOverlayData> {
const data = new Map<string, PolicyOverlayData>();
const statuses: PolicyOverlayData['policyStatus'][] = ['pass', 'warn', 'block', 'unknown'];
for (const nodeId of nodeIds) {
const status = statuses[Math.floor(Math.random() * statuses.length)];
const hash = stableHash(`policy:${nodeId}`);
const status = statuses[hash % statuses.length];
const policyNumber = hash % 100;
data.set(nodeId, {
nodeId,
policyStatus: status,
policyName: status === 'pass' ? undefined : `policy-${Math.floor(Math.random() * 100)}`,
policyName: status === 'pass' ? undefined : `policy-${policyNumber}`,
violations: status === 'block' ? ['Vulnerable dependency detected', 'Missing attestation'] : undefined,
gateBlocked: status === 'block',
});
@@ -83,12 +106,13 @@ function generateMockEvidenceData(nodeIds: string[]): Map<string, EvidenceOverla
const types: EvidenceOverlayData['evidenceType'][] = ['sbom', 'attestation', 'signature', 'provenance'];
for (const nodeId of nodeIds) {
const hasEvidence = Math.random() > 0.3;
const hash = stableHash(`evidence:${nodeId}`);
const hasEvidence = (hash % 10) >= 3;
data.set(nodeId, {
nodeId,
hasEvidence,
evidenceType: hasEvidence ? types[Math.floor(Math.random() * types.length)] : undefined,
confidence: hasEvidence ? Math.floor(Math.random() * 40) + 60 : undefined,
evidenceType: hasEvidence ? types[hash % types.length] : undefined,
confidence: hasEvidence ? Math.round(60 + fraction(hash) * 40) : undefined,
sources: hasEvidence ? ['scanner', 'registry'] : undefined,
});
}
@@ -98,13 +122,13 @@ function generateMockEvidenceData(nodeIds: string[]): Map<string, EvidenceOverla
function generateMockLicenseData(nodeIds: string[]): Map<string, LicenseOverlayData> {
const data = new Map<string, LicenseOverlayData>();
const licenses = ['MIT', 'Apache-2.0', 'GPL-3.0', 'BSD-3-Clause', 'LGPL-2.1', 'Proprietary'];
const families: LicenseOverlayData['licenseFamily'][] = ['permissive', 'copyleft', 'proprietary', 'unknown'];
for (const nodeId of nodeIds) {
const license = licenses[Math.floor(Math.random() * licenses.length)];
const hash = stableHash(`license:${nodeId}`);
const license = licenses[hash % licenses.length];
const family = license.includes('GPL') ? 'copyleft' :
license === 'Proprietary' ? 'proprietary' : 'permissive';
const compatible = family !== 'copyleft' || Math.random() > 0.5;
const compatible = family !== 'copyleft' || (hash % 2) === 0;
data.set(nodeId, {
nodeId,
@@ -122,19 +146,60 @@ function generateMockExposureData(nodeIds: string[]): Map<string, ExposureOverla
const levels: ExposureOverlayData['exposureLevel'][] = ['internet', 'internal', 'isolated', 'unknown'];
for (const nodeId of nodeIds) {
const level = levels[Math.floor(Math.random() * levels.length)];
const hash = stableHash(`exposure:${nodeId}`);
const level = levels[hash % levels.length];
data.set(nodeId, {
nodeId,
exposureLevel: level,
reachable: level === 'internet' || level === 'internal',
attackPaths: level === 'internet' ? Math.floor(Math.random() * 5) + 1 : 0,
riskScore: level === 'internet' ? Math.floor(Math.random() * 40) + 60 :
level === 'internal' ? Math.floor(Math.random() * 30) + 20 : 0,
attackPaths: level === 'internet' ? 1 + (hash % 5) : 0,
riskScore: level === 'internet' ? Math.round(60 + fraction(hash) * 40) :
level === 'internal' ? Math.round(20 + fraction(hash) * 30) : 0,
});
}
return data;
}
function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<string, ReachabilityOverlayData> {
const data = new Map<string, ReachabilityOverlayData>();
const snapshotDays: Record<string, number> = { current: 0, '1d': 1, '7d': 7, '30d': 30 };
const days = snapshotDays[snapshot] ?? 0;
const base = Date.parse('2025-12-12T00:00:00Z');
const observedAt = new Date(base - days * 24 * 60 * 60 * 1000).toISOString();
for (const nodeId of nodeIds) {
const normalized = nodeId.toLowerCase();
let status: ReachabilityOverlayData['status'] = 'unknown';
let confidence = 0.0;
if (normalized.includes('log4j') || normalized.includes('log4shell')) {
status = 'unreachable';
confidence = 0.95;
} else if (
normalized.includes('curl') ||
normalized.includes('nghttp2') ||
normalized.includes('golang') ||
normalized.includes('jwt') ||
normalized.includes('jsonwebtoken')
) {
status = 'reachable';
confidence = 0.88;
} else if (normalized.includes('spring')) {
status = 'reachable';
confidence = 0.6;
}
data.set(nodeId, {
nodeId,
status,
confidence,
observedAt,
});
}
return data;
}
@Component({
selector: 'app-graph-overlays',
standalone: true,
@@ -263,6 +328,26 @@ function generateMockExposureData(nodeIds: string[]): Map<string, ExposureOverla
</div>
</div>
}
@if (isOverlayEnabled('reachability')) {
<div class="legend-section">
<h4 class="legend-section__title">Reachability</h4>
<div class="legend-items">
<div class="legend-item">
<span class="legend-dot legend-dot--reachability-reachable"></span>
<span>Reachable</span>
</div>
<div class="legend-item">
<span class="legend-dot legend-dot--reachability-unreachable"></span>
<span>Unreachable</span>
</div>
<div class="legend-item">
<span class="legend-dot legend-dot--reachability-unknown"></span>
<span>Unknown</span>
</div>
</div>
</div>
}
</div>
}
@@ -363,6 +448,29 @@ function generateMockExposureData(nodeIds: string[]): Map<string, ExposureOverla
}
</div>
}
@if (isOverlayEnabled('reachability') && getReachabilityData(selectedNodeId)) {
<div class="overlay-detail-card">
<div class="overlay-detail-card__header">
<span class="overlay-detail-card__icon">◎</span>
<span class="overlay-detail-card__label">Reachability</span>
<span
class="status-badge"
[class.status-badge--pass]="getReachabilityData(selectedNodeId)!.status === 'reachable'"
[class.status-badge--block]="getReachabilityData(selectedNodeId)!.status === 'unreachable'"
[class.status-badge--unknown]="getReachabilityData(selectedNodeId)!.status === 'unknown'"
>
{{ getReachabilityData(selectedNodeId)!.status }}
</span>
</div>
<div class="overlay-detail-card__info">
Confidence: {{ (getReachabilityData(selectedNodeId)!.confidence * 100).toFixed(0) }}%
</div>
<div class="overlay-detail-card__info">
Observed: {{ getReachabilityData(selectedNodeId)!.observedAt }}
</div>
</div>
}
</div>
}
@@ -419,15 +527,27 @@ function generateMockExposureData(nodeIds: string[]): Map<string, ExposureOverla
<button
type="button"
class="time-travel-btn"
[class.time-travel-btn--active]="timeTravelEnabled()"
[class.time-travel-btn--active]="timeTravelEnabled() || isOverlayEnabled('reachability')"
(click)="toggleTimeTravel()"
aria-label="Toggle time travel view"
>
<span class="time-travel-btn__icon">⏱️</span>
<span>Time Travel</span>
</button>
@if (timeTravelEnabled()) {
@if (timeTravelEnabled() || isOverlayEnabled('reachability')) {
<div class="time-travel-options">
<div class="time-slider">
<input
type="range"
min="0"
max="3"
step="1"
[value]="snapshotIndex()"
(input)="setSnapshotByIndex($any($event.target).valueAsNumber)"
aria-label="Snapshot time slider"
/>
<span class="time-slider__label">{{ snapshotLabel() }}</span>
</div>
<select
class="time-travel-select"
[value]="selectedSnapshot()"
@@ -438,14 +558,16 @@ function generateMockExposureData(nodeIds: string[]): Map<string, ExposureOverla
<option value="7d">7 days ago</option>
<option value="30d">30 days ago</option>
</select>
<button
type="button"
class="diff-btn"
(click)="showDiff()"
[disabled]="selectedSnapshot() === 'current'"
>
Show Diff
</button>
@if (timeTravelEnabled()) {
<button
type="button"
class="diff-btn"
(click)="showDiff()"
[disabled]="selectedSnapshot() === 'current'"
>
Show Diff
</button>
}
</div>
}
</div>
@@ -614,6 +736,10 @@ function generateMockExposureData(nodeIds: string[]): Map<string, ExposureOverla
&--exposure-internet { background: #ef4444; }
&--exposure-internal { background: #f59e0b; }
&--exposure-isolated { background: #22c55e; }
&--reachability-reachable { background: #22c55e; }
&--reachability-unreachable { background: #94a3b8; }
&--reachability-unknown { background: #f59e0b; }
}
/* Overlay details */
@@ -820,6 +946,22 @@ function generateMockExposureData(nodeIds: string[]): Map<string, ExposureOverla
border-radius: 0.375rem;
}
.time-slider {
display: flex;
align-items: center;
gap: 0.5rem;
}
.time-slider input[type='range'] {
width: 120px;
}
.time-slider__label {
font-size: 0.75rem;
color: #64748b;
white-space: nowrap;
}
.time-travel-select {
padding: 0.375rem 0.5rem;
border: 1px solid #e2e8f0;
@@ -885,6 +1027,7 @@ export class GraphOverlaysComponent implements OnChanges {
{ type: 'evidence', enabled: false, label: 'Evidence', icon: '🔍', color: '#0ea5e9' },
{ type: 'license', enabled: false, label: 'License', icon: '📜', color: '#22c55e' },
{ type: 'exposure', enabled: false, label: 'Exposure', icon: '🌐', color: '#ef4444' },
{ type: 'reachability', enabled: false, label: 'Reachability', icon: '◎', color: '#22c55e' },
]);
// Overlay data
@@ -893,6 +1036,7 @@ export class GraphOverlaysComponent implements OnChanges {
evidence: new Map(),
license: new Map(),
exposure: new Map(),
reachability: new Map(),
});
// Mode toggles
@@ -901,6 +1045,25 @@ export class GraphOverlaysComponent implements OnChanges {
readonly pathType = signal<'shortest' | 'attack' | 'dependency'>('shortest');
readonly timeTravelEnabled = signal(false);
readonly selectedSnapshot = signal<string>('current');
private readonly snapshotOrder = ['current', '1d', '7d', '30d'] as const;
readonly snapshotIndex = computed(() => {
const idx = this.snapshotOrder.indexOf(this.selectedSnapshot() as (typeof this.snapshotOrder)[number]);
return idx === -1 ? 0 : idx;
});
readonly snapshotLabel = computed(() => {
switch (this.selectedSnapshot()) {
case '1d':
return '1 day ago';
case '7d':
return '7 days ago';
case '30d':
return '30 days ago';
default:
return 'Current';
}
});
// Computed
readonly hasActiveOverlays = computed(() =>
@@ -910,6 +1073,7 @@ export class GraphOverlaysComponent implements OnChanges {
ngOnChanges(changes: SimpleChanges): void {
if (changes['nodeIds']) {
this.regenerateOverlayData();
this.overlayStateChange.emit(this.overlayState());
}
}
@@ -920,9 +1084,11 @@ export class GraphOverlaysComponent implements OnChanges {
);
this.overlayConfigs.set(updated);
// Regenerate data for newly enabled overlay
if (updated.find(c => c.type === type)?.enabled) {
const enabled = updated.find(c => c.type === type)?.enabled ?? false;
if (enabled) {
this.regenerateOverlayDataForType(type);
} else {
this.clearOverlayDataForType(type);
}
this.overlayStateChange.emit(this.overlayState());
@@ -967,6 +1133,16 @@ export class GraphOverlaysComponent implements OnChanges {
enabled: this.timeTravelEnabled(),
snapshot,
});
if (this.isOverlayEnabled('reachability')) {
this.regenerateOverlayDataForType('reachability');
this.overlayStateChange.emit(this.overlayState());
}
}
setSnapshotByIndex(index: number): void {
const clamped = Math.max(0, Math.min(index, this.snapshotOrder.length - 1));
this.setSnapshot(this.snapshotOrder[clamped]);
}
showDiff(): void {
@@ -990,13 +1166,21 @@ export class GraphOverlaysComponent implements OnChanges {
return this.overlayState().exposure.get(nodeId);
}
getReachabilityData(nodeId: string): ReachabilityOverlayData | undefined {
return this.overlayState().reachability.get(nodeId);
}
private regenerateOverlayData(): void {
const nodeIds = this.nodeIds;
const enabled = new Set(this.overlayConfigs().filter((c) => c.enabled).map((c) => c.type));
const state: GraphOverlayState = {
policy: generateMockPolicyData(nodeIds),
evidence: generateMockEvidenceData(nodeIds),
license: generateMockLicenseData(nodeIds),
exposure: generateMockExposureData(nodeIds),
policy: enabled.has('policy') ? generateMockPolicyData(nodeIds) : new Map(),
evidence: enabled.has('evidence') ? generateMockEvidenceData(nodeIds) : new Map(),
license: enabled.has('license') ? generateMockLicenseData(nodeIds) : new Map(),
exposure: enabled.has('exposure') ? generateMockExposureData(nodeIds) : new Map(),
reachability: enabled.has('reachability')
? generateMockReachabilityData(nodeIds, this.selectedSnapshot())
: new Map(),
};
this.overlayState.set(state);
}
@@ -1018,6 +1202,31 @@ export class GraphOverlaysComponent implements OnChanges {
case 'exposure':
this.overlayState.set({ ...current, exposure: generateMockExposureData(nodeIds) });
break;
case 'reachability':
this.overlayState.set({ ...current, reachability: generateMockReachabilityData(nodeIds, this.selectedSnapshot()) });
break;
}
}
private clearOverlayDataForType(type: OverlayType): void {
const current = this.overlayState();
switch (type) {
case 'policy':
this.overlayState.set({ ...current, policy: new Map() });
break;
case 'evidence':
this.overlayState.set({ ...current, evidence: new Map() });
break;
case 'license':
this.overlayState.set({ ...current, license: new Map() });
break;
case 'exposure':
this.overlayState.set({ ...current, exposure: new Map() });
break;
case 'reachability':
this.overlayState.set({ ...current, reachability: new Map() });
break;
}
}
}

View File

@@ -11,6 +11,7 @@ describe('PolicyWorkspaceComponent', () => {
let fixture: ComponentFixture<PolicyWorkspaceComponent>;
let component: PolicyWorkspaceComponent;
let store: jasmine.SpyObj<PolicyPackStore>;
let authStub: AuthService;
beforeEach(async () => {
store = jasmine.createSpyObj<PolicyPackStore>('PolicyPackStore', ['getPacks']);
@@ -43,11 +44,19 @@ describe('PolicyWorkspaceComponent', () => {
])
);
authStub = {
canViewPolicies: () => true,
canAuthorPolicies: () => true,
canSimulatePolicies: () => true,
canReviewPolicies: () => true,
canApprovePolicies: () => false,
} as AuthService;
await TestBed.configureTestingModule({
imports: [CommonModule, RouterLink, PolicyWorkspaceComponent],
providers: [
{ provide: PolicyPackStore, useValue: store },
{ provide: AUTH_SERVICE, useValue: { canViewPolicies: () => true, canAuthorPolicies: () => true, canSimulatePolicies: () => true, canReviewPolicies: () => true } as AuthService },
{ provide: AUTH_SERVICE, useValue: authStub },
{
provide: ActivatedRoute,
useValue: {
@@ -67,4 +76,20 @@ describe('PolicyWorkspaceComponent', () => {
expect(component['packs'][0].id).toBe('pack-b');
expect(component['packs'][1].id).toBe('pack-a');
}));
it('enables approvals when approve scope present', fakeAsync(() => {
authStub.canAuthorPolicies = () => false;
authStub.canSimulatePolicies = () => false;
authStub.canReviewPolicies = () => false;
authStub.canApprovePolicies = () => true;
const localFixture = TestBed.createComponent(PolicyWorkspaceComponent);
localFixture.detectChanges();
tick();
const el = localFixture.nativeElement as HTMLElement;
const approvals = Array.from(el.querySelectorAll('a')).find((a) => a.textContent?.trim() === 'Approvals');
expect(approvals).toBeTruthy();
expect(approvals!.classList.contains('action-disabled')).toBeFalse();
}));
});

View File

@@ -62,9 +62,9 @@ import { PolicyPackStore } from '../services/policy-pack.store';
</a>
<a
[routerLink]="['/policy-studio/packs', pack.id, 'approvals']"
[class.action-disabled]="!canReview"
[attr.aria-disabled]="!canReview"
[title]="canReview ? '' : 'Requires policy:review scope'"
[class.action-disabled]="!canReviewOrApprove"
[attr.aria-disabled]="!canReviewOrApprove"
[title]="canReviewOrApprove ? '' : 'Requires policy:review or policy:approve scope'"
>
Approvals
</a>
@@ -136,6 +136,7 @@ export class PolicyWorkspaceComponent {
protected canApprove = false;
protected canOperate = false;
protected canAudit = false;
protected canReviewOrApprove = false;
protected canView = false;
protected scopeHint = '';
protected refreshing = false;
@@ -171,6 +172,7 @@ export class PolicyWorkspaceComponent {
this.canReview = this.auth.canReviewPolicies?.() ?? false;
this.canView = this.auth.canViewPolicies?.() ?? false;
this.canApprove = this.auth.canApprovePolicies?.() ?? false;
this.canReviewOrApprove = this.canReview || this.canApprove;
this.canOperate = this.auth.canOperatePolicies?.() ?? false;
this.canAudit = this.auth.canAuditPolicies?.() ?? false;
const missing: string[] = [];

View File

@@ -0,0 +1,29 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReachabilityCenterComponent } from './reachability-center.component';
describe('ReachabilityCenterComponent', () => {
let fixture: ComponentFixture<ReachabilityCenterComponent>;
let component: ReachabilityCenterComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReachabilityCenterComponent],
}).compileComponents();
fixture = TestBed.createComponent(ReachabilityCenterComponent);
component = fixture.componentInstance;
});
it('computes summary counts deterministically', () => {
expect(component.okCount()).toBe(1);
expect(component.staleCount()).toBe(1);
expect(component.missingCount()).toBe(1);
});
it('filters rows by status', () => {
component.setStatusFilter('stale');
expect(component.filteredRows().map((r) => r.assetId)).toEqual(['asset-api-prod']);
});
});

View File

@@ -0,0 +1,329 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
type CoverageStatus = 'ok' | 'stale' | 'missing';
interface ReachabilityCoverageRow {
readonly assetId: string;
readonly coveragePercent: number;
readonly sensorsOnline: number;
readonly sensorsExpected: number;
readonly lastFactAt: string | null;
readonly status: CoverageStatus;
}
const FIXTURE_ROWS: ReachabilityCoverageRow[] = [
{
assetId: 'asset-api-prod',
coveragePercent: 75,
sensorsOnline: 2,
sensorsExpected: 3,
lastFactAt: '2025-12-01T06:10:00Z',
status: 'stale',
},
{
assetId: 'asset-web-prod',
coveragePercent: 92,
sensorsOnline: 3,
sensorsExpected: 3,
lastFactAt: '2025-12-11T09:20:00Z',
status: 'ok',
},
{
assetId: 'asset-worker-prod',
coveragePercent: 40,
sensorsOnline: 0,
sensorsExpected: 2,
lastFactAt: null,
status: 'missing',
},
];
@Component({
selector: 'app-reachability-center',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="reachability">
<header class="reachability__header">
<div>
<p class="reachability__eyebrow">Signals · Reachability</p>
<h1>Reachability Center</h1>
<p class="reachability__subtitle">
Coverage-first view: what we observe, what is missing, and what is stale.
</p>
</div>
<button type="button" class="btn" (click)="reset()">Reset</button>
</header>
<div class="reachability__summary">
<div class="summary-card">
<div class="summary-card__value">{{ okCount() }}</div>
<div class="summary-card__label">Healthy assets</div>
</div>
<div class="summary-card summary-card--warn">
<div class="summary-card__value">{{ staleCount() }}</div>
<div class="summary-card__label">Stale facts</div>
</div>
<div class="summary-card summary-card--danger">
<div class="summary-card__value">{{ missingCount() }}</div>
<div class="summary-card__label">Missing sensors</div>
</div>
</div>
<div class="reachability__filters" role="group" aria-label="Filters">
<button
type="button"
class="pill"
[class.pill--active]="statusFilter() === 'all'"
(click)="setStatusFilter('all')"
>
All
</button>
<button
type="button"
class="pill"
[class.pill--active]="statusFilter() === 'ok'"
(click)="setStatusFilter('ok')"
>
Healthy
</button>
<button
type="button"
class="pill"
[class.pill--active]="statusFilter() === 'stale'"
(click)="setStatusFilter('stale')"
>
Stale
</button>
<button
type="button"
class="pill"
[class.pill--active]="statusFilter() === 'missing'"
(click)="setStatusFilter('missing')"
>
Missing
</button>
</div>
<div class="reachability__table">
<table>
<thead>
<tr>
<th>Asset</th>
<th>Coverage</th>
<th>Sensors</th>
<th>Last fact</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows(); track row.assetId) {
<tr>
<td><code>{{ row.assetId }}</code></td>
<td>{{ row.coveragePercent }}%</td>
<td>{{ row.sensorsOnline }}/{{ row.sensorsExpected }}</td>
<td>{{ row.lastFactAt ?? '—' }}</td>
<td>
<span class="status" [class]="'status--' + row.status">
{{ row.status }}
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
`,
styles: [
`
:host {
display: block;
min-height: 100vh;
background: #0b1224;
color: #e5e7eb;
}
.reachability {
max-width: 1100px;
margin: 0 auto;
padding: 1.5rem;
display: grid;
gap: 1rem;
}
.reachability__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.reachability__eyebrow {
margin: 0;
color: #22d3ee;
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.8rem;
}
h1 {
margin: 0.25rem 0 0;
font-size: 1.5rem;
}
.reachability__subtitle {
margin: 0.25rem 0 0;
color: #94a3b8;
}
.btn {
border: 1px solid #334155;
background: transparent;
color: #e5e7eb;
border-radius: 10px;
padding: 0.5rem 0.8rem;
cursor: pointer;
}
.reachability__summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
}
.summary-card {
border: 1px solid #1f2937;
background: #0f172a;
border-radius: 14px;
padding: 0.9rem 1rem;
display: grid;
gap: 0.25rem;
}
.summary-card__value {
font-size: 1.5rem;
font-weight: 700;
}
.summary-card__label {
color: #94a3b8;
font-size: 0.85rem;
}
.summary-card--warn .summary-card__value {
color: #f59e0b;
}
.summary-card--danger .summary-card__value {
color: #ef4444;
}
.reachability__filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.pill {
border: 1px solid #334155;
background: #0f172a;
color: #e5e7eb;
border-radius: 999px;
padding: 0.35rem 0.75rem;
cursor: pointer;
}
.pill--active {
border-color: #22d3ee;
color: #22d3ee;
}
.reachability__table {
border: 1px solid #1f2937;
background: #0f172a;
border-radius: 14px;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.75rem 0.9rem;
border-bottom: 1px solid #1f2937;
text-align: left;
font-size: 0.9rem;
}
th {
color: #94a3b8;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
code {
font-family: ui-monospace, monospace;
color: #e2e8f0;
}
.status {
display: inline-flex;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
border: 1px solid #334155;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status--ok {
border-color: #14532d;
color: #22c55e;
}
.status--stale {
border-color: #92400e;
color: #f59e0b;
}
.status--missing {
border-color: #991b1b;
color: #ef4444;
}
`,
],
})
export class ReachabilityCenterComponent {
readonly statusFilter = signal<CoverageStatus | 'all'>('all');
readonly rows = signal<ReachabilityCoverageRow[]>(
[...FIXTURE_ROWS].sort((a, b) => a.assetId.localeCompare(b.assetId))
);
readonly filteredRows = computed(() => {
const status = this.statusFilter();
const rows = this.rows();
if (status === 'all') return rows;
return rows.filter((r) => r.status === status);
});
readonly okCount = computed(() => this.rows().filter((r) => r.status === 'ok').length);
readonly staleCount = computed(() => this.rows().filter((r) => r.status === 'stale').length);
readonly missingCount = computed(() => this.rows().filter((r) => r.status === 'missing').length);
setStatusFilter(status: CoverageStatus | 'all'): void {
this.statusFilter.set(status);
}
reset(): void {
this.statusFilter.set('all');
}
}

View File

@@ -0,0 +1,87 @@
import { ComponentFixture, TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
import { of } from 'rxjs';
import { MockSignalsClient } from '../../core/api/signals.client';
import { ReachabilityWhyDrawerComponent } from './reachability-why-drawer.component';
describe('ReachabilityWhyDrawerComponent', () => {
let fixture: ComponentFixture<ReachabilityWhyDrawerComponent>;
let signals: jasmine.SpyObj<MockSignalsClient>;
beforeEach(async () => {
signals = jasmine.createSpyObj<MockSignalsClient>('MockSignalsClient', ['getFacts', 'getCallGraphs']);
signals.getFacts.and.returnValue(
of({
tenantId: 'tenant-default',
facts: [
{
id: 'fact-1',
type: 'reachability',
assetId: 'asset-1',
component: 'pkg:npm/jsonwebtoken@9.0.2',
status: 'reachable',
confidence: 0.88,
observedAt: '2025-12-05T10:10:00Z',
signalsVersion: 'signals-2025.310.1',
evidenceTraceIds: ['trace-abc'],
},
],
pagination: { nextPageToken: null },
etag: '"etag-1"',
traceId: 'trace-req-1',
})
);
signals.getCallGraphs.and.returnValue(
of({
tenantId: 'tenant-default',
assetId: 'asset-1',
paths: [
{
id: 'path-1',
source: 'api-gateway',
target: 'jwt-auth-service',
hops: [
{ service: 'api-gateway', endpoint: '/login', timestamp: '2025-12-05T10:00:00Z' },
{ service: 'jwt-auth-service', endpoint: '/verify', timestamp: '2025-12-05T10:00:01Z' },
],
evidence: { traceId: 'trace-abc', spanCount: 2, score: 0.92 },
lastObserved: '2025-12-05T10:00:01Z',
},
],
pagination: { nextPageToken: null },
etag: '"etag-2"',
traceId: 'trace-req-2',
})
);
await TestBed.configureTestingModule({
imports: [ReachabilityWhyDrawerComponent],
providers: [{ provide: MockSignalsClient, useValue: signals }],
}).compileComponents();
fixture = TestBed.createComponent(ReachabilityWhyDrawerComponent);
});
it('loads evidence and renders trace ids', fakeAsync(() => {
fixture.componentRef.setInput('open', true);
fixture.componentRef.setInput('status', 'reachable');
fixture.componentRef.setInput('confidence', 0.88);
fixture.componentRef.setInput('component', 'pkg:npm/jsonwebtoken@9.0.2');
fixture.componentRef.setInput('assetId', 'asset-1');
fixture.detectChanges();
flushMicrotasks();
fixture.detectChanges();
expect(signals.getFacts).toHaveBeenCalled();
expect(signals.getCallGraphs).toHaveBeenCalled();
const el = fixture.nativeElement as HTMLElement;
expect(el.textContent).toContain('Call paths');
expect(el.textContent).toContain('api-gateway');
expect(el.textContent).toContain('trace-abc');
}));
});

View File

@@ -0,0 +1,423 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Output,
computed,
effect,
inject,
input,
signal,
} from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { MockSignalsClient, type CallGraphPath } from '../../core/api/signals.client';
type ReachabilityStatus = 'reachable' | 'unreachable' | 'unknown';
@Component({
selector: 'app-reachability-why-drawer',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (open()) {
<div class="why-drawer">
<div class="why-drawer__backdrop" (click)="requestClose()"></div>
<aside class="why-drawer__panel" role="dialog" aria-modal="true" aria-label="Reachability details">
<header class="why-drawer__header">
<div>
<p class="why-drawer__eyebrow">Reachability evidence</p>
<h2 class="why-drawer__title">
{{ statusLabel() }}
@if (confidenceLabel()) {
<span class="why-drawer__confidence">{{ confidenceLabel() }}</span>
}
</h2>
@if (component()) {
<p class="why-drawer__subtitle">{{ component() }}</p>
}
</div>
<button type="button" class="why-drawer__close" (click)="requestClose()">Close</button>
</header>
<div class="why-drawer__body" [attr.aria-busy]="loading()">
@if (loading()) {
<div class="why-drawer__loading">Loading reachability evidence…</div>
} @else if (error()) {
<div class="why-drawer__error">{{ error() }}</div>
} @else if (!component()) {
<div class="why-drawer__empty">No affected component selected.</div>
} @else {
<section class="why-drawer__section">
<h3>Timeline</h3>
<ul class="timeline">
@for (entry of timeline(); track entry.key) {
<li>
<span class="timeline__when">{{ entry.when }}</span>
<span class="timeline__what">{{ entry.what }}</span>
</li>
}
</ul>
</section>
<section class="why-drawer__section">
<h3>Call paths</h3>
@if (paths().length === 0) {
<p class="muted">No call paths available for this asset/component.</p>
} @else {
<div class="paths">
@for (path of paths(); track path.id) {
<article class="path-card">
<header class="path-card__header">
<div class="path-card__title">
{{ path.source }} → {{ path.target }}
</div>
<div class="path-card__meta">
<span>score {{ path.evidence.score.toFixed(2) }}</span>
<span>last {{ path.lastObserved }}</span>
</div>
</header>
<ol class="hops">
@for (hop of path.hops; track hop.service + hop.endpoint + hop.timestamp) {
<li class="hop">
<span class="hop__svc">{{ hop.service }}</span>
<span class="hop__ep">{{ hop.endpoint }}</span>
<span class="hop__ts">{{ hop.timestamp }}</span>
</li>
}
</ol>
</article>
}
</div>
}
</section>
<section class="why-drawer__section">
<h3>Evidence</h3>
<ul class="evidence">
@for (trace of evidenceTraceIds(); track trace) {
<li><code>{{ trace }}</code></li>
}
</ul>
</section>
}
</div>
</aside>
</div>
}
`,
styles: [
`
.why-drawer {
position: fixed;
inset: 0;
z-index: 210;
display: grid;
grid-template-columns: 1fr min(520px, 100%);
}
.why-drawer__backdrop {
background: rgba(15, 23, 42, 0.65);
backdrop-filter: blur(2px);
}
.why-drawer__panel {
background: #0b1224;
color: #e5e7eb;
border-left: 1px solid #1f2937;
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
}
.why-drawer__header {
padding: 1rem 1.25rem;
border-bottom: 1px solid #1f2937;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.why-drawer__eyebrow {
margin: 0;
font-size: 0.75rem;
color: #22d3ee;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.why-drawer__title {
margin: 0.25rem 0 0;
font-size: 1.25rem;
}
.why-drawer__confidence {
margin-left: 0.5rem;
font-size: 0.85rem;
color: #94a3b8;
}
.why-drawer__subtitle {
margin: 0.25rem 0 0;
font-size: 0.75rem;
color: #94a3b8;
word-break: break-all;
}
.why-drawer__close {
background: transparent;
color: #e5e7eb;
border: 1px solid #334155;
border-radius: 8px;
padding: 0.35rem 0.65rem;
cursor: pointer;
}
.why-drawer__body {
padding: 1rem 1.25rem;
overflow: auto;
}
.why-drawer__section + .why-drawer__section {
margin-top: 1.25rem;
}
h3 {
margin: 0 0 0.5rem;
font-size: 0.9rem;
color: #e2e8f0;
}
.muted {
margin: 0;
color: #94a3b8;
font-size: 0.85rem;
}
.why-drawer__loading,
.why-drawer__error,
.why-drawer__empty {
padding: 0.75rem 1rem;
border: 1px solid #334155;
border-radius: 10px;
background: #0f172a;
}
.timeline {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.5rem;
}
.timeline li {
display: grid;
gap: 0.15rem;
padding: 0.6rem 0.75rem;
border: 1px solid #1f2937;
border-radius: 10px;
background: #0f172a;
}
.timeline__when {
font-size: 0.75rem;
color: #94a3b8;
font-family: ui-monospace, monospace;
}
.timeline__what {
font-size: 0.875rem;
color: #e5e7eb;
}
.paths {
display: grid;
gap: 0.75rem;
}
.path-card {
border: 1px solid #1f2937;
background: #0f172a;
border-radius: 12px;
padding: 0.75rem 0.9rem;
}
.path-card__header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: baseline;
margin-bottom: 0.5rem;
}
.path-card__title {
font-weight: 600;
}
.path-card__meta {
display: flex;
gap: 0.75rem;
font-size: 0.75rem;
color: #94a3b8;
font-family: ui-monospace, monospace;
}
.hops {
margin: 0;
padding-left: 1.25rem;
display: grid;
gap: 0.35rem;
color: #e2e8f0;
font-size: 0.85rem;
}
.hop {
display: grid;
gap: 0.15rem;
}
.hop__svc {
font-weight: 600;
}
.hop__ep {
font-family: ui-monospace, monospace;
color: #cbd5e1;
}
.hop__ts {
font-size: 0.75rem;
color: #94a3b8;
font-family: ui-monospace, monospace;
}
.evidence {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.35rem;
}
code {
font-family: ui-monospace, monospace;
font-size: 0.8rem;
color: #e2e8f0;
}
`,
],
})
export class ReachabilityWhyDrawerComponent {
private readonly signals = inject(MockSignalsClient);
readonly open = input.required<boolean>();
readonly status = input<ReachabilityStatus>('unknown');
readonly confidence = input<number | null>(null);
readonly component = input<string | null>(null);
readonly assetId = input<string | null>(null);
@Output() close = new EventEmitter<void>();
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly paths = signal<readonly CallGraphPath[]>([]);
readonly factObservedAt = signal<string | null>(null);
readonly lastObserved = signal<string | null>(null);
readonly evidenceTraceIds = signal<readonly string[]>([]);
readonly statusLabel = computed(() => {
switch (this.status()) {
case 'reachable':
return 'Reachable';
case 'unreachable':
return 'Unreachable';
default:
return 'Unknown';
}
});
readonly confidenceLabel = computed(() => {
const c = this.confidence();
if (typeof c !== 'number') return '';
return `${Math.round(c * 100)}%`;
});
readonly timeline = computed(() => {
const items: { key: string; when: string; what: string }[] = [];
const factAt = this.factObservedAt();
if (factAt) {
items.push({ key: `fact-${factAt}`, when: factAt, what: 'Reachability fact observed' });
}
const last = this.lastObserved();
if (last) {
items.push({ key: `call-${last}`, when: last, what: 'Latest call path observation' });
}
return items;
});
constructor() {
effect(
() => {
if (!this.open()) return;
if (!this.component()) return;
void this.refresh();
},
{ allowSignalWrites: true }
);
}
requestClose(): void {
this.close.emit();
}
private async refresh(): Promise<void> {
if (!this.open() || !this.component()) return;
this.loading.set(true);
this.error.set(null);
try {
const component = this.component();
if (!component) return;
const [facts, callGraphs] = await Promise.all([
firstValueFrom(
this.signals.getFacts({
assetId: this.assetId() ?? undefined,
component,
})
),
firstValueFrom(
this.signals.getCallGraphs({
assetId: this.assetId() ?? undefined,
})
),
]);
this.paths.set(callGraphs.paths ?? []);
this.lastObserved.set(callGraphs.paths?.[0]?.lastObserved ?? null);
const fact = facts.facts?.[0] ?? null;
this.factObservedAt.set(fact?.observedAt ?? null);
const traces = new Set<string>();
for (const path of callGraphs.paths ?? []) {
if (path?.evidence?.traceId) traces.add(path.evidence.traceId);
}
for (const trace of fact?.evidenceTraceIds ?? []) {
traces.add(trace);
}
this.evidenceTraceIds.set([...traces].sort((a, b) => a.localeCompare(b)));
} catch (err) {
const message = err instanceof Error ? err.message : 'Unable to load reachability evidence.';
this.error.set(message);
this.paths.set([]);
this.evidenceTraceIds.set([]);
} finally {
this.loading.set(false);
}
}
}

View File

@@ -224,13 +224,13 @@ type ViewMode = 'summary' | 'layers' | 'files';
{{ (file.opaqueRatio * 100).toFixed(1) }}%
</span>
</button>
<!-- Entropy Heatmap -->
<div class="file-heatmap" aria-label="Entropy heatmap for {{ file.path }}">
@for (window of file.windows; track window.offset) {
<div
class="heatmap-cell"
[style.background]="getEntropyColor(window.entropy)"
<!-- Entropy Heatmap -->
<div class="file-heatmap" [attr.aria-label]="'Entropy heatmap for ' + file.path">
@for (window of file.windows; track window.offset) {
<div
class="heatmap-cell"
[style.background]="getEntropyColor(window.entropy)"
[attr.title]="'Offset: ' + window.offset + ', Entropy: ' + window.entropy.toFixed(2)"
></div>
}

View File

@@ -51,29 +51,29 @@
</p>
<!-- Determinism Evidence Section -->
<section class="determinism-section">
<h2>SBOM Determinism</h2>
@if (scan().determinism) {
<app-determinism-badge [evidence]="scan().determinism" />
} @else {
<p class="determinism-empty">
No determinism evidence available for this scan.
</p>
}
</section>
<section class="determinism-section">
<h2>SBOM Determinism</h2>
@if (scan().determinism) {
<app-determinism-badge [evidence]="scan().determinism ?? null" />
} @else {
<p class="determinism-empty">
No determinism evidence available for this scan.
</p>
}
</section>
<!-- Entropy Analysis Section -->
<section class="entropy-section">
<h2>Entropy Analysis</h2>
@if (scan().entropy) {
<!-- Policy Banner with thresholds and mitigations -->
<app-entropy-policy-banner [evidence]="scan().entropy" />
<!-- Detailed entropy visualization -->
<app-entropy-panel [evidence]="scan().entropy" />
} @else {
<p class="entropy-empty">
No entropy analysis available for this scan.
</p>
}
</section>
</section>
<section class="entropy-section">
<h2>Entropy Analysis</h2>
@if (scan().entropy) {
<!-- Policy Banner with thresholds and mitigations -->
<app-entropy-policy-banner [evidence]="scan().entropy ?? null" />
<!-- Detailed entropy visualization -->
<app-entropy-panel [evidence]="scan().entropy ?? null" />
} @else {
<p class="entropy-empty">
No entropy analysis available for this scan.
</p>
}
</section>
</section>

View File

@@ -85,24 +85,38 @@
</select>
</div>
<div class="filter-group">
<label class="filter-group__label">Status</label>
<select
class="filter-group__select"
[value]="statusFilter()"
(change)="setStatusFilter($any($event.target).value)"
>
<option value="all">All Statuses</option>
<option *ngFor="let st of allStatuses" [value]="st">
{{ statusLabels[st] }}
</option>
</select>
</div>
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="showExceptedOnly()"
<div class="filter-group">
<label class="filter-group__label">Status</label>
<select
class="filter-group__select"
[value]="statusFilter()"
(change)="setStatusFilter($any($event.target).value)"
>
<option value="all">All Statuses</option>
<option *ngFor="let st of allStatuses" [value]="st">
{{ statusLabels[st] }}
</option>
</select>
</div>
<div class="filter-group">
<label class="filter-group__label">Reachability</label>
<select
class="filter-group__select"
[value]="reachabilityFilter()"
(change)="setReachabilityFilter($any($event.target).value)"
>
<option value="all">All</option>
<option *ngFor="let reach of allReachability" [value]="reach">
{{ reachabilityLabels[reach] }}
</option>
</select>
</div>
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="showExceptedOnly()"
(change)="toggleExceptedOnly()"
/>
<span>Show with exceptions only</span>
@@ -133,13 +147,14 @@
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('cvssScore')">
CVSS {{ getSortIcon('cvssScore') }}
</th>
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('status')">
Status {{ getSortIcon('status') }}
</th>
<th class="vuln-table__th">Components</th>
<th class="vuln-table__th">Actions</th>
</tr>
</thead>
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('status')">
Status {{ getSortIcon('status') }}
</th>
<th class="vuln-table__th">Reachability</th>
<th class="vuln-table__th">Components</th>
<th class="vuln-table__th">Actions</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let vuln of filteredVulnerabilities(); trackBy: trackByVuln"
@@ -173,15 +188,24 @@
{{ formatCvss(vuln.cvssScore) }}
</span>
</td>
<td class="vuln-table__td">
<span class="chip chip--small" [ngClass]="getStatusClass(vuln.status)">
{{ statusLabels[vuln.status] }}
</span>
</td>
<td class="vuln-table__td">
<span class="component-count">{{ vuln.affectedComponents.length }}</span>
</td>
<td class="vuln-table__td vuln-table__td--actions">
<td class="vuln-table__td">
<span class="chip chip--small" [ngClass]="getStatusClass(vuln.status)">
{{ statusLabels[vuln.status] }}
</span>
</td>
<td class="vuln-table__td">
<span
class="chip chip--small"
[ngClass]="getReachabilityClass(vuln)"
[title]="getReachabilityTooltip(vuln)"
>
{{ getReachabilityLabel(vuln) }}
</span>
</td>
<td class="vuln-table__td">
<span class="component-count">{{ vuln.affectedComponents.length }}</span>
</td>
<td class="vuln-table__td vuln-table__td--actions">
<button
type="button"
class="btn btn--small btn--action"
@@ -230,13 +254,27 @@
{{ formatCvss(vuln.cvssScore) }}
</span>
</div>
<div class="detail-item">
<span class="detail-item__label">Status</span>
<span class="chip" [ngClass]="getStatusClass(vuln.status)">
{{ statusLabels[vuln.status] }}
</span>
</div>
</div>
<div class="detail-item">
<span class="detail-item__label">Status</span>
<span class="chip" [ngClass]="getStatusClass(vuln.status)">
{{ statusLabels[vuln.status] }}
</span>
</div>
<div class="detail-item">
<span class="detail-item__label">Reachability</span>
<span class="chip" [ngClass]="getReachabilityClass(vuln)" [title]="getReachabilityTooltip(vuln)">
{{ getReachabilityLabel(vuln) }}
</span>
<button
type="button"
class="btn btn--secondary btn--small"
(click)="openWhyDrawer()"
[disabled]="!vuln.affectedComponents.length"
>
Why?
</button>
</div>
</div>
<!-- Exception Badge -->
<div class="detail-section" *ngIf="getExceptionBadgeData(vuln) as badgeData">
@@ -299,21 +337,30 @@
</div>
<!-- Actions -->
<div class="detail-panel__actions" *ngIf="!vuln.hasException && !showExceptionDraft()">
<button
type="button"
class="btn btn--primary"
(click)="startExceptionDraft()"
>
Create Exception
</button>
</div>
<!-- Inline Exception Draft -->
<div class="detail-panel__exception-draft" *ngIf="showExceptionDraft() && exceptionDraftContext()">
<app-exception-draft-inline
[context]="exceptionDraftContext()!"
(created)="onExceptionCreated()"
<div class="detail-panel__actions" *ngIf="!vuln.hasException && !showExceptionDraft()">
<button
type="button"
class="btn btn--primary"
(click)="startExceptionDraft()"
>
Create Exception
</button>
</div>
<app-reachability-why-drawer
[open]="showWhyDrawer()"
[status]="(vuln.reachabilityStatus ?? 'unknown')"
[confidence]="vuln.reachabilityScore ?? null"
[component]="vuln.affectedComponents[0]?.purl ?? null"
[assetId]="vuln.affectedComponents[0]?.assetIds?.[0] ?? null"
(close)="closeWhyDrawer()"
></app-reachability-why-drawer>
<!-- Inline Exception Draft -->
<div class="detail-panel__exception-draft" *ngIf="showExceptionDraft() && exceptionDraftContext()">
<app-exception-draft-inline
[context]="exceptionDraftContext()!"
(created)="onExceptionCreated()"
(cancelled)="cancelExceptionDraft()"
(openFullWizard)="openFullWizard()"
></app-exception-draft-inline>

View File

@@ -413,15 +413,31 @@
color: #92400e;
}
.status--excepted {
background: #f3e8ff;
color: #7c3aed;
}
// Empty State
.empty-state {
display: flex;
flex-direction: column;
.status--excepted {
background: #f3e8ff;
color: #7c3aed;
}
// Reachability chips
.reachability--reachable {
background: #dcfce7;
color: #166534;
}
.reachability--unreachable {
background: #f1f5f9;
color: #475569;
}
.reachability--unknown {
background: #fef3c7;
color: #92400e;
}
// Empty State
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;

View File

@@ -0,0 +1,83 @@
import { ComponentFixture, TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
import { of } from 'rxjs';
import { EXCEPTION_API, MockExceptionApiService } from '../../core/api/exception.client';
import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client';
import type { Vulnerability, VulnerabilityStats } from '../../core/api/vulnerability.models';
import { VulnerabilityExplorerComponent } from './vulnerability-explorer.component';
describe('VulnerabilityExplorerComponent', () => {
let fixture: ComponentFixture<VulnerabilityExplorerComponent>;
let component: VulnerabilityExplorerComponent;
let api: jasmine.SpyObj<VulnerabilityApi>;
beforeEach(async () => {
api = jasmine.createSpyObj<VulnerabilityApi>('VulnerabilityApi', ['listVulnerabilities', 'getStats']);
const vulns: Vulnerability[] = [
{
vulnId: 'v-1',
cveId: 'CVE-2024-0001',
title: 'Reachable vuln',
description: '',
severity: 'high',
status: 'open',
affectedComponents: [],
reachabilityStatus: 'reachable',
reachabilityScore: 0.9,
},
{
vulnId: 'v-2',
cveId: 'CVE-2024-0002',
title: 'Unreachable vuln',
description: '',
severity: 'high',
status: 'open',
affectedComponents: [],
reachabilityStatus: 'unreachable',
reachabilityScore: 0.95,
},
];
const stats: VulnerabilityStats = {
total: 2,
bySeverity: { critical: 0, high: 2, medium: 0, low: 0, unknown: 0 },
byStatus: { open: 2, fixed: 0, wont_fix: 0, in_progress: 0, excepted: 0 },
withExceptions: 0,
criticalOpen: 0,
};
api.listVulnerabilities.and.returnValue(of({ items: vulns, total: vulns.length }));
api.getStats.and.returnValue(of(stats));
await TestBed.configureTestingModule({
imports: [VulnerabilityExplorerComponent],
providers: [
{ provide: VULNERABILITY_API, useValue: api },
{ provide: EXCEPTION_API, useClass: MockExceptionApiService },
],
}).compileComponents();
fixture = TestBed.createComponent(VulnerabilityExplorerComponent);
component = fixture.componentInstance;
});
it('requests reachability data from API', fakeAsync(() => {
fixture.detectChanges();
flushMicrotasks();
const options = api.listVulnerabilities.calls.mostRecent().args[0];
expect(options?.includeReachability).toBeTrue();
}));
it('filters by reachability status', fakeAsync(() => {
fixture.detectChanges();
flushMicrotasks();
component.reachabilityFilter.set('reachable');
expect(component.filteredVulnerabilities().map((v) => v.vulnId)).toEqual(['v-1']);
component.reachabilityFilter.set('unreachable');
expect(component.filteredVulnerabilities().map((v) => v.vulnId)).toEqual(['v-2']);
}));
});

View File

@@ -20,17 +20,19 @@ import {
ExceptionDraftContext,
ExceptionDraftInlineComponent,
} from '../exceptions/exception-draft-inline.component';
import {
ExceptionBadgeComponent,
ExceptionBadgeData,
ExceptionExplainComponent,
ExceptionExplainData,
} from '../../shared/components';
import {
ExceptionBadgeComponent,
ExceptionBadgeData,
ExceptionExplainComponent,
ExceptionExplainData,
} from '../../shared/components';
import { ReachabilityWhyDrawerComponent } from '../reachability/reachability-why-drawer.component';
type SeverityFilter = VulnerabilitySeverity | 'all';
type StatusFilter = VulnerabilityStatus | 'all';
type SortField = 'cveId' | 'severity' | 'cvssScore' | 'publishedAt' | 'status';
type SortOrder = 'asc' | 'desc';
type SeverityFilter = VulnerabilitySeverity | 'all';
type StatusFilter = VulnerabilityStatus | 'all';
type ReachabilityFilter = 'reachable' | 'unreachable' | 'unknown' | 'all';
type SortField = 'cveId' | 'severity' | 'cvssScore' | 'publishedAt' | 'status';
type SortOrder = 'asc' | 'desc';
const SEVERITY_LABELS: Record<VulnerabilitySeverity, string> = {
critical: 'Critical',
@@ -40,29 +42,35 @@ const SEVERITY_LABELS: Record<VulnerabilitySeverity, string> = {
unknown: 'Unknown',
};
const STATUS_LABELS: Record<VulnerabilityStatus, string> = {
open: 'Open',
fixed: 'Fixed',
wont_fix: "Won't Fix",
in_progress: 'In Progress',
excepted: 'Excepted',
};
const SEVERITY_ORDER: Record<VulnerabilitySeverity, number> = {
critical: 0,
high: 1,
medium: 2,
const STATUS_LABELS: Record<VulnerabilityStatus, string> = {
open: 'Open',
fixed: 'Fixed',
wont_fix: "Won't Fix",
in_progress: 'In Progress',
excepted: 'Excepted',
};
const REACHABILITY_LABELS: Record<Exclude<ReachabilityFilter, 'all'>, string> = {
reachable: 'Reachable',
unreachable: 'Unreachable',
unknown: 'Unknown',
};
const SEVERITY_ORDER: Record<VulnerabilitySeverity, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
unknown: 4,
};
@Component({
selector: 'app-vulnerability-explorer',
standalone: true,
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent],
templateUrl: './vulnerability-explorer.component.html',
styleUrls: ['./vulnerability-explorer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-vulnerability-explorer',
standalone: true,
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent, ReachabilityWhyDrawerComponent],
templateUrl: './vulnerability-explorer.component.html',
styleUrls: ['./vulnerability-explorer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [],
})
export class VulnerabilityExplorerComponent implements OnInit {
@@ -78,45 +86,55 @@ export class VulnerabilityExplorerComponent implements OnInit {
readonly stats = signal<VulnerabilityStats | null>(null);
readonly selectedVulnId = signal<string | null>(null);
// Filters & sorting
readonly severityFilter = signal<SeverityFilter>('all');
readonly statusFilter = signal<StatusFilter>('all');
readonly searchQuery = signal('');
readonly sortField = signal<SortField>('severity');
readonly sortOrder = signal<SortOrder>('asc');
readonly showExceptedOnly = signal(false);
// Filters & sorting
readonly severityFilter = signal<SeverityFilter>('all');
readonly statusFilter = signal<StatusFilter>('all');
readonly reachabilityFilter = signal<ReachabilityFilter>('all');
readonly searchQuery = signal('');
readonly sortField = signal<SortField>('severity');
readonly sortOrder = signal<SortOrder>('asc');
readonly showExceptedOnly = signal(false);
// Exception draft state
readonly showExceptionDraft = signal(false);
readonly selectedForException = signal<Vulnerability[]>([]);
// Exception explain state
readonly showExceptionExplain = signal(false);
readonly explainExceptionId = signal<string | null>(null);
// Constants for template
readonly severityLabels = SEVERITY_LABELS;
readonly statusLabels = STATUS_LABELS;
readonly allSeverities: VulnerabilitySeverity[] = ['critical', 'high', 'medium', 'low', 'unknown'];
readonly allStatuses: VulnerabilityStatus[] = ['open', 'fixed', 'wont_fix', 'in_progress', 'excepted'];
// Exception explain state
readonly showExceptionExplain = signal(false);
readonly explainExceptionId = signal<string | null>(null);
// Why drawer state
readonly showWhyDrawer = signal(false);
// Constants for template
readonly severityLabels = SEVERITY_LABELS;
readonly statusLabels = STATUS_LABELS;
readonly reachabilityLabels = REACHABILITY_LABELS;
readonly allSeverities: VulnerabilitySeverity[] = ['critical', 'high', 'medium', 'low', 'unknown'];
readonly allStatuses: VulnerabilityStatus[] = ['open', 'fixed', 'wont_fix', 'in_progress', 'excepted'];
readonly allReachability: Exclude<ReachabilityFilter, 'all'>[] = ['reachable', 'unknown', 'unreachable'];
// Computed: filtered and sorted list
readonly filteredVulnerabilities = computed(() => {
let items = [...this.vulnerabilities()];
const severity = this.severityFilter();
const status = this.statusFilter();
const search = this.searchQuery().toLowerCase();
const exceptedOnly = this.showExceptedOnly();
readonly filteredVulnerabilities = computed(() => {
let items = [...this.vulnerabilities()];
const severity = this.severityFilter();
const status = this.statusFilter();
const reachability = this.reachabilityFilter();
const search = this.searchQuery().toLowerCase();
const exceptedOnly = this.showExceptedOnly();
if (severity !== 'all') {
items = items.filter((v) => v.severity === severity);
}
if (status !== 'all') {
items = items.filter((v) => v.status === status);
}
if (exceptedOnly) {
items = items.filter((v) => v.hasException);
}
if (status !== 'all') {
items = items.filter((v) => v.status === status);
}
if (reachability !== 'all') {
items = items.filter((v) => (v.reachabilityStatus ?? 'unknown') === reachability);
}
if (exceptedOnly) {
items = items.filter((v) => v.hasException);
}
if (search) {
items = items.filter(
(v) =>
@@ -221,10 +239,10 @@ export class VulnerabilityExplorerComponent implements OnInit {
this.message.set(null);
try {
const [vulnsResponse, statsResponse] = await Promise.all([
firstValueFrom(this.api.listVulnerabilities()),
firstValueFrom(this.api.getStats()),
]);
const [vulnsResponse, statsResponse] = await Promise.all([
firstValueFrom(this.api.listVulnerabilities({ includeReachability: true })),
firstValueFrom(this.api.getStats()),
]);
this.vulnerabilities.set([...vulnsResponse.items]);
this.stats.set(statsResponse);
@@ -240,14 +258,18 @@ export class VulnerabilityExplorerComponent implements OnInit {
this.severityFilter.set(severity);
}
setStatusFilter(status: StatusFilter): void {
this.statusFilter.set(status);
}
onSearchInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchQuery.set(input.value);
}
setStatusFilter(status: StatusFilter): void {
this.statusFilter.set(status);
}
setReachabilityFilter(reachability: ReachabilityFilter): void {
this.reachabilityFilter.set(reachability);
}
onSearchInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchQuery.set(input.value);
}
clearSearch(): void {
this.searchQuery.set('');
@@ -315,17 +337,17 @@ export class VulnerabilityExplorerComponent implements OnInit {
this.showExceptionExplain.set(true);
}
closeExplain(): void {
this.showExceptionExplain.set(false);
this.explainExceptionId.set(null);
}
closeExplain(): void {
this.showExceptionExplain.set(false);
this.explainExceptionId.set(null);
}
viewExceptionFromExplain(exceptionId: string): void {
this.closeExplain();
this.onViewExceptionDetails(exceptionId);
}
openFullWizard(): void {
openFullWizard(): void {
// In a real app, this would navigate to the Exception Center wizard
// For now, just show a message
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
@@ -349,13 +371,47 @@ export class VulnerabilityExplorerComponent implements OnInit {
});
}
formatCvss(score: number | undefined): string {
if (score === undefined) return '-';
return score.toFixed(1);
}
trackByVuln = (_: number, item: Vulnerability) => item.vulnId;
trackByComponent = (_: number, item: { purl: string }) => item.purl;
formatCvss(score: number | undefined): string {
if (score === undefined) return '-';
return score.toFixed(1);
}
openWhyDrawer(): void {
this.showWhyDrawer.set(true);
}
closeWhyDrawer(): void {
this.showWhyDrawer.set(false);
}
getReachabilityClass(vuln: Vulnerability): string {
const status = vuln.reachabilityStatus ?? 'unknown';
return `reachability--${status}`;
}
getReachabilityLabel(vuln: Vulnerability): string {
const status = vuln.reachabilityStatus ?? 'unknown';
return REACHABILITY_LABELS[status];
}
getReachabilityTooltip(vuln: Vulnerability): string {
const status = vuln.reachabilityStatus ?? 'unknown';
const score = vuln.reachabilityScore;
const scoreText =
typeof score === 'number' ? ` (confidence ${(score * 100).toFixed(0)}%)` : '';
switch (status) {
case 'reachable':
return `Reachable${scoreText}. Signals indicates a call path reaches at least one affected component.`;
case 'unreachable':
return `Unreachable${scoreText}. Signals found no call path to affected components.`;
default:
return `Unknown${scoreText}. No reachability evidence is available for the affected components.`;
}
}
trackByVuln = (_: number, item: Vulnerability) => item.vulnId;
trackByComponent = (_: number, item: { purl: string }) => item.purl;
private sortVulnerabilities(items: Vulnerability[]): Vulnerability[] {
const field = this.sortField();
@@ -392,9 +448,9 @@ export class VulnerabilityExplorerComponent implements OnInit {
setTimeout(() => this.message.set(null), 5000);
}
private toErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return 'Operation failed. Please retry.';
}
}
private toErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return 'Operation failed. Please retry.';
}
}

View File

@@ -79,25 +79,28 @@ export interface ExceptionExplainData {
</section>
<!-- Scope -->
<section class="explain-section">
<h4>What does it cover?</h4>
<ul class="explain-scope">
<li *ngIf="data.scope.vulnIds?.length">
<strong>{{ data.scope.vulnIds.length }}</strong> vulnerabilit{{ data.scope.vulnIds.length === 1 ? 'y' : 'ies' }}:
<span class="explain-items">{{ formatList(data.scope.vulnIds) }}</span>
</li>
<li *ngIf="data.scope.componentPurls?.length">
<strong>{{ data.scope.componentPurls.length }}</strong> component{{ data.scope.componentPurls.length === 1 ? '' : 's' }}
</li>
<li *ngIf="data.scope.assetIds?.length">
<strong>{{ data.scope.assetIds.length }}</strong> asset{{ data.scope.assetIds.length === 1 ? '' : 's' }}:
<span class="explain-items">{{ formatList(data.scope.assetIds) }}</span>
</li>
<li *ngIf="data.scope.type === 'global'">
<strong>Global scope</strong> - applies to all matching findings
</li>
</ul>
</section>
<section class="explain-section">
<h4>What does it cover?</h4>
<ul class="explain-scope">
<li *ngIf="data.scope.vulnIds?.length">
<strong>{{ data.scope.vulnIds?.length ?? 0 }}</strong>
vulnerabilit{{ (data.scope.vulnIds?.length ?? 0) === 1 ? 'y' : 'ies' }}:
<span class="explain-items">{{ formatList(data.scope.vulnIds ?? []) }}</span>
</li>
<li *ngIf="data.scope.componentPurls?.length">
<strong>{{ data.scope.componentPurls?.length ?? 0 }}</strong>
component{{ (data.scope.componentPurls?.length ?? 0) === 1 ? '' : 's' }}
</li>
<li *ngIf="data.scope.assetIds?.length">
<strong>{{ data.scope.assetIds?.length ?? 0 }}</strong>
asset{{ (data.scope.assetIds?.length ?? 0) === 1 ? '' : 's' }}:
<span class="explain-items">{{ formatList(data.scope.assetIds ?? []) }}</span>
</li>
<li *ngIf="data.scope.type === 'global'">
<strong>Global scope</strong> - applies to all matching findings
</li>
</ul>
</section>
<!-- Impact -->
<section class="explain-section" *ngIf="data.impact">

View File

@@ -27,7 +27,7 @@ import { PolicyPackSummary } from '../../features/policy-studio/models/policy.mo
<label for="pack-select">Policy pack</label>
<select
id="pack-select"
(change)="onChange($event.target.value)"
(change)="onChange($any($event.target).value)"
[disabled]="loading"
[attr.aria-busy]="loading"
>

View File

@@ -9,7 +9,7 @@ const baseScopes = ['ui.read', 'policy:read'];
export const policyAuthorSession: StubAuthSession = {
subjectId: 'user-author',
tenant: 'tenant-default',
scopes: [...baseScopes, 'policy:author', 'policy:edit', 'policy:submit', 'policy:simulate'],
scopes: [...baseScopes, 'policy:author', 'policy:simulate'],
};
export const policyReviewerSession: StubAuthSession = {
@@ -21,13 +21,13 @@ export const policyReviewerSession: StubAuthSession = {
export const policyApproverSession: StubAuthSession = {
subjectId: 'user-approver',
tenant: 'tenant-default',
scopes: [...baseScopes, 'policy:review', 'policy:approve', 'policy:simulate'],
scopes: [...baseScopes, 'policy:approve', 'policy:simulate'],
};
export const policyOperatorSession: StubAuthSession = {
subjectId: 'user-operator',
tenant: 'tenant-default',
scopes: [...baseScopes, 'policy:operate', 'policy:activate', 'policy:run', 'policy:simulate'],
scopes: [...baseScopes, 'policy:operate', 'policy:simulate'],
};
export const policyAuditSession: StubAuthSession = {

View File

@@ -1,7 +1,7 @@
import {
Exception,
ExceptionStats,
} from '../core/api/exception.models';
import {
Exception,
ExceptionStats,
} from '../core/api/exception.contract.models';
/**
* Test fixtures for Exception Center components and services.

View File

@@ -3,18 +3,36 @@ import { defer, Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { NotifyApi } from '../core/api/notify.client';
import {
ChannelHealthResponse,
ChannelTestSendRequest,
ChannelTestSendResponse,
ChannelHealthStatus,
NotifyChannel,
NotifyDeliveriesQueryOptions,
NotifyDeliveriesResponse,
NotifyDelivery,
NotifyDeliveryRendered,
NotifyRule,
} from '../core/api/notify.models';
import {
ChannelHealthResponse,
ChannelTestSendRequest,
ChannelTestSendResponse,
ChannelHealthStatus,
NotifyChannel,
NotifyDeliveriesQueryOptions,
NotifyDeliveriesResponse,
NotifyDelivery,
NotifyDeliveryRendered,
NotifyRule,
DigestSchedule,
DigestSchedulesResponse,
QuietHours,
QuietHoursResponse,
ThrottleConfig,
ThrottleConfigsResponse,
NotifySimulationRequest,
NotifySimulationResult,
EscalationPolicy,
EscalationPoliciesResponse,
LocalizationConfig,
LocalizationConfigsResponse,
NotifyIncident,
NotifyIncidentsResponse,
AckRequest,
AckResponse,
NotifyQueryOptions,
DigestFrequency,
} from '../core/api/notify.models';
import {
inferHealthStatus,
mockNotifyChannels,
@@ -26,14 +44,20 @@ import {
const LATENCY_MS = 140;
@Injectable({ providedIn: 'root' })
export class MockNotifyApiService implements NotifyApi {
private readonly channels = signal<NotifyChannel[]>(
clone(mockNotifyChannels)
);
private readonly rules = signal<NotifyRule[]>(clone(mockNotifyRules));
private readonly deliveries = signal<NotifyDelivery[]>(
clone(mockNotifyDeliveries)
);
export class MockNotifyApiService implements NotifyApi {
private readonly channels = signal<NotifyChannel[]>(
clone(mockNotifyChannels)
);
private readonly rules = signal<NotifyRule[]>(clone(mockNotifyRules));
private readonly deliveries = signal<NotifyDelivery[]>(
clone(mockNotifyDeliveries)
);
private readonly digestSchedules = signal<DigestSchedule[]>([]);
private readonly quietHours = signal<QuietHours[]>([]);
private readonly throttleConfigs = signal<ThrottleConfig[]>([]);
private readonly escalationPolicies = signal<EscalationPolicy[]>([]);
private readonly localizations = signal<LocalizationConfig[]>([]);
private readonly incidents = signal<NotifyIncident[]>([]);
listChannels(): Observable<NotifyChannel[]> {
return this.simulate(() => this.channels());
@@ -126,17 +150,208 @@ export class MockNotifyApiService implements NotifyApi {
return this.simulate(() => undefined);
}
listDeliveries(
options?: NotifyDeliveriesQueryOptions
): Observable<NotifyDeliveriesResponse> {
listDeliveries(
options?: NotifyDeliveriesQueryOptions
): Observable<NotifyDeliveriesResponse> {
const filtered = this.filterDeliveries(options);
const payload: NotifyDeliveriesResponse = {
items: filtered,
continuationToken: null,
count: filtered.length,
};
return this.simulate(() => payload);
}
return this.simulate(() => payload);
}
listDigestSchedules(options: NotifyQueryOptions = {}): Observable<DigestSchedulesResponse> {
const traceId = options.traceId ?? this.traceId();
const items = this.digestSchedules();
return this.simulate(() => ({
items,
nextPageToken: null,
total: items.length,
traceId,
}));
}
saveDigestSchedule(schedule: DigestSchedule): Observable<DigestSchedule> {
const next = this.enrichDigestSchedule(schedule);
this.digestSchedules.update((items) => upsertById(items, next, (s) => s.scheduleId));
return this.simulate(() => next);
}
deleteDigestSchedule(scheduleId: string): Observable<void> {
this.digestSchedules.update((items) => items.filter((s) => s.scheduleId !== scheduleId));
return this.simulate(() => undefined);
}
listQuietHours(options: NotifyQueryOptions = {}): Observable<QuietHoursResponse> {
const traceId = options.traceId ?? this.traceId();
const items = this.quietHours();
return this.simulate(() => ({
items,
nextPageToken: null,
total: items.length,
traceId,
}));
}
saveQuietHours(quietHours: QuietHours): Observable<QuietHours> {
const next = this.enrichQuietHours(quietHours);
this.quietHours.update((items) => upsertById(items, next, (q) => q.quietHoursId));
return this.simulate(() => next);
}
deleteQuietHours(quietHoursId: string): Observable<void> {
this.quietHours.update((items) => items.filter((q) => q.quietHoursId !== quietHoursId));
return this.simulate(() => undefined);
}
listThrottleConfigs(options: NotifyQueryOptions = {}): Observable<ThrottleConfigsResponse> {
const traceId = options.traceId ?? this.traceId();
const items = this.throttleConfigs();
return this.simulate(() => ({
items,
nextPageToken: null,
total: items.length,
traceId,
}));
}
saveThrottleConfig(config: ThrottleConfig): Observable<ThrottleConfig> {
const next = this.enrichThrottleConfig(config);
this.throttleConfigs.update((items) => upsertById(items, next, (c) => c.throttleId));
return this.simulate(() => next);
}
deleteThrottleConfig(throttleId: string): Observable<void> {
this.throttleConfigs.update((items) => items.filter((c) => c.throttleId !== throttleId));
return this.simulate(() => undefined);
}
simulateNotification(request: NotifySimulationRequest, options: NotifyQueryOptions = {}): Observable<NotifySimulationResult> {
const traceId = options.traceId ?? this.traceId();
const matchedRules = this.rules()
.filter((rule) => rule.enabled)
.filter((rule) => (rule.match.eventKinds?.length ? rule.match.eventKinds.includes(request.eventKind) : true))
.map((rule) => rule.ruleId)
.sort();
const targetChannels = request.targetChannels?.length ? new Set(request.targetChannels) : null;
const wouldNotify = this.rules()
.filter((rule) => matchedRules.includes(rule.ruleId))
.flatMap((rule) =>
(rule.actions ?? [])
.filter((action) => action.enabled)
.filter((action) => (targetChannels ? targetChannels.has(action.channel) : true))
.map((action) => ({
channelId: action.channel,
actionId: action.actionId,
template: action.template ?? 'default',
digest: normalizeDigestFrequency(action.digest),
}))
);
return this.simulate(() => ({
simulationId: this.randomId('sim'),
matchedRules,
wouldNotify,
throttled: false,
quietHoursActive: false,
traceId,
}));
}
listEscalationPolicies(options: NotifyQueryOptions = {}): Observable<EscalationPoliciesResponse> {
const traceId = options.traceId ?? this.traceId();
const items = this.escalationPolicies();
return this.simulate(() => ({
items,
nextPageToken: null,
total: items.length,
traceId,
}));
}
saveEscalationPolicy(policy: EscalationPolicy): Observable<EscalationPolicy> {
const next = this.enrichEscalationPolicy(policy);
this.escalationPolicies.update((items) => upsertById(items, next, (p) => p.policyId));
return this.simulate(() => next);
}
deleteEscalationPolicy(policyId: string): Observable<void> {
this.escalationPolicies.update((items) => items.filter((p) => p.policyId !== policyId));
return this.simulate(() => undefined);
}
listLocalizations(options: NotifyQueryOptions = {}): Observable<LocalizationConfigsResponse> {
const traceId = options.traceId ?? this.traceId();
const items = this.localizations();
return this.simulate(() => ({
items,
nextPageToken: null,
total: items.length,
traceId,
}));
}
saveLocalization(config: LocalizationConfig): Observable<LocalizationConfig> {
const next = this.enrichLocalization(config);
this.localizations.update((items) => upsertById(items, next, (c) => c.localeId));
return this.simulate(() => next);
}
deleteLocalization(localeId: string): Observable<void> {
this.localizations.update((items) => items.filter((c) => c.localeId !== localeId));
return this.simulate(() => undefined);
}
listIncidents(options: NotifyQueryOptions = {}): Observable<NotifyIncidentsResponse> {
const traceId = options.traceId ?? this.traceId();
const items = this.incidents();
return this.simulate(() => ({
items,
nextPageToken: null,
total: items.length,
traceId,
}));
}
getIncident(incidentId: string, options: NotifyQueryOptions = {}): Observable<NotifyIncident> {
const found = this.incidents().find((i) => i.incidentId === incidentId);
if (!found) {
return defer(() => {
throw new Error(`Incident ${incidentId} not found`);
});
}
return this.simulate(() => found);
}
acknowledgeIncident(incidentId: string, request: AckRequest, options: NotifyQueryOptions = {}): Observable<AckResponse> {
const traceId = options.traceId ?? this.traceId();
const now = new Date().toISOString();
this.incidents.update((items) =>
items.map((incident) =>
incident.incidentId !== incidentId
? incident
: {
...incident,
status: 'acknowledged',
acknowledgedAt: now,
acknowledgedBy: 'ui@stella-ops.org',
updatedAt: now,
}
)
);
return this.simulate(() => ({
incidentId,
acknowledged: !!request.ackToken,
acknowledgedAt: now,
acknowledgedBy: 'ui@stella-ops.org',
traceId,
}));
}
private enrichChannel(channel: NotifyChannel): NotifyChannel {
const now = new Date().toISOString();
@@ -163,7 +378,7 @@ export class MockNotifyApiService implements NotifyApi {
};
}
private enrichRule(rule: NotifyRule): NotifyRule {
private enrichRule(rule: NotifyRule): NotifyRule {
const now = new Date().toISOString();
const current = this.rules().find((r) => r.ruleId === rule.ruleId);
return {
@@ -184,7 +399,91 @@ export class MockNotifyApiService implements NotifyApi {
updatedBy: 'ui@stella-ops.org',
updatedAt: now,
};
}
}
private enrichDigestSchedule(schedule: DigestSchedule): DigestSchedule {
const now = new Date().toISOString();
const current = this.digestSchedules().find((s) => s.scheduleId === schedule.scheduleId);
return {
scheduleId: schedule.scheduleId || this.randomId('dsc'),
tenantId: schedule.tenantId || mockNotifyTenant,
name: schedule.name,
description: schedule.description,
frequency: schedule.frequency,
timezone: schedule.timezone,
hour: schedule.hour,
dayOfWeek: schedule.dayOfWeek,
enabled: schedule.enabled,
createdAt: current?.createdAt ?? schedule.createdAt ?? now,
updatedAt: now,
};
}
private enrichQuietHours(quietHours: QuietHours): QuietHours {
const now = new Date().toISOString();
const current = this.quietHours().find((q) => q.quietHoursId === quietHours.quietHoursId);
return {
quietHoursId: quietHours.quietHoursId || this.randomId('qhr'),
tenantId: quietHours.tenantId || mockNotifyTenant,
name: quietHours.name,
description: quietHours.description,
windows: quietHours.windows ?? [],
exemptions: quietHours.exemptions ?? [],
enabled: quietHours.enabled,
createdAt: current?.createdAt ?? quietHours.createdAt ?? now,
updatedAt: now,
};
}
private enrichThrottleConfig(config: ThrottleConfig): ThrottleConfig {
const now = new Date().toISOString();
const current = this.throttleConfigs().find((c) => c.throttleId === config.throttleId);
return {
throttleId: config.throttleId || this.randomId('thr'),
tenantId: config.tenantId || mockNotifyTenant,
name: config.name,
description: config.description,
windowSeconds: config.windowSeconds,
maxEvents: config.maxEvents,
burstLimit: config.burstLimit,
enabled: config.enabled,
createdAt: current?.createdAt ?? config.createdAt ?? now,
updatedAt: now,
};
}
private enrichEscalationPolicy(policy: EscalationPolicy): EscalationPolicy {
const now = new Date().toISOString();
const current = this.escalationPolicies().find((p) => p.policyId === policy.policyId);
return {
policyId: policy.policyId || this.randomId('esp'),
tenantId: policy.tenantId || mockNotifyTenant,
name: policy.name,
description: policy.description,
levels: policy.levels ?? [],
enabled: policy.enabled,
createdAt: current?.createdAt ?? policy.createdAt ?? now,
updatedAt: now,
};
}
private enrichLocalization(config: LocalizationConfig): LocalizationConfig {
const now = new Date().toISOString();
const current = this.localizations().find((c) => c.localeId === config.localeId);
return {
localeId: config.localeId || this.randomId('loc'),
tenantId: config.tenantId || mockNotifyTenant,
locale: config.locale,
name: config.name,
templates: config.templates ?? {},
dateFormat: config.dateFormat,
timeFormat: config.timeFormat,
timezone: config.timezone,
enabled: config.enabled,
createdAt: current?.createdAt ?? config.createdAt ?? now,
updatedAt: now,
};
}
private appendDeliveryFromPreview(
channelId: string,
@@ -277,7 +576,7 @@ function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function cryptoRandomUuid(): string {
function cryptoRandomUuid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
@@ -287,4 +586,22 @@ function cryptoRandomUuid(): string {
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
}
function normalizeDigestFrequency(value?: string): DigestFrequency {
switch ((value ?? 'instant').toLowerCase()) {
case 'hourly':
case '1h':
case '60m':
return 'hourly';
case 'daily':
case '1d':
case '24h':
return 'daily';
case 'weekly':
case '1w':
return 'weekly';
default:
return 'instant';
}
}

View File

@@ -1,4 +1,4 @@
{
"status": "interrupted",
"status": "passed",
"failedTests": []
}

View File

@@ -0,0 +1,4 @@
{
"url": "http://127.0.0.1:4400/console/profile",
"violations": []
}

View File

@@ -0,0 +1,4 @@
{
"url": "http://127.0.0.1:4400/graph",
"violations": []
}

View File

@@ -3,9 +3,36 @@ import AxeBuilder from '@axe-core/playwright';
import fs from 'node:fs';
import path from 'node:path';
import { policyAuthorSession } from '../../src/app/testing';
const shouldFail = process.env.FAIL_ON_A11Y === '1';
const reportDir = path.join(process.cwd(), 'test-results');
const mockConfig = {
authority: {
issuer: 'https://authority.local',
clientId: 'stellaops-ui',
authorizeEndpoint: 'https://authority.local/connect/authorize',
tokenEndpoint: 'https://authority.local/connect/token',
logoutEndpoint: 'https://authority.local/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope:
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
audience: 'https://scanner.local',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: 'https://authority.local',
scanner: 'https://scanner.local',
policy: 'https://scanner.local',
concelier: 'https://concelier.local',
attestor: 'https://attestor.local',
},
quickstartMode: true,
};
async function writeReport(filename: string, data: unknown) {
fs.mkdirSync(reportDir, { recursive: true });
fs.writeFileSync(path.join(reportDir, filename), JSON.stringify(data, null, 2));
@@ -16,7 +43,7 @@ async function runA11y(url: string, page: Page) {
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
const violations = [...results.violations].sort((a, b) => a.id.localeCompare(b.id));
await writeReport(
`a11y-${url.replace(/\\W+/g, '_') || 'home'}.json`,
`a11y-${url.replace(/\W+/g, '_') || 'home'}.json`,
{ url: page.url(), violations }
);
if (shouldFail) {
@@ -26,6 +53,25 @@ async function runA11y(url: string, page: Page) {
}
test.describe('a11y-smoke', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors in restricted contexts
}
(window as any).__stellaopsTestSession = session;
}, policyAuthorSession);
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
await page.route('https://authority.local/**', (route) => route.abort());
});
test('home page baseline', async ({ page }, testInfo) => {
const violations = await runA11y('/', page);
testInfo.annotations.push({

View File

@@ -1,9 +1,8 @@
import { expect, test } from '@playwright/test';
import { policyAuthorSession } from '../src/app/testing';
const mockConfig = {
authority: {
issuer: 'https://authority.local',
const mockConfig = {
authority: {
issuer: 'https://authority.local',
clientId: 'stellaops-ui',
authorizeEndpoint: 'https://authority.local/connect/authorize',
tokenEndpoint: 'https://authority.local/connect/token',
@@ -16,60 +15,57 @@ const mockConfig = {
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: 'https://authority.local',
scanner: 'https://scanner.local',
policy: 'https://scanner.local',
concelier: 'https://concelier.local',
attestor: 'https://attestor.local',
},
};
apiBaseUrls: {
authority: 'https://authority.local',
scanner: 'https://scanner.local',
policy: 'https://scanner.local',
concelier: 'https://concelier.local',
attestor: 'https://attestor.local',
},
quickstartMode: true,
};
test.beforeEach(async ({ page }) => {
page.on('console', (message) => {
// bubble up browser logs for debugging
console.log('[browser]', message.type(), message.text());
});
page.on('pageerror', (error) => {
console.log('[pageerror]', error.message);
});
await page.addInitScript(() => {
// Capture attempted redirects so the test can assert against them.
(window as any).__stellaopsAssignedUrls = [];
const originalAssign = window.location.assign.bind(window.location);
window.location.assign = (url: string | URL) => {
(window as any).__stellaopsAssignedUrls.push(url.toString());
};
window.sessionStorage.clear();
// Seed a default Policy Studio author session so guarded routes load in e2e
(window as any).__stellaopsTestSession = policyAuthorSession;
page.on('console', (message) => {
// bubble up browser logs for debugging
console.log('[browser]', message.type(), message.text());
});
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
await page.route('https://authority.local/**', (route) => route.abort());
});
test('sign-in flow builds Authority authorization URL', async ({ page }) => {
await page.goto('/');
const signInButton = page.getByRole('button', { name: /sign in/i });
await expect(signInButton).toBeVisible();
const [request] = await Promise.all([
page.waitForRequest('https://authority.local/connect/authorize*'),
signInButton.click(),
]);
const authorizeUrl = new URL(request.url());
expect(authorizeUrl.origin).toBe('https://authority.local');
expect(authorizeUrl.pathname).toBe('/connect/authorize');
expect(authorizeUrl.searchParams.get('client_id')).toBe('stellaops-ui');
});
page.on('pageerror', (error) => {
console.log('[pageerror]', error.message);
});
await page.addInitScript(() => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors in restricted contexts
}
(window as any).__stellaopsTestSession = undefined;
});
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
await page.route('https://authority.local/**', (route) => route.abort());
});
test('sign-in flow builds Authority authorization URL', async ({ page }) => {
await page.goto('/');
const signInButton = page.getByRole('button', { name: /sign in/i });
await expect(signInButton).toBeVisible();
const [request] = await Promise.all([
page.waitForRequest('https://authority.local/connect/authorize*'),
signInButton.click({ noWaitAfter: true }),
]);
const authorizeUrl = new URL(request.url());
expect(authorizeUrl.origin).toBe('https://authority.local');
expect(authorizeUrl.pathname).toBe('/connect/authorize');
expect(authorizeUrl.searchParams.get('client_id')).toBe('stellaops-ui');
});
test('callback without pending state surfaces error message', async ({ page }) => {
await page.route('https://authority.local/**', (route) =>