feat: Add VEX Lens CI and Load Testing Plan
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
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

- Introduced a comprehensive CI job structure for VEX Lens, including build, test, linting, and load testing.
- Defined load test parameters and SLOs for VEX Lens API and Issuer Directory.
- Created Grafana dashboards and alerting mechanisms for monitoring API performance and error rates.
- Established offline posture guidelines for CI jobs and load testing.

feat: Implement deterministic projection verification script

- Added `verify_projection.sh` script for verifying the integrity of projection exports against expected hashes.
- Ensured robust error handling for missing files and hash mismatches.

feat: Develop Vuln Explorer CI and Ops Plan

- Created CI jobs for Vuln Explorer, including build, test, and replay verification.
- Implemented backup and disaster recovery strategies for MongoDB and Redis.
- Established Merkle anchoring verification and automation for ledger projector.

feat: Introduce EventEnvelopeHasher for hashing event envelopes

- Implemented `EventEnvelopeHasher` to compute SHA256 hashes for event envelopes.

feat: Add Risk Store and Dashboard components

- Developed `RiskStore` for managing risk data and state.
- Created `RiskDashboardComponent` for displaying risk profiles with filtering capabilities.
- Implemented unit tests for `RiskStore` and `RiskDashboardComponent`.

feat: Enhance Vulnerability Detail Component

- Developed `VulnerabilityDetailComponent` for displaying detailed information about vulnerabilities.
- Implemented error handling for missing vulnerability IDs and loading failures.
This commit is contained in:
StellaOps Bot
2025-12-02 07:18:28 +02:00
parent 44171930ff
commit 885ce86af4
83 changed files with 2090 additions and 97 deletions

View File

@@ -5,7 +5,7 @@
| 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-RISK-66-001 | DOING (2025-12-02) | Added risk gateway HTTP client (trace-id headers), store, `/risk` dashboard with filters and vuln link, auth guard; added `/vulnerabilities/:vulnId` detail; risk/vuln providers switch via quickstart; awaiting gateway endpoints/test harness. |
| 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`). |

View File

@@ -24,6 +24,9 @@
<a routerLink="/notify" routerLinkActive="active">
Notify
</a>
<a routerLink="/risk" routerLinkActive="active">
Risk
</a>
<a routerLink="/welcome" routerLinkActive="active">
Welcome
</a>

View File

@@ -43,26 +43,26 @@
letter-spacing: 0.02em;
}
.app-nav {
display: flex;
gap: 1rem;
a {
color: rgba(248, 250, 252, 0.8);
text-decoration: none;
font-size: 0.95rem;
padding: 0.35rem 0.75rem;
border-radius: 9999px;
transition: background-color 0.2s ease, color 0.2s ease;
&.active,
&:hover,
&:focus-visible {
color: #0f172a;
background-color: rgba(248, 250, 252, 0.9);
}
}
}
.app-nav {
display: flex;
gap: 1rem;
a {
color: rgba(248, 250, 252, 0.8);
text-decoration: none;
font-size: 0.95rem;
padding: 0.35rem 0.75rem;
border-radius: 9999px;
transition: background-color 0.2s ease, color 0.2s ease;
&.active,
&:hover,
&:focus-visible {
color: #0f172a;
background-color: rgba(248, 250, 252, 0.9);
}
}
}
.app-auth {
display: flex;

View File

@@ -14,13 +14,14 @@ import {
DEFAULT_EVENT_SOURCE_FACTORY,
EVENT_SOURCE_FACTORY,
} from './core/api/console-status.client';
import {
NOTIFY_API,
NOTIFY_API_BASE_URL,
import {
NOTIFY_API,
NOTIFY_API_BASE_URL,
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 { 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';
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';
@@ -88,9 +89,38 @@ export const appConfig: ApplicationConfig = {
},
},
RiskHttpClient,
MockRiskApi,
{
provide: RISK_API,
useExisting: RiskHttpClient,
deps: [AppConfigService, RiskHttpClient, MockRiskApi],
useFactory: (config: AppConfigService, http: RiskHttpClient, mock: MockRiskApi) =>
config.config.quickstartMode ? mock : http,
},
{
provide: VULNERABILITY_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const authorityBase = config.config.apiBaseUrls.authority;
try {
return new URL('/vuln', authorityBase).toString();
} catch {
const normalized = authorityBase.endsWith('/')
? authorityBase.slice(0, -1)
: authorityBase;
return `${normalized}/vuln`;
}
},
},
VulnerabilityHttpClient,
MockVulnerabilityApiService,
{
provide: VULNERABILITY_API,
deps: [AppConfigService, VulnerabilityHttpClient, MockVulnerabilityApiService],
useFactory: (
config: AppConfigService,
http: VulnerabilityHttpClient,
mock: MockVulnerabilityApiService
) => (config.config.quickstartMode ? mock : http),
},
{
provide: NOTIFY_API_BASE_URL,

View File

@@ -29,13 +29,29 @@ export const routes: Routes = [
(m) => m.ScanDetailPageComponent
),
},
{
path: 'welcome',
loadComponent: () =>
import('./features/welcome/welcome-page.component').then(
(m) => m.WelcomePageComponent
),
},
{
path: 'welcome',
loadComponent: () =>
import('./features/welcome/welcome-page.component').then(
(m) => m.WelcomePageComponent
),
},
{
path: 'risk',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/risk/risk-dashboard.component').then(
(m) => m.RiskDashboardComponent
),
},
{
path: 'vulnerabilities/:vulnId',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/vulnerabilities/vulnerability-detail.component').then(
(m) => m.VulnerabilityDetailComponent
),
},
{
path: 'notify',
loadComponent: () =>

View File

@@ -18,7 +18,8 @@ export class RiskHttpClient implements RiskApi {
list(options: RiskQueryOptions): Observable<RiskResultPage> {
const tenant = this.resolveTenant(options.tenantId);
const headers = this.buildHeaders(tenant, options.projectId, options.traceId);
const traceId = options.traceId ?? this.generateTraceId();
const headers = this.buildHeaders(tenant, options.projectId, traceId);
let params = new HttpParams();
if (options.page) params = params.set('page', options.page);
@@ -28,12 +29,19 @@ export class RiskHttpClient implements RiskApi {
return this.http
.get<RiskResultPage>(`${this.baseUrl}/risk`, { headers, params })
.pipe(map((page) => ({ ...page, page: page.page ?? 1, pageSize: page.pageSize ?? 20 })));
.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);
const traceId = options.traceId ?? this.generateTraceId();
const headers = this.buildHeaders(tenant, options.projectId, traceId);
return this.http
.get<RiskStats>(`${this.baseUrl}/risk/status`, { headers })
@@ -52,6 +60,13 @@ export class RiskHttpClient implements RiskApi {
return headers;
}
private generateTraceId(): string {
// Lightweight ULID-like generator (time + random) for trace correlation.
const time = Date.now().toString(36);
const rand = crypto.getRandomValues(new Uint32Array(1))[0].toString(36).padStart(6, '0');
return `${time}-${rand}`;
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {

View File

@@ -0,0 +1,73 @@
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { RISK_API } from './risk.client';
import { RiskQueryOptions, RiskResultPage, RiskStats } from './risk.models';
import { RiskStore } from './risk.store';
describe('RiskStore', () => {
let store: RiskStore;
let apiSpy: jasmine.SpyObj<any>;
const defaultOptions: RiskQueryOptions = {
tenantId: 'acme-tenant',
page: 1,
pageSize: 10,
};
beforeEach(() => {
apiSpy = jasmine.createSpyObj('RiskApi', ['list', 'stats']);
TestBed.configureTestingModule({
providers: [
RiskStore,
{ provide: RISK_API, useValue: apiSpy },
],
});
store = TestBed.inject(RiskStore);
});
it('stores list results and clears loading flag', () => {
const page: RiskResultPage = { items: [], total: 0, page: 1, pageSize: 10 };
apiSpy.list.and.returnValue(of(page));
store.fetchList(defaultOptions);
expect(store.loading()).toBeFalse();
expect(store.list()).toEqual(page);
expect(store.error()).toBeNull();
});
it('captures errors from list call', () => {
apiSpy.list.and.returnValue(throwError(() => new Error('boom')));
store.fetchList(defaultOptions);
expect(store.error()).toBe('boom');
});
it('stores stats results', () => {
const stats: RiskStats = {
countsBySeverity: { none: 0, info: 0, low: 1, medium: 0, high: 1, critical: 0 },
lastComputation: '2025-11-30T00:00:00Z',
};
apiSpy.stats.and.returnValue(of(stats));
store.fetchStats({ tenantId: 'acme-tenant' });
expect(store.stats()).toEqual(stats);
expect(store.error()).toBeNull();
});
it('clear resets state', () => {
apiSpy.list.and.returnValue(of({ items: [], total: 0, page: 1, pageSize: 10 }));
store.fetchList(defaultOptions);
store.clear();
expect(store.list()).toBeNull();
expect(store.stats()).toBeNull();
expect(store.error()).toBeNull();
expect(store.loading()).toBeFalse();
});
});

View File

@@ -0,0 +1,53 @@
import { inject, Injectable, Signal, computed, signal } from '@angular/core';
import { finalize } from 'rxjs/operators';
import { RISK_API, RiskApi } from './risk.client';
import { RiskQueryOptions, RiskResultPage, RiskStats } from './risk.models';
@Injectable({ providedIn: 'root' })
export class RiskStore {
private readonly riskApi = inject<RiskApi>(RISK_API);
private readonly listSignal = signal<RiskResultPage | null>(null);
private readonly statsSignal = signal<RiskStats | null>(null);
private readonly loadingSignal = signal(false);
private readonly errorSignal = signal<string | null>(null);
readonly list: Signal<RiskResultPage | null> = this.listSignal.asReadonly();
readonly stats: Signal<RiskStats | null> = this.statsSignal.asReadonly();
readonly loading: Signal<boolean> = this.loadingSignal.asReadonly();
readonly error: Signal<string | null> = this.errorSignal.asReadonly();
readonly hasData: Signal<boolean> = computed(() => !!this.listSignal());
fetchList(options: RiskQueryOptions): void {
this.loadingSignal.set(true);
this.errorSignal.set(null);
this.riskApi
.list({ ...options })
.pipe(finalize(() => this.loadingSignal.set(false)))
.subscribe({
next: (page) => this.listSignal.set(page),
error: (err: unknown) => this.errorSignal.set(this.normalizeError(err)),
});
}
fetchStats(options: Pick<RiskQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): void {
this.riskApi.stats(options).subscribe({
next: (stats) => this.statsSignal.set(stats),
error: (err: unknown) => this.errorSignal.set(this.normalizeError(err)),
});
}
clear(): void {
this.listSignal.set(null);
this.statsSignal.set(null);
this.errorSignal.set(null);
this.loadingSignal.set(false);
}
private normalizeError(err: unknown): string {
if (err instanceof Error) return err.message;
return 'Unknown error fetching risk data';
}
}

View File

@@ -0,0 +1,66 @@
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 {
VulnerabilitiesQueryOptions,
VulnerabilitiesResponse,
Vulnerability,
VulnerabilityStats,
} from './vulnerability.models';
import { VulnerabilityApi } from './vulnerability.client';
export const VULNERABILITY_API_BASE_URL = new InjectionToken<string>('VULNERABILITY_API_BASE_URL');
@Injectable({ providedIn: 'root' })
export class VulnerabilityHttpClient implements VulnerabilityApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
@Inject(VULNERABILITY_API_BASE_URL) private readonly baseUrl: string
) {}
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
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?.status) params = params.set('status', options.status);
if (options?.search) params = params.set('search', options.search);
return this.http
.get<VulnerabilitiesResponse>(`${this.baseUrl}/vuln`, { headers, params })
.pipe(map((resp) => ({ ...resp, page: resp.page ?? 1, pageSize: resp.pageSize ?? 20 })));
}
getVulnerability(vulnId: string): Observable<Vulnerability> {
const tenant = this.resolveTenant();
const headers = this.buildHeaders(tenant, undefined, undefined);
return this.http.get<Vulnerability>(`${this.baseUrl}/vuln/${encodeURIComponent(vulnId)}`, { headers });
}
getStats(): Observable<VulnerabilityStats> {
const tenant = this.resolveTenant();
const headers = this.buildHeaders(tenant, undefined, undefined);
return this.http.get<VulnerabilityStats>(`${this.baseUrl}/vuln/status`, { headers });
}
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('VulnerabilityHttpClient requires an active tenant identifier.');
}
return tenant;
}
}

View File

@@ -0,0 +1,15 @@
import { inject } from '@angular/core';
import { CanMatchFn, Router } from '@angular/router';
import { AuthSessionStore } from './auth-session.store';
/**
* Simple guard to prevent unauthenticated navigation to protected routes.
* Redirects to /welcome when no active session is present.
*/
export const requireAuthGuard: CanMatchFn = () => {
const auth = inject(AuthSessionStore);
const router = inject(Router);
const isAuthenticated = auth.isAuthenticated();
return isAuthenticated ? true : router.createUrlTree(['/welcome']);
};

View File

@@ -0,0 +1 @@
export * from './risk-dashboard.component';

View File

@@ -0,0 +1,70 @@
<section class="risk-dashboard">
<header class="risk-dashboard__header">
<div>
<p class="eyebrow">Gateway · Risk</p>
<h1>Risk Profiles</h1>
<p class="sub">Tenant-scoped risk posture with deterministic ordering.</p>
</div>
<div class="status" *ngIf="loading(); else loadedState">Loading…</div>
<ng-template #loadedState>
<div class="status status--ok" *ngIf="!error(); else errorState">Up to date</div>
</ng-template>
<ng-template #errorState>
<div class="status status--error">{{ error() }}</div>
</ng-template>
</header>
<section class="risk-dashboard__stats" *ngIf="stats() as s">
<div class="stat" *ngFor="let sev of severities">
<div class="stat__label">{{ sev | titlecase }}</div>
<div class="stat__value" [class]="'sev sev--' + sev">{{ s.countsBySeverity[sev] ?? 0 }}</div>
</div>
<div class="stat stat--meta">
<div class="stat__label">Last Computation</div>
<div class="stat__value">{{ s.lastComputation }}</div>
</div>
</section>
<section class="risk-dashboard__filters">
<label>
Severity
<select [(ngModel)]="selectedSeverity()" (ngModelChange)="selectedSeverity.set($event); applyFilters()">
<option value="">All</option>
<option *ngFor="let sev of severities" [value]="sev">{{ sev | titlecase }}</option>
</select>
</label>
<label>
Search
<input type="search" [ngModel]="search()" (ngModelChange)="search.set($event); applyFilters()" placeholder="Title contains" />
</label>
<button type="button" (click)="applyFilters()">Refresh</button>
</section>
<section class="risk-dashboard__table" *ngIf="list() as page">
<table>
<thead>
<tr>
<th>Severity</th>
<th>Score</th>
<th>Title</th>
<th>Description</th>
<th>Evaluated</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let risk of page.items; trackBy: trackRisk">
<td><span class="pill" [class]="'pill--' + risk.severity">{{ risk.severity }}</span></td>
<td>{{ risk.score }}</td>
<td>{{ risk.title }}</td>
<td>{{ risk.description }}</td>
<td>{{ risk.lastEvaluatedAt }}</td>
<td>
<a [routerLink]="['/vulnerabilities', risk.id]" class="link">View</a>
</td>
</tr>
</tbody>
</table>
<p class="meta">Showing {{ page.items.length }} of {{ page.total }} risks.</p>
</section>
</section>

View File

@@ -0,0 +1,162 @@
.risk-dashboard {
display: grid;
gap: 1.5rem;
padding: 1.5rem;
}
.risk-dashboard__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.eyebrow {
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6b7280;
margin: 0 0 0.25rem;
}
.sub {
margin: 0.25rem 0 0;
color: #4b5563;
}
.status {
padding: 0.35rem 0.75rem;
border-radius: 999px;
font-size: 0.9rem;
border: 1px solid #d1d5db;
color: #374151;
}
.status--ok {
border-color: #10b981;
color: #065f46;
background: #ecfdf3;
}
.status--error {
border-color: #f43f5e;
color: #7f1d1d;
background: #fef2f2;
}
.risk-dashboard__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.75rem;
}
.risk-dashboard__filters {
display: flex;
gap: 1rem;
align-items: flex-end;
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.9rem;
color: #374151;
}
input,
select,
button {
padding: 0.4rem 0.6rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.95rem;
}
button {
background: #0f172a;
color: #f8fafc;
cursor: pointer;
border-color: #0f172a;
}
}
.stat {
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
background: #ffffff;
}
.stat__label {
font-size: 0.9rem;
color: #6b7280;
}
.stat__value {
font-size: 1.4rem;
font-weight: 600;
margin-top: 0.25rem;
}
.sev {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.sev--critical { color: #991b1b; }
.sev--high { color: #b45309; }
.sev--medium { color: #92400e; }
.sev--low { color: #047857; }
.sev--info { color: #1d4ed8; }
.sev--none { color: #374151; }
.risk-dashboard__table table {
width: 100%;
border-collapse: collapse;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
overflow: hidden;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
th {
background: #f9fafb;
font-weight: 600;
color: #374151;
}
tr:last-child td {
border-bottom: none;
}
.pill {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.85rem;
border: 1px solid transparent;
}
.pill--critical { background: #fef2f2; color: #991b1b; border-color: #fecdd3; }
.pill--high { background: #fff7ed; color: #b45309; border-color: #fed7aa; }
.pill--medium { background: #fffbeb; color: #92400e; border-color: #fde68a; }
.pill--low { background: #ecfdf3; color: #065f46; border-color: #bbf7d0; }
.pill--info { background: #eef2ff; color: #4338ca; border-color: #e0e7ff; }
.pill--none { background: #f3f4f6; color: #374151; border-color: #e5e7eb; }
.meta {
margin-top: 0.5rem;
color: #6b7280;
}
@media (max-width: 768px) {
.risk-dashboard__header { flex-direction: column; align-items: flex-start; }
table { display: block; overflow-x: auto; }
}

View File

@@ -0,0 +1,52 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { RiskDashboardComponent } from './risk-dashboard.component';
import { RiskStore } from '../../core/api/risk.store';
import { RiskResultPage, RiskStats } from '../../core/api/risk.models';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
class MockRiskStore {
list = signal<RiskResultPage | null>({ items: [], total: 0, page: 1, pageSize: 20 });
stats = signal<RiskStats | null>({
countsBySeverity: { none: 0, info: 0, low: 0, medium: 0, high: 1, critical: 1 },
lastComputation: '2025-11-30T00:00:00Z',
});
loading = signal(false);
error = signal<string | null>(null);
fetchList = jasmine.createSpy('fetchList');
fetchStats = jasmine.createSpy('fetchStats');
}
class MockAuthSessionStore {
getActiveTenantId(): string | null {
return 'acme-tenant';
}
}
describe('RiskDashboardComponent', () => {
let component: RiskDashboardComponent;
let fixture: ComponentFixture<RiskDashboardComponent>;
let store: MockRiskStore;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RiskDashboardComponent],
providers: [
{ provide: RiskStore, useClass: MockRiskStore },
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
],
}).compileComponents();
fixture = TestBed.createComponent(RiskDashboardComponent);
component = fixture.componentInstance;
store = TestBed.inject(RiskStore) as unknown as MockRiskStore;
fixture.detectChanges();
});
it('renders without errors and triggers fetches', () => {
expect(component).toBeTruthy();
expect(store.fetchList).toHaveBeenCalled();
expect(store.fetchStats).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,18 @@
---
title: Risk Dashboard
component: RiskDashboardComponent
---
```ts
import { RiskDashboardComponent } from './risk-dashboard.component';
```
The risk dashboard displays tenant-scoped risk profiles with severity counts and filtering.
### Mock Data (quickstart)
- Uses `MockRiskApi` when `quickstartMode` is true.
- Filters apply client-side via the store signal.
### Production
- Uses `RiskHttpClient` with gateway base URL and tenant/project headers.
- Auth guard enforces an active session; unauthenticated users are redirected to `/welcome`.

View File

@@ -0,0 +1,53 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
import { RiskStore } from '../../core/api/risk.store';
import { RiskProfile, RiskSeverity } from '../../core/api/risk.models';
@Component({
standalone: true,
selector: 'st-risk-dashboard',
imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './risk-dashboard.component.html',
styleUrl: './risk-dashboard.component.scss',
})
export class RiskDashboardComponent implements OnInit {
private readonly store = inject(RiskStore);
private readonly authSession = inject(AuthSessionStore);
readonly list = this.store.list;
readonly stats = this.store.stats;
readonly loading = this.store.loading;
readonly error = this.store.error;
readonly severities: RiskSeverity[] = ['critical', 'high', 'medium', 'low', 'info', 'none'];
readonly selectedSeverity = signal<RiskSeverity | ''>('');
readonly search = signal('');
readonly severityCounts = computed(() => this.store.stats()?.countsBySeverity ?? {});
ngOnInit(): void {
const tenant = this.authSession.getActiveTenantId() ?? 'tenant-dev';
this.store.fetchList({ tenantId: tenant, page: 1, pageSize: 20 });
this.store.fetchStats({ tenantId: tenant });
}
applyFilters(): void {
const tenant = this.authSession.getActiveTenantId() ?? 'tenant-dev';
this.store.fetchList({
tenantId: tenant,
page: 1,
pageSize: 20,
severity: this.selectedSeverity() || undefined,
search: this.search().trim() || undefined,
});
}
trackRisk(_index: number, risk: RiskProfile): string {
return risk.id;
}
}

View File

@@ -0,0 +1,31 @@
<section class="vuln-detail" *ngIf="vulnerability() as vuln; else loadingOrError">
<header>
<p class="eyebrow">Vulnerability</p>
<h1>{{ vuln.title }}</h1>
<p class="meta">{{ vuln.cveId }} · Severity {{ vuln.severity | titlecase }} · CVSS {{ vuln.cvssScore }}</p>
<p class="sub">{{ vuln.description }}</p>
</header>
<section class="vuln-detail__section">
<h2>Affected Components</h2>
<ul>
<li *ngFor="let comp of vuln.affectedComponents">
<strong>{{ comp.name }}</strong> {{ comp.version }} → fix {{ comp.fixedVersion || 'n/a' }}
</li>
</ul>
</section>
<section class="vuln-detail__section" *ngIf="vuln.references?.length">
<h2>References</h2>
<ul>
<li *ngFor="let ref of vuln.references">{{ ref }}</li>
</ul>
</section>
<a routerLink="/risk" class="link">Back to Risk</a>
</section>
<ng-template #loadingOrError>
<p *ngIf="error(); else loading">{{ error() }}</p>
<ng-template #loading><p>Loading…</p></ng-template>
</ng-template>

View File

@@ -0,0 +1,42 @@
.vuln-detail {
display: grid;
gap: 1.25rem;
padding: 1.5rem;
background: #ffffff;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6b7280;
margin: 0;
}
.meta {
color: #4b5563;
margin: 0.35rem 0;
}
.sub {
margin: 0;
color: #374151;
}
.vuln-detail__section h2 {
margin: 0 0 0.35rem;
font-size: 1.05rem;
color: #111827;
}
.vuln-detail__section ul {
margin: 0;
padding-left: 1.25rem;
color: #374151;
}
.link {
color: #0f172a;
text-decoration: underline;
}

View File

@@ -0,0 +1,34 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { VULNERABILITY_API, VulnerabilityApi } from '../../core/api/vulnerability.client';
import { Vulnerability } from '../../core/api/vulnerability.models';
@Component({
standalone: true,
selector: 'st-vulnerability-detail',
imports: [CommonModule, RouterLink],
templateUrl: './vulnerability-detail.component.html',
styleUrl: './vulnerability-detail.component.scss',
providers: [],
})
export class VulnerabilityDetailComponent implements OnInit {
private readonly api = inject<VulnerabilityApi>(VULNERABILITY_API);
private readonly route = inject(ActivatedRoute);
readonly vulnerability = signal<Vulnerability | null>(null);
readonly error = signal<string | null>(null);
ngOnInit(): void {
const vulnId = this.route.snapshot.paramMap.get('vulnId');
if (!vulnId) {
this.error.set('Missing vulnerability id');
return;
}
this.api.getVulnerability(vulnId).subscribe({
next: (v) => this.vulnerability.set(v),
error: () => this.error.set('Unable to load vulnerability'),
});
}
}

View File

@@ -9,11 +9,7 @@ import {
} from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
VULNERABILITY_API,
VulnerabilityApi,
MockVulnerabilityApiService,
} from '../../core/api/vulnerability.client';
import { VULNERABILITY_API, VulnerabilityApi } from '../../core/api/vulnerability.client';
import {
Vulnerability,
VulnerabilitySeverity,
@@ -67,11 +63,9 @@ const SEVERITY_ORDER: Record<VulnerabilitySeverity, number> = {
templateUrl: './vulnerability-explorer.component.html',
styleUrls: ['./vulnerability-explorer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: VULNERABILITY_API, useClass: MockVulnerabilityApiService },
],
})
export class VulnerabilityExplorerComponent implements OnInit {
providers: [],
})
export class VulnerabilityExplorerComponent implements OnInit {
private readonly api = inject<VulnerabilityApi>(VULNERABILITY_API);
// View state