feat: add Reachability Center and Why Drawer components with tests
- 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:
@@ -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 can’t 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. |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
196
src/Web/StellaOps.Web/src/app/core/api/advisories.client.ts
Normal file
196
src/Web/StellaOps.Web/src/app/core/api/advisories.client.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
48
src/Web/StellaOps.Web/src/app/core/api/advisories.models.ts
Normal file
48
src/Web/StellaOps.Web/src/app/core/api/advisories.models.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
275
src/Web/StellaOps.Web/src/app/core/api/advisory-ai.client.ts
Normal file
275
src/Web/StellaOps.Web/src/app/core/api/advisory-ai.client.ts
Normal 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')}`;
|
||||
}
|
||||
80
src/Web/StellaOps.Web/src/app/core/api/advisory-ai.models.ts
Normal file
80
src/Web/StellaOps.Web/src/app/core/api/advisory-ai.models.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
165
src/Web/StellaOps.Web/src/app/core/api/orchestrator.client.ts
Normal file
165
src/Web/StellaOps.Web/src/app/core/api/orchestrator.client.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
322
src/Web/StellaOps.Web/src/app/core/api/policy-evidence.client.ts
Normal file
322
src/Web/StellaOps.Web/src/app/core/api/policy-evidence.client.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
283
src/Web/StellaOps.Web/src/app/core/api/vex-evidence.client.ts
Normal file
283
src/Web/StellaOps.Web/src/app/core/api/vex-evidence.client.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
}));
|
||||
});
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"status": "interrupted",
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
4
src/Web/StellaOps.Web/test-results/a11y-_.json
Normal file
4
src/Web/StellaOps.Web/test-results/a11y-_.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"url": "http://127.0.0.1:4400/console/profile",
|
||||
"violations": []
|
||||
}
|
||||
4
src/Web/StellaOps.Web/test-results/a11y-_graph.json
Normal file
4
src/Web/StellaOps.Web/test-results/a11y-_graph.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"url": "http://127.0.0.1:4400/graph",
|
||||
"violations": []
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user