feat: Add UI benchmark driver and scenarios for graph interactions
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
- Introduced `ui_bench_driver.mjs` to read scenarios and fixture manifest, generating a deterministic run plan. - Created `ui_bench_plan.md` outlining the purpose, scope, and next steps for the benchmark. - Added `ui_bench_scenarios.json` containing various scenarios for graph UI interactions. - Implemented tests for CLI commands, ensuring bundle verification and telemetry defaults. - Developed schemas for orchestrator components, including replay manifests and event envelopes. - Added mock API for risk management, including listing and statistics functionalities. - Implemented models for risk profiles and query options to support the new API.
This commit is contained in:
@@ -5,4 +5,8 @@
|
||||
| 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-CONSOLE-23-002 | DOING (2025-12-01) | Console status polling + SSE run stream client/store/UI added; tests pending once env fixed. |
|
||||
| WEB-RISK-66-001 | DOING (2025-12-01) | Added risk gateway mock client/models + tests; wire to real gateway once endpoints land. |
|
||||
| WEB-EXC-25-001 | TODO | Exceptions workflow CRUD pending policy scopes. |
|
||||
| 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`). |
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
NOTIFY_TENANT_ID,
|
||||
} from './core/api/notify.client';
|
||||
import { CONSOLE_API_BASE_URL } from './core/api/console-status.client';
|
||||
import { RISK_API } from './core/api/risk.client';
|
||||
import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client';
|
||||
import { AppConfigService } from './core/config/app-config.service';
|
||||
import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
|
||||
import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor';
|
||||
@@ -65,11 +67,31 @@ export const appConfig: ApplicationConfig = {
|
||||
}
|
||||
},
|
||||
},
|
||||
AuthorityConsoleApiHttpClient,
|
||||
{
|
||||
provide: AUTHORITY_CONSOLE_API,
|
||||
useExisting: AuthorityConsoleApiHttpClient,
|
||||
},
|
||||
AuthorityConsoleApiHttpClient,
|
||||
{
|
||||
provide: AUTHORITY_CONSOLE_API,
|
||||
useExisting: AuthorityConsoleApiHttpClient,
|
||||
},
|
||||
{
|
||||
provide: RISK_API_BASE_URL,
|
||||
deps: [AppConfigService],
|
||||
useFactory: (config: AppConfigService) => {
|
||||
const authorityBase = config.config.apiBaseUrls.authority;
|
||||
try {
|
||||
return new URL('/risk', authorityBase).toString();
|
||||
} catch {
|
||||
const normalized = authorityBase.endsWith('/')
|
||||
? authorityBase.slice(0, -1)
|
||||
: authorityBase;
|
||||
return `${normalized}/risk`;
|
||||
}
|
||||
},
|
||||
},
|
||||
RiskHttpClient,
|
||||
{
|
||||
provide: RISK_API,
|
||||
useExisting: RiskHttpClient,
|
||||
},
|
||||
{
|
||||
provide: NOTIFY_API_BASE_URL,
|
||||
useValue: '/api/v1/notify',
|
||||
|
||||
62
src/Web/StellaOps.Web/src/app/core/api/risk-http.client.ts
Normal file
62
src/Web/StellaOps.Web/src/app/core/api/risk-http.client.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { RiskApi } from './risk.client';
|
||||
import { RiskQueryOptions, RiskResultPage, RiskStats } from './risk.models';
|
||||
|
||||
export const RISK_API_BASE_URL = new InjectionToken<string>('RISK_API_BASE_URL');
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RiskHttpClient implements RiskApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly authSession: AuthSessionStore,
|
||||
@Inject(RISK_API_BASE_URL) private readonly baseUrl: string
|
||||
) {}
|
||||
|
||||
list(options: RiskQueryOptions): Observable<RiskResultPage> {
|
||||
const tenant = this.resolveTenant(options.tenantId);
|
||||
const headers = this.buildHeaders(tenant, options.projectId, options.traceId);
|
||||
|
||||
let params = new HttpParams();
|
||||
if (options.page) params = params.set('page', options.page);
|
||||
if (options.pageSize) params = params.set('pageSize', options.pageSize);
|
||||
if (options.severity) params = params.set('severity', options.severity);
|
||||
if (options.search) params = params.set('search', options.search);
|
||||
|
||||
return this.http
|
||||
.get<RiskResultPage>(`${this.baseUrl}/risk`, { headers, params })
|
||||
.pipe(map((page) => ({ ...page, page: page.page ?? 1, pageSize: page.pageSize ?? 20 })));
|
||||
}
|
||||
|
||||
stats(options: Pick<RiskQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<RiskStats> {
|
||||
const tenant = this.resolveTenant(options.tenantId);
|
||||
const headers = this.buildHeaders(tenant, options.projectId, options.traceId);
|
||||
|
||||
return this.http
|
||||
.get<RiskStats>(`${this.baseUrl}/risk/status`, { headers })
|
||||
.pipe(
|
||||
map((stats) => ({
|
||||
countsBySeverity: stats.countsBySeverity,
|
||||
lastComputation: stats.lastComputation ?? '1970-01-01T00:00:00Z',
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(tenantId: string, projectId?: string, traceId?: string): HttpHeaders {
|
||||
let headers = new HttpHeaders({ 'X-Stella-Tenant': tenantId });
|
||||
if (projectId) headers = headers.set('X-Stella-Project', projectId);
|
||||
if (traceId) headers = headers.set('X-Stella-Trace-Id', traceId);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private resolveTenant(tenantId?: string): string {
|
||||
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
|
||||
if (!tenant) {
|
||||
throw new Error('RiskHttpClient requires an active tenant identifier.');
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
}
|
||||
40
src/Web/StellaOps.Web/src/app/core/api/risk.client.spec.ts
Normal file
40
src/Web/StellaOps.Web/src/app/core/api/risk.client.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { MockRiskApi } from './risk.client';
|
||||
|
||||
describe('MockRiskApi', () => {
|
||||
let api: MockRiskApi;
|
||||
|
||||
beforeEach(() => {
|
||||
api = new MockRiskApi();
|
||||
});
|
||||
|
||||
it('requires tenantId for list', () => {
|
||||
expect(() => api.list({ tenantId: '' })).toThrow('tenantId is required');
|
||||
});
|
||||
|
||||
it('returns deterministic ordering by score then id', (done) => {
|
||||
api.list({ tenantId: 'acme-tenant', pageSize: 10 }).subscribe((page) => {
|
||||
const scores = page.items.map((r) => r.score);
|
||||
expect(scores).toEqual([...scores].sort((a, b) => b - a));
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by project and severity', (done) => {
|
||||
api
|
||||
.list({ tenantId: 'acme-tenant', projectId: 'proj-ops', severity: 'high' })
|
||||
.subscribe((page) => {
|
||||
expect(page.items.every((r) => r.projectId === 'proj-ops')).toBeTrue();
|
||||
expect(page.items.every((r) => r.severity === 'high')).toBeTrue();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('computes stats with zeroed severities present', (done) => {
|
||||
api.stats({ tenantId: 'acme-tenant' }).subscribe((stats) => {
|
||||
expect(stats.countsBySeverity.none).toBe(0);
|
||||
expect(stats.countsBySeverity.critical).toBeGreaterThan(0);
|
||||
expect(stats.lastComputation).toMatch(/T/);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
112
src/Web/StellaOps.Web/src/app/core/api/risk.client.ts
Normal file
112
src/Web/StellaOps.Web/src/app/core/api/risk.client.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, delay, map, of } from 'rxjs';
|
||||
|
||||
import { RiskProfile, RiskQueryOptions, RiskResultPage, RiskStats, RiskSeverity } from './risk.models';
|
||||
|
||||
export interface RiskApi {
|
||||
list(options: RiskQueryOptions): Observable<RiskResultPage>;
|
||||
stats(options: Pick<RiskQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<RiskStats>;
|
||||
}
|
||||
|
||||
export const RISK_API = new InjectionToken<RiskApi>('RISK_API');
|
||||
|
||||
const MOCK_RISKS: RiskProfile[] = [
|
||||
{
|
||||
id: 'risk-001',
|
||||
title: 'RCE on internet-facing API',
|
||||
description: 'Critical RCE on public API gateway impacting tenants acme, globally exposed.',
|
||||
severity: 'critical',
|
||||
score: 97,
|
||||
lastEvaluatedAt: '2025-11-30T12:00:00Z',
|
||||
tenantId: 'acme-tenant',
|
||||
},
|
||||
{
|
||||
id: 'risk-002',
|
||||
title: 'Expired token audience',
|
||||
description: 'Tokens minted without correct audience allow cross-tenant reuse.',
|
||||
severity: 'high',
|
||||
score: 81,
|
||||
lastEvaluatedAt: '2025-11-30T11:00:00Z',
|
||||
tenantId: 'acme-tenant',
|
||||
projectId: 'proj-ops',
|
||||
},
|
||||
{
|
||||
id: 'risk-003',
|
||||
title: 'Missing SBOM attestation',
|
||||
description: 'Builds lack SBOM attestations; export blocked in sealed mode.',
|
||||
severity: 'medium',
|
||||
score: 55,
|
||||
lastEvaluatedAt: '2025-11-29T19:30:00Z',
|
||||
tenantId: 'acme-tenant',
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockRiskApi implements RiskApi {
|
||||
list(options: RiskQueryOptions): Observable<RiskResultPage> {
|
||||
if (!options.tenantId) {
|
||||
throw new Error('tenantId is required');
|
||||
}
|
||||
|
||||
const page = options.page ?? 1;
|
||||
const pageSize = options.pageSize ?? 20;
|
||||
const filtered = MOCK_RISKS.filter((r) => {
|
||||
if (r.tenantId !== options.tenantId) {
|
||||
return false;
|
||||
}
|
||||
if (options.projectId && r.projectId !== options.projectId) {
|
||||
return false;
|
||||
}
|
||||
if (options.severity && r.severity !== options.severity) {
|
||||
return false;
|
||||
}
|
||||
if (options.search && !r.title.toLowerCase().includes(options.search.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const start = (page - 1) * pageSize;
|
||||
const items = filtered
|
||||
.slice()
|
||||
.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
|
||||
.slice(start, start + pageSize);
|
||||
|
||||
const response: RiskResultPage = {
|
||||
items,
|
||||
total: filtered.length,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
|
||||
return of(response).pipe(delay(50));
|
||||
}
|
||||
|
||||
stats(options: Pick<RiskQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<RiskStats> {
|
||||
if (!options.tenantId) {
|
||||
throw new Error('tenantId is required');
|
||||
}
|
||||
|
||||
const relevant = MOCK_RISKS.filter((r) => r.tenantId === options.tenantId);
|
||||
const emptyCounts: Record<RiskSeverity, number> = {
|
||||
none: 0,
|
||||
info: 0,
|
||||
low: 0,
|
||||
medium: 0,
|
||||
high: 0,
|
||||
critical: 0,
|
||||
};
|
||||
|
||||
const counts = relevant.reduce((acc, curr) => {
|
||||
acc[curr.severity] = (acc[curr.severity] ?? 0) + 1;
|
||||
return acc;
|
||||
}, { ...emptyCounts });
|
||||
|
||||
const lastEvaluatedAt = relevant
|
||||
.map((r) => r.lastEvaluatedAt)
|
||||
.sort()
|
||||
.reverse()[0] ?? '1970-01-01T00:00:00Z';
|
||||
|
||||
return of({ countsBySeverity: counts, lastComputation: lastEvaluatedAt }).pipe(delay(25));
|
||||
}
|
||||
}
|
||||
34
src/Web/StellaOps.Web/src/app/core/api/risk.models.ts
Normal file
34
src/Web/StellaOps.Web/src/app/core/api/risk.models.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type RiskSeverity = 'none' | 'info' | 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
export interface RiskProfile {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
severity: RiskSeverity;
|
||||
score: number;
|
||||
lastEvaluatedAt: string; // UTC ISO-8601
|
||||
tenantId: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface RiskResultPage {
|
||||
items: RiskProfile[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface RiskQueryOptions {
|
||||
tenantId: string;
|
||||
projectId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
severity?: RiskSeverity;
|
||||
search?: string;
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
export interface RiskStats {
|
||||
countsBySeverity: Record<RiskSeverity, number>;
|
||||
lastComputation: string; // UTC ISO-8601
|
||||
}
|
||||
Reference in New Issue
Block a user