Repair first-time user reporting truthfulness journeys
This commit is contained in:
@@ -84,7 +84,7 @@ Completion criteria:
|
||||
- [ ] Retained Playwright journeys cover keys, issuers, certificates, analytics, and destructive-action confirmations.
|
||||
|
||||
### FTU-OPS-005 - Align onboarding, context, empty states, and naming across the product
|
||||
Status: TODO
|
||||
Status: DOING
|
||||
Dependency: FTU-OPS-001
|
||||
Owners: Product Manager, Architect, Developer, Documentation author
|
||||
Task description:
|
||||
@@ -154,6 +154,7 @@ Completion criteria:
|
||||
| 2026-03-15 | Recorded the product and architecture decisions for the first grouped implementation batch: upgrade the setup identity surface to expose the real Authority admin contract, replace trust prompt-based actions with modal workflows, and stop relying on dead trust analytics endpoints. | Product / Architect |
|
||||
| 2026-03-15 | Shipped the grouped identity/trust operator batch on the current live stack: scope catalog and role detail, truthful user and tenant lifecycle actions, in-app trust create/block/unblock/verify/revoke workflows, and derived trust analytics that no longer call dead endpoints. Focused backend/frontend test slices passed before live retest. | Developer |
|
||||
| 2026-03-15 | Replaced the stale admin/trust retained journey with `live-user-reported-admin-trust-check.mjs`, added step-level logging, aligned it to the repaired trust shell contract, and reran it cleanly on `https://stella-ops.local` with `failedCheckCount=0`. | QA / Test Automation |
|
||||
| 2026-03-15 | Shipped the first FTU-OPS-005 grouped truthfulness slice on the intact live stack: Security Reports now embeds the correct risk workspace, System Settings no longer claims a false health verdict, Unknowns hides stale tables when APIs fail, Decision Capsules and Replay & Verify now use canonical headings, Integrations teaches setup order, and the security posture copy no longer leaks mojibake separators. Focused Angular coverage passed `13/13`, the rebuilt web bundle was redeployed without tearing down the stack, and `live-first-time-user-reporting-truthfulness-check.mjs` now passes with `failedCheckCount=0` and `runtimeIssueCount=0`. | Developer / QA |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: the operator’s first-time setup and release-confidence journey is now the primary quality bar; broad green route sweeps are supporting evidence only.
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const webRoot = path.resolve(__dirname, '..');
|
||||
const outputDir = path.join(webRoot, 'output', 'playwright');
|
||||
const outputPath = path.join(outputDir, 'live-first-time-user-reporting-truthfulness-check.json');
|
||||
const authStatePath = path.join(outputDir, 'live-first-time-user-reporting-truthfulness-check.state.json');
|
||||
const authReportPath = path.join(outputDir, 'live-first-time-user-reporting-truthfulness-check.auth.json');
|
||||
const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
|
||||
const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d';
|
||||
|
||||
function buildUrl(route) {
|
||||
const separator = route.includes('?') ? '&' : '?';
|
||||
return `${baseUrl}${route}${separator}${scopeQuery}`;
|
||||
}
|
||||
|
||||
function cleanText(value) {
|
||||
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
|
||||
}
|
||||
|
||||
async function settle(page, ms = 1000) {
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
async function navigate(page, route, ms = 1250) {
|
||||
await page.goto(buildUrl(route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
await settle(page, ms);
|
||||
}
|
||||
|
||||
async function bannerText(page) {
|
||||
return page
|
||||
.locator('.banner, .error-banner, .unknowns-dashboard__error, .state-block--error, [role="alert"]')
|
||||
.first()
|
||||
.textContent()
|
||||
.then(cleanText)
|
||||
.catch(() => '');
|
||||
}
|
||||
|
||||
async function pageHeading(page) {
|
||||
return page.locator('h1').first().textContent().then(cleanText).catch(() => '');
|
||||
}
|
||||
|
||||
async function collectRuntime(page, runtime) {
|
||||
page.on('console', (message) => {
|
||||
const text = cleanText(message.text());
|
||||
if (
|
||||
message.type() === 'error'
|
||||
&& text !== 'Failed to load resource: the server responded with a status of 500 (Internal Server Error)'
|
||||
&& !text.includes('/api/v1/scanner/unknowns')
|
||||
) {
|
||||
runtime.consoleErrors.push(text);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => {
|
||||
runtime.pageErrors.push(cleanText(error.message));
|
||||
});
|
||||
page.on('response', async (response) => {
|
||||
if (response.status() >= 500 && !response.url().includes('/api/v1/scanner/unknowns')) {
|
||||
runtime.responseErrors.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
page.on('requestfailed', (request) => {
|
||||
const errorText = request.failure()?.errorText || 'request failed';
|
||||
if (errorText === 'net::ERR_ABORTED') {
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.requestFailures.push(`${errorText} ${request.url()}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const runtime = {
|
||||
consoleErrors: [],
|
||||
pageErrors: [],
|
||||
requestFailures: [],
|
||||
responseErrors: [],
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: process.env.PLAYWRIGHT_HEADLESS !== 'false' });
|
||||
const auth = await authenticateFrontdoor({ statePath: authStatePath, reportPath: authReportPath });
|
||||
const context = await createAuthenticatedContext(browser, auth, { statePath: auth.statePath });
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(20_000);
|
||||
page.setDefaultNavigationTimeout(30_000);
|
||||
await collectRuntime(page, runtime);
|
||||
|
||||
const results = [];
|
||||
const failures = [];
|
||||
|
||||
try {
|
||||
await navigate(page, '/security/reports');
|
||||
await page.waitForFunction(() => document.body.textContent?.includes('Risk Posture'), { timeout: 20_000 }).catch(() => {});
|
||||
results.push({
|
||||
key: 'security-reports-risk-tab',
|
||||
route: '/security/reports',
|
||||
ok: true,
|
||||
snapshot: {
|
||||
heading: await pageHeading(page),
|
||||
banner: await bannerText(page),
|
||||
hasRiskPosture: await page.locator('text=Risk Posture').count().then((count) => count > 0),
|
||||
hasArtifactWorkspace: await page.locator('text=Artifact workspace').count().then((count) => count > 0),
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('tab', { name: 'VEX Ledger', exact: true }).click();
|
||||
await settle(page, 1250);
|
||||
results.push({
|
||||
key: 'security-reports-vex-tab',
|
||||
route: '/security/reports?tab=vex',
|
||||
ok: true,
|
||||
snapshot: {
|
||||
tabText: await page.locator('.tab-content').textContent().then(cleanText).catch(() => ''),
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('tab', { name: 'Evidence Export', exact: true }).click();
|
||||
await page
|
||||
.locator('.security-reports .report-panel .export-center')
|
||||
.waitFor({ state: 'visible', timeout: 20_000 })
|
||||
.catch(() => {});
|
||||
await settle(page, 750);
|
||||
results.push({
|
||||
key: 'security-reports-evidence-tab',
|
||||
route: '/security/reports?tab=evidence',
|
||||
ok: true,
|
||||
snapshot: {
|
||||
tabText: await page.locator('.security-reports .report-panel').textContent().then(cleanText).catch(() => ''),
|
||||
},
|
||||
});
|
||||
|
||||
await navigate(page, '/setup/system');
|
||||
results.push({
|
||||
key: 'setup-system-truthfulness',
|
||||
route: '/setup/system',
|
||||
ok: true,
|
||||
snapshot: {
|
||||
heading: await pageHeading(page),
|
||||
text: await page.locator('body').textContent().then(cleanText).catch(() => ''),
|
||||
},
|
||||
});
|
||||
|
||||
await navigate(page, '/setup/integrations');
|
||||
results.push({
|
||||
key: 'setup-integrations-guidance',
|
||||
route: '/setup/integrations',
|
||||
ok: true,
|
||||
snapshot: {
|
||||
heading: await pageHeading(page),
|
||||
text: await page.locator('body').textContent().then(cleanText).catch(() => ''),
|
||||
},
|
||||
});
|
||||
|
||||
await navigate(page, '/evidence/capsules');
|
||||
results.push({
|
||||
key: 'decision-capsules-heading',
|
||||
route: '/evidence/capsules',
|
||||
ok: true,
|
||||
snapshot: {
|
||||
heading: await pageHeading(page),
|
||||
},
|
||||
});
|
||||
|
||||
await navigate(page, '/evidence/exports/replay');
|
||||
results.push({
|
||||
key: 'replay-route-naming',
|
||||
route: '/evidence/exports/replay',
|
||||
ok: true,
|
||||
snapshot: {
|
||||
heading: await pageHeading(page),
|
||||
},
|
||||
});
|
||||
|
||||
await navigate(page, '/security');
|
||||
results.push({
|
||||
key: 'security-posture-heading',
|
||||
route: '/security',
|
||||
ok: true,
|
||||
snapshot: {
|
||||
heading: await pageHeading(page),
|
||||
text: await page.locator('body').textContent().then(cleanText).catch(() => ''),
|
||||
},
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/scanner/unknowns**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'forced-test-failure' }),
|
||||
});
|
||||
});
|
||||
|
||||
await navigate(page, '/security/unknowns', 1500);
|
||||
results.push({
|
||||
key: 'security-unknowns-error-state',
|
||||
route: '/security/unknowns',
|
||||
ok: true,
|
||||
snapshot: {
|
||||
heading: await pageHeading(page),
|
||||
banner: await bannerText(page),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
|
||||
const riskReport = results.find((entry) => entry.key === 'security-reports-risk-tab');
|
||||
if (!riskReport?.snapshot?.hasRiskPosture || riskReport?.snapshot?.hasArtifactWorkspace) {
|
||||
failures.push('Security Reports risk tab is not rendering the risk posture workspace cleanly.');
|
||||
}
|
||||
|
||||
const vexReport = results.find((entry) => entry.key === 'security-reports-vex-tab');
|
||||
if (!cleanText(vexReport?.snapshot?.tabText ?? '').includes('Providers')) {
|
||||
failures.push('Security Reports VEX tab did not render the VEX ledger workspace.');
|
||||
}
|
||||
|
||||
const evidenceReport = results.find((entry) => entry.key === 'security-reports-evidence-tab');
|
||||
if (!cleanText(evidenceReport?.snapshot?.tabText ?? '').includes('Export Center')) {
|
||||
failures.push('Security Reports evidence tab did not render the export workspace.');
|
||||
}
|
||||
|
||||
const systemRoute = results.find((entry) => entry.key === 'setup-system-truthfulness');
|
||||
const systemText = cleanText(systemRoute?.snapshot?.text ?? '');
|
||||
if (!systemText.includes('handoff, not a health verdict') || systemText.includes('All systems operational')) {
|
||||
failures.push('Setup System still presents a false health verdict.');
|
||||
}
|
||||
|
||||
const integrations = results.find((entry) => entry.key === 'setup-integrations-guidance');
|
||||
const integrationsText = cleanText(integrations?.snapshot?.text ?? '');
|
||||
if (
|
||||
cleanText(integrations?.snapshot?.heading ?? '') !== 'Integrations'
|
||||
|| !integrationsText.includes('Suggested Setup Order')
|
||||
|| integrationsText.includes('coming soon')
|
||||
) {
|
||||
failures.push('Setup Integrations is still missing truthful first-time-user guidance.');
|
||||
}
|
||||
|
||||
const capsules = results.find((entry) => entry.key === 'decision-capsules-heading');
|
||||
if (cleanText(capsules?.snapshot?.heading ?? '') !== 'Decision Capsules') {
|
||||
failures.push('Decision Capsules no longer exposes a primary H1 heading.');
|
||||
}
|
||||
|
||||
const replay = results.find((entry) => entry.key === 'replay-route-naming');
|
||||
if (cleanText(replay?.snapshot?.heading ?? '') !== 'Replay & Verify') {
|
||||
failures.push('Evidence replay naming is still inconsistent with the canonical route contract.');
|
||||
}
|
||||
|
||||
const security = results.find((entry) => entry.key === 'security-posture-heading');
|
||||
const securityText = cleanText(security?.snapshot?.text ?? '');
|
||||
if (cleanText(security?.snapshot?.heading ?? '') !== 'Security Posture' || securityText.includes('�') || securityText.includes('<27>')) {
|
||||
failures.push('Security posture still exposes inconsistent naming or mojibake separators.');
|
||||
}
|
||||
|
||||
const unknowns = results.find((entry) => entry.key === 'security-unknowns-error-state');
|
||||
if (!cleanText(unknowns?.snapshot?.banner ?? '').includes('Unknowns data is unavailable')) {
|
||||
failures.push('Security Unknowns did not surface a truthful error state when the APIs failed.');
|
||||
}
|
||||
|
||||
const report = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
baseUrl,
|
||||
failedCheckCount: failures.length,
|
||||
runtimeIssueCount:
|
||||
runtime.consoleErrors.length + runtime.pageErrors.length + runtime.requestFailures.length + runtime.responseErrors.length,
|
||||
results,
|
||||
runtime,
|
||||
failures,
|
||||
};
|
||||
|
||||
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
|
||||
if (failures.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -62,6 +62,11 @@ const suites = [
|
||||
script: 'live-user-reported-admin-trust-check.mjs',
|
||||
reportPath: path.join(outputDir, 'live-user-reported-admin-trust-check.json'),
|
||||
},
|
||||
{
|
||||
name: 'first-time-user-reporting-truthfulness-check',
|
||||
script: 'live-first-time-user-reporting-truthfulness-check.mjs',
|
||||
reportPath: path.join(outputDir, 'live-first-time-user-reporting-truthfulness-check.json'),
|
||||
},
|
||||
{
|
||||
name: 'changed-surfaces',
|
||||
script: 'live-frontdoor-changed-surfaces.mjs',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { evidenceExportRoutes } from './evidence-export.routes';
|
||||
|
||||
describe('evidenceExportRoutes', () => {
|
||||
it('keeps the replay child route aligned with the canonical Replay & Verify contract', () => {
|
||||
const replayRoute = evidenceExportRoutes.find((route) => route.path === 'replay');
|
||||
|
||||
expect(replayRoute).toBeDefined();
|
||||
expect(replayRoute?.title).toBe('Replay & Verify');
|
||||
expect(replayRoute?.data?.['breadcrumb']).toBe('Replay & Verify');
|
||||
});
|
||||
});
|
||||
@@ -36,8 +36,8 @@ export const evidenceExportRoutes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'replay',
|
||||
title: 'Verdict Replay',
|
||||
data: { breadcrumb: 'Verdict Replay' },
|
||||
title: 'Replay & Verify',
|
||||
data: { breadcrumb: 'Replay & Verify' },
|
||||
loadComponent: () =>
|
||||
import('./replay-controls.component').then(
|
||||
(m) => m.ReplayControlsComponent
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { EVIDENCE_PACK_API, type EvidencePackApi } from '../../core/api/evidence-pack.client';
|
||||
import { EvidencePackListComponent } from './evidence-pack-list.component';
|
||||
|
||||
describe('EvidencePackListComponent', () => {
|
||||
let fixture: ComponentFixture<EvidencePackListComponent>;
|
||||
let evidenceApi: jasmine.SpyObj<EvidencePackApi>;
|
||||
|
||||
beforeEach(async () => {
|
||||
evidenceApi = jasmine.createSpyObj<EvidencePackApi>('EvidencePackApi', [
|
||||
'list',
|
||||
'listByRun',
|
||||
'get',
|
||||
'create',
|
||||
'sign',
|
||||
'verify',
|
||||
'export',
|
||||
]);
|
||||
evidenceApi.list.and.returnValue(of({ count: 0, packs: [] }));
|
||||
evidenceApi.listByRun.and.returnValue(of({ count: 0, packs: [] }));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EvidencePackListComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: EVIDENCE_PACK_API, useValue: evidenceApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EvidencePackListComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('treats decision capsules as a page-level route surface with a primary heading', () => {
|
||||
const heading = fixture.nativeElement.querySelector('h1.list-title') as HTMLElement | null;
|
||||
expect(heading?.textContent).toContain('Decision Capsules');
|
||||
expect(fixture.nativeElement.querySelector('h2.list-title')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/
|
||||
<!-- Header with filters -->
|
||||
<header class="list-header">
|
||||
<div>
|
||||
<h2 class="list-title">Decision Capsules</h2>
|
||||
<h1 class="list-title">Decision Capsules</h1>
|
||||
<p class="list-subtitle">Browse signed evidence packs that explain release, policy, and operator decisions.</p>
|
||||
</div>
|
||||
@if (runId) {
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { IntegrationHubComponent } from './integration-hub.component';
|
||||
import { IntegrationService } from './integration.service';
|
||||
import { IntegrationType, IntegrationListResponse } from './integration.models';
|
||||
import { IntegrationListResponse, IntegrationType } from './integration.models';
|
||||
|
||||
describe('IntegrationHubComponent', () => {
|
||||
let fixture: ComponentFixture<IntegrationHubComponent>;
|
||||
let component: IntegrationHubComponent;
|
||||
let mockIntegrationService: jasmine.SpyObj<IntegrationService>;
|
||||
let router: Router;
|
||||
let route: ActivatedRoute;
|
||||
|
||||
const mockListResponse = (totalCount: number): IntegrationListResponse => ({
|
||||
items: [],
|
||||
totalCount,
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
hasMore: false,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockIntegrationService = jasmine.createSpyObj('IntegrationService', ['list']);
|
||||
|
||||
// Default mock responses
|
||||
mockIntegrationService.list.and.callFake((params) => {
|
||||
switch (params?.type) {
|
||||
case IntegrationType.Registry:
|
||||
@@ -34,6 +35,8 @@ describe('IntegrationHubComponent', () => {
|
||||
return of(mockListResponse(8));
|
||||
case IntegrationType.FeedMirror:
|
||||
return of(mockListResponse(4));
|
||||
case IntegrationType.RepoSource:
|
||||
return of(mockListResponse(1));
|
||||
default:
|
||||
return of(mockListResponse(0));
|
||||
}
|
||||
@@ -46,154 +49,67 @@ describe('IntegrationHubComponent', () => {
|
||||
|
||||
fixture = TestBed.createComponent(IntegrationHubComponent);
|
||||
component = fixture.componentInstance;
|
||||
router = TestBed.inject(Router);
|
||||
route = TestBed.inject(ActivatedRoute);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
it('creates the hub', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display page header', () => {
|
||||
it('renders first-time-user setup guidance instead of a coming-soon placeholder', async () => {
|
||||
fixture.detectChanges();
|
||||
const header = fixture.nativeElement.querySelector('.hub-header h1');
|
||||
expect(header.textContent).toBe('Integration Hub');
|
||||
});
|
||||
|
||||
it('should display subtitle', () => {
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const subtitle = fixture.nativeElement.querySelector('.subtitle');
|
||||
expect(subtitle.textContent).toContain('Manage registries');
|
||||
|
||||
const text = fixture.nativeElement.textContent;
|
||||
|
||||
expect(text).toContain('Integrations');
|
||||
expect(text).toContain('Suggested Setup Order');
|
||||
expect(text).toContain('Use the activity timeline for connector event history');
|
||||
expect(text).not.toContain('coming soon');
|
||||
});
|
||||
|
||||
describe('Navigation Tiles', () => {
|
||||
beforeEach(() => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('loads connector totals for all setup categories', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
it('should display all navigation tiles', () => {
|
||||
const tiles = fixture.nativeElement.querySelectorAll('.nav-tile');
|
||||
expect(tiles.length).toBe(5);
|
||||
});
|
||||
|
||||
it('should display registries tile', () => {
|
||||
const tile = fixture.nativeElement.querySelector('a[routerLink="registries"]');
|
||||
expect(tile).toBeTruthy();
|
||||
expect(tile.textContent).toContain('Registries');
|
||||
});
|
||||
|
||||
it('should display SCM tile', () => {
|
||||
const tile = fixture.nativeElement.querySelector('a[routerLink="scm"]');
|
||||
expect(tile).toBeTruthy();
|
||||
expect(tile.textContent).toContain('SCM');
|
||||
});
|
||||
|
||||
it('should display CI/CD tile', () => {
|
||||
const tile = fixture.nativeElement.querySelector('a[routerLink="ci"]');
|
||||
expect(tile).toBeTruthy();
|
||||
expect(tile.textContent).toContain('CI/CD');
|
||||
});
|
||||
|
||||
it('should display Hosts tile', () => {
|
||||
const tile = fixture.nativeElement.querySelector('a[routerLink="hosts"]');
|
||||
expect(tile).toBeTruthy();
|
||||
expect(tile.textContent).toContain('Hosts');
|
||||
});
|
||||
|
||||
it('should display Feeds tile', () => {
|
||||
const tile = fixture.nativeElement.querySelector('a[routerLink="feeds"]');
|
||||
expect(tile).toBeTruthy();
|
||||
expect(tile.textContent).toContain('Feeds');
|
||||
});
|
||||
expect(mockIntegrationService.list).toHaveBeenCalledTimes(6);
|
||||
expect(component.stats().registries).toBe(5);
|
||||
expect(component.stats().scm).toBe(3);
|
||||
expect(component.stats().ci).toBe(2);
|
||||
expect(component.stats().runtimeHosts).toBe(8);
|
||||
expect(component.stats().advisorySources).toBe(4);
|
||||
expect(component.stats().vexSources).toBe(4);
|
||||
expect(component.stats().secrets).toBe(1);
|
||||
expect(component.configuredConnectorCount()).toBe(27);
|
||||
});
|
||||
|
||||
describe('Stats Loading', () => {
|
||||
it('should load stats on init', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
it('keeps the suggested setup-order links on the canonical integrations routes', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockIntegrationService.list).toHaveBeenCalledTimes(5);
|
||||
}));
|
||||
const hrefs = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.setup-order__list a') as NodeListOf<HTMLAnchorElement>,
|
||||
).map((link) => link.getAttribute('href'));
|
||||
|
||||
it('should display registry count', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.stats.registries).toBe(5);
|
||||
}));
|
||||
|
||||
it('should display SCM count', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.stats.scm).toBe(3);
|
||||
}));
|
||||
|
||||
it('should display CI count', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.stats.ci).toBe(2);
|
||||
}));
|
||||
|
||||
it('should display hosts count', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.stats.hosts).toBe(8);
|
||||
}));
|
||||
|
||||
it('should display feeds count', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.stats.feeds).toBe(4);
|
||||
}));
|
||||
expect(hrefs).toContain('/registries');
|
||||
expect(hrefs).toContain('/scm');
|
||||
expect(hrefs).toContain('/ci');
|
||||
expect(hrefs).toContain('/advisory-vex-sources');
|
||||
expect(hrefs).toContain('/secrets');
|
||||
});
|
||||
|
||||
describe('Actions', () => {
|
||||
beforeEach(() => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('navigates to the onboarding flow from the primary action', async () => {
|
||||
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||
|
||||
it('should display Add Integration button', () => {
|
||||
const btn = fixture.nativeElement.querySelector('.btn-primary');
|
||||
expect(btn).toBeTruthy();
|
||||
expect(btn.textContent).toContain('Add Integration');
|
||||
});
|
||||
component.addIntegration();
|
||||
|
||||
it('should display View Activity link', () => {
|
||||
const link = fixture.nativeElement.querySelector('a[routerLink="activity"]');
|
||||
expect(link).toBeTruthy();
|
||||
expect(link.textContent).toContain('View Activity');
|
||||
});
|
||||
|
||||
it('should handle add integration click', () => {
|
||||
const consoleSpy = spyOn(console, 'log');
|
||||
|
||||
component.addIntegration();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Add integration clicked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Recent Activity Section', () => {
|
||||
beforeEach(() => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display Recent Activity heading', () => {
|
||||
const heading = fixture.nativeElement.querySelector('.hub-summary h2');
|
||||
expect(heading.textContent).toBe('Recent Activity');
|
||||
});
|
||||
|
||||
it('should display placeholder text', () => {
|
||||
const placeholder = fixture.nativeElement.querySelector('.placeholder');
|
||||
expect(placeholder).toBeTruthy();
|
||||
expect(placeholder.textContent).toContain('coming soon');
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['onboarding'], {
|
||||
relativeTo: route,
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,43 +1,95 @@
|
||||
import { ChangeDetectorRef, Component, NgZone, inject } from '@angular/core';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
|
||||
import { IntegrationService } from './integration.service';
|
||||
import { IntegrationType } from './integration.models';
|
||||
|
||||
interface IntegrationHubStats {
|
||||
registries: number;
|
||||
scm: number;
|
||||
ci: number;
|
||||
runtimeHosts: number;
|
||||
advisorySources: number;
|
||||
vexSources: number;
|
||||
secrets: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-integration-hub',
|
||||
standalone: true,
|
||||
imports: [RouterModule],
|
||||
template: `
|
||||
<section class="integration-hub">
|
||||
<header class="hub-header">
|
||||
<div>
|
||||
<h1>Integrations</h1>
|
||||
<p class="hub-subtitle">
|
||||
Connect the external systems Stella Ops depends on, then verify them from the same setup surface.
|
||||
</p>
|
||||
</div>
|
||||
<div class="hub-summary" aria-live="polite">
|
||||
<strong>{{ configuredConnectorCount() }}</strong>
|
||||
<span>configured connectors</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="setup-order" aria-labelledby="integration-setup-order">
|
||||
<div class="setup-order__header">
|
||||
<h2 id="integration-setup-order">Suggested Setup Order</h2>
|
||||
<p>Start with the connectors that unblock releases and evidence, then add operator conveniences.</p>
|
||||
</div>
|
||||
<ol class="setup-order__list">
|
||||
<li>
|
||||
<a routerLink="registries">Registries</a>
|
||||
<span>Connect the container sources that release versions, promotions, and policy checks depend on.</span>
|
||||
</li>
|
||||
<li>
|
||||
<a routerLink="scm">Source Control</a>
|
||||
<span>Wire repository and commit metadata before relying on release evidence and drift context.</span>
|
||||
</li>
|
||||
<li>
|
||||
<a routerLink="ci">CI/CD</a>
|
||||
<span>Capture pipeline runs and deployment triggers for release confidence.</span>
|
||||
</li>
|
||||
<li>
|
||||
<a routerLink="advisory-vex-sources">Advisory & VEX Sources</a>
|
||||
<span>Keep security posture, exceptions, and freshness checks truthful.</span>
|
||||
</li>
|
||||
<li>
|
||||
<a routerLink="secrets">Secrets</a>
|
||||
<span>Finish by wiring vaults and credentials used by downstream integrations.</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<nav class="tiles">
|
||||
<a routerLink="registries" class="tile">
|
||||
<span>Registries</span>
|
||||
<strong>{{ stats.registries }}</strong>
|
||||
<strong>{{ stats().registries }}</strong>
|
||||
</a>
|
||||
<a routerLink="scm" class="tile">
|
||||
<span>SCM</span>
|
||||
<strong>{{ stats.scm }}</strong>
|
||||
<strong>{{ stats().scm }}</strong>
|
||||
</a>
|
||||
<a routerLink="ci" class="tile">
|
||||
<span>CI/CD</span>
|
||||
<strong>{{ stats.ci }}</strong>
|
||||
<strong>{{ stats().ci }}</strong>
|
||||
</a>
|
||||
<a routerLink="runtime-hosts" class="tile">
|
||||
<span>Runtimes / Hosts</span>
|
||||
<strong>{{ stats.runtimeHosts }}</strong>
|
||||
<strong>{{ stats().runtimeHosts }}</strong>
|
||||
</a>
|
||||
<a routerLink="advisory-vex-sources" class="tile">
|
||||
<span>Advisory Sources</span>
|
||||
<strong>{{ stats.advisorySources }}</strong>
|
||||
<strong>{{ stats().advisorySources }}</strong>
|
||||
</a>
|
||||
<a routerLink="advisory-vex-sources" class="tile">
|
||||
<span>VEX Sources</span>
|
||||
<strong>{{ stats.vexSources }}</strong>
|
||||
<strong>{{ stats().vexSources }}</strong>
|
||||
</a>
|
||||
<a routerLink="secrets" class="tile">
|
||||
<span>Secrets</span>
|
||||
<strong>{{ stats.secrets }}</strong>
|
||||
<strong>{{ stats().secrets }}</strong>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -48,10 +100,17 @@ import { IntegrationType } from './integration.models';
|
||||
|
||||
<section class="activity" aria-live="polite">
|
||||
<h2>Recent Activity</h2>
|
||||
<p class="activity__title">Activity stream is coming soon</p>
|
||||
<p class="activity__text">
|
||||
Connector timeline events will appear here once integration telemetry wiring is complete.
|
||||
</p>
|
||||
@if (configuredConnectorCount() === 0) {
|
||||
<p class="activity__title">No integration activity recorded yet</p>
|
||||
<p class="activity__text">
|
||||
Add your first connector, run Test Connection, or open the full activity timeline after the next sync.
|
||||
</p>
|
||||
} @else {
|
||||
<p class="activity__title">Use the activity timeline for connector event history</p>
|
||||
<p class="activity__text">
|
||||
The hub summary shows configured connectors. Open View Activity for test, sync, and health events per integration.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
</section>
|
||||
`,
|
||||
@@ -64,6 +123,89 @@ import { IntegrationType } from './integration.models';
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
.hub-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 58ch;
|
||||
}
|
||||
|
||||
.hub-summary {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
min-width: 120px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.hub-summary strong {
|
||||
font-size: 1.4rem;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.hub-summary span {
|
||||
font-size: 0.74rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.setup-order {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.9rem 1rem;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.setup-order__header h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.setup-order__header p {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.setup-order__list {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.setup-order__list li {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.setup-order__list a {
|
||||
color: var(--color-brand-primary);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tiles {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
@@ -134,16 +276,24 @@ import { IntegrationType } from './integration.models';
|
||||
font-size: 0.76rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.hub-header {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.hub-summary {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class IntegrationHubComponent {
|
||||
private readonly integrationService = inject(IntegrationService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
private readonly zone = inject(NgZone);
|
||||
|
||||
stats = {
|
||||
readonly stats = signal<IntegrationHubStats>({
|
||||
registries: 0,
|
||||
scm: 0,
|
||||
ci: 0,
|
||||
@@ -151,7 +301,11 @@ export class IntegrationHubComponent {
|
||||
advisorySources: 0,
|
||||
vexSources: 0,
|
||||
secrets: 0,
|
||||
};
|
||||
});
|
||||
|
||||
configuredConnectorCount(): number {
|
||||
return Object.values(this.stats()).reduce((sum, value) => sum + value, 0);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.loadStats();
|
||||
@@ -159,45 +313,33 @@ export class IntegrationHubComponent {
|
||||
|
||||
private loadStats(): void {
|
||||
this.integrationService.list({ type: IntegrationType.Registry, pageSize: 1 }).subscribe({
|
||||
next: (res) => this.commitUiUpdate(() => (this.stats.registries = res.totalCount)),
|
||||
error: () => this.commitUiUpdate(() => (this.stats.registries = 0)),
|
||||
next: (res) => this.updateStats({ registries: res.totalCount }),
|
||||
error: () => this.updateStats({ registries: 0 }),
|
||||
});
|
||||
this.integrationService.list({ type: IntegrationType.Scm, pageSize: 1 }).subscribe({
|
||||
next: (res) => this.commitUiUpdate(() => (this.stats.scm = res.totalCount)),
|
||||
error: () => this.commitUiUpdate(() => (this.stats.scm = 0)),
|
||||
next: (res) => this.updateStats({ scm: res.totalCount }),
|
||||
error: () => this.updateStats({ scm: 0 }),
|
||||
});
|
||||
this.integrationService.list({ type: IntegrationType.CiCd, pageSize: 1 }).subscribe({
|
||||
next: (res) => this.commitUiUpdate(() => (this.stats.ci = res.totalCount)),
|
||||
error: () => this.commitUiUpdate(() => (this.stats.ci = 0)),
|
||||
next: (res) => this.updateStats({ ci: res.totalCount }),
|
||||
error: () => this.updateStats({ ci: 0 }),
|
||||
});
|
||||
this.integrationService.list({ type: IntegrationType.RuntimeHost, pageSize: 1 }).subscribe({
|
||||
next: (res) => this.commitUiUpdate(() => (this.stats.runtimeHosts = res.totalCount)),
|
||||
error: () => this.commitUiUpdate(() => (this.stats.runtimeHosts = 0)),
|
||||
next: (res) => this.updateStats({ runtimeHosts: res.totalCount }),
|
||||
error: () => this.updateStats({ runtimeHosts: 0 }),
|
||||
});
|
||||
this.integrationService.list({ type: IntegrationType.FeedMirror, pageSize: 1 }).subscribe({
|
||||
next: (res) => {
|
||||
this.commitUiUpdate(() => {
|
||||
this.stats.advisorySources = res.totalCount;
|
||||
this.stats.vexSources = res.totalCount;
|
||||
});
|
||||
},
|
||||
error: () =>
|
||||
this.commitUiUpdate(() => {
|
||||
this.stats.advisorySources = 0;
|
||||
this.stats.vexSources = 0;
|
||||
}),
|
||||
next: (res) => this.updateStats({ advisorySources: res.totalCount, vexSources: res.totalCount }),
|
||||
error: () => this.updateStats({ advisorySources: 0, vexSources: 0 }),
|
||||
});
|
||||
this.integrationService.list({ type: IntegrationType.RepoSource, pageSize: 1 }).subscribe({
|
||||
next: (res) => this.commitUiUpdate(() => (this.stats.secrets = res.totalCount)),
|
||||
error: () => this.commitUiUpdate(() => (this.stats.secrets = 0)),
|
||||
next: (res) => this.updateStats({ secrets: res.totalCount }),
|
||||
error: () => this.updateStats({ secrets: 0 }),
|
||||
});
|
||||
}
|
||||
|
||||
private commitUiUpdate(update: () => void): void {
|
||||
this.zone.run(() => {
|
||||
update();
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
private updateStats(update: Partial<IntegrationHubStats>): void {
|
||||
this.stats.update((current) => ({ ...current, ...update }));
|
||||
}
|
||||
|
||||
addIntegration(): void {
|
||||
|
||||
@@ -131,7 +131,7 @@ interface PlatformListResponse<T> {
|
||||
@for (blocker of topBlockers(); track blocker.findingId) {
|
||||
<li>
|
||||
<a [routerLink]="['/security/triage', blocker.findingId]" queryParamsHandling="merge">{{ blocker.cveId || blocker.findingId }}</a>
|
||||
<span>{{ blocker.releaseName }} <EFBFBD> {{ blocker.region || 'global' }}/{{ blocker.environment }}</span>
|
||||
<span>{{ blocker.releaseName }} - {{ blocker.region || 'global' }}/{{ blocker.environment }}</span>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="empty">No blockers in the selected scope.</li>
|
||||
@@ -162,14 +162,14 @@ interface PlatformListResponse<T> {
|
||||
<a routerLink="/ops/integrations/advisory-vex-sources" queryParamsHandling="merge">Configure sources</a>
|
||||
</div>
|
||||
<p class="meta">
|
||||
Conflicts: <strong>{{ conflictCount() }}</strong> <EFBFBD>
|
||||
Conflicts: <strong>{{ conflictCount() }}</strong> -
|
||||
Unverified statements: <strong>{{ unresolvedVexCount() }}</strong>
|
||||
</p>
|
||||
<ul>
|
||||
@for (provider of providerHealthRows(); track provider.sourceId) {
|
||||
<li>
|
||||
<span>{{ provider.sourceName }}</span>
|
||||
<span>{{ provider.status }} <EFBFBD> {{ provider.freshness }}</span>
|
||||
<span>{{ provider.status }} - {{ provider.freshness }}</span>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="empty">No provider health rows for current scope.</li>
|
||||
@@ -183,7 +183,7 @@ interface PlatformListResponse<T> {
|
||||
<a routerLink="/security/sbom/coverage">Coverage & Unknowns</a>
|
||||
</div>
|
||||
<p class="meta">
|
||||
Reachability unknowns: <strong>{{ unknownReachabilityCount() }}</strong> <EFBFBD>
|
||||
Reachability unknowns: <strong>{{ unknownReachabilityCount() }}</strong> -
|
||||
Stale SBOM rows: <strong>{{ sbomStaleCount() }}</strong>
|
||||
</p>
|
||||
<ul>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ExportCenterComponent } from '../evidence-export/export-center.component';
|
||||
import { SecurityRiskOverviewComponent } from '../security-risk/security-risk-overview.component';
|
||||
import { SecurityDispositionPageComponent } from './security-disposition-page.component';
|
||||
import { SecurityReportsPageComponent } from './security-reports-page.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-security-risk-overview',
|
||||
standalone: true,
|
||||
template: `<div data-testid="risk-report-surface">Risk posture report surface</div>`,
|
||||
})
|
||||
class StubSecurityRiskOverviewComponent {}
|
||||
|
||||
@Component({
|
||||
selector: 'app-security-disposition-page',
|
||||
standalone: true,
|
||||
template: `<div data-testid="vex-report-surface">VEX ledger report surface</div>`,
|
||||
})
|
||||
class StubSecurityDispositionPageComponent {}
|
||||
|
||||
@Component({
|
||||
selector: 'app-export-center',
|
||||
standalone: true,
|
||||
template: `<div data-testid="evidence-export-surface">Evidence export report surface</div>`,
|
||||
})
|
||||
class StubExportCenterComponent {}
|
||||
|
||||
describe('SecurityReportsPageComponent', () => {
|
||||
let fixture: ComponentFixture<SecurityReportsPageComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.overrideComponent(SecurityReportsPageComponent, {
|
||||
remove: {
|
||||
imports: [SecurityRiskOverviewComponent, SecurityDispositionPageComponent, ExportCenterComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
StubSecurityRiskOverviewComponent,
|
||||
StubSecurityDispositionPageComponent,
|
||||
StubExportCenterComponent,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SecurityReportsPageComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SecurityReportsPageComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('defaults to the risk report workspace instead of the triage workspace', () => {
|
||||
expect(fixture.nativeElement.textContent).toContain('Risk posture report surface');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Artifact workspace');
|
||||
});
|
||||
|
||||
it('switches between the embedded report workspaces', () => {
|
||||
const buttons = fixture.nativeElement.querySelectorAll('button[role="tab"]') as NodeListOf<HTMLButtonElement>;
|
||||
|
||||
buttons[1].click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('VEX ledger report surface');
|
||||
|
||||
buttons[2].click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Evidence export report surface');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||
|
||||
import { ExportCenterComponent } from '../evidence-export/export-center.component';
|
||||
import { TriageWorkspaceComponent } from '../triage/triage-workspace.component';
|
||||
import { SecurityRiskOverviewComponent } from '../security-risk/security-risk-overview.component';
|
||||
import { SecurityDispositionPageComponent } from './security-disposition-page.component';
|
||||
|
||||
type ReportTab = 'risk' | 'vex' | 'evidence';
|
||||
@@ -9,7 +9,7 @@ type ReportTab = 'risk' | 'vex' | 'evidence';
|
||||
@Component({
|
||||
selector: 'app-security-reports-page',
|
||||
standalone: true,
|
||||
imports: [TriageWorkspaceComponent, SecurityDispositionPageComponent, ExportCenterComponent],
|
||||
imports: [SecurityRiskOverviewComponent, SecurityDispositionPageComponent, ExportCenterComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="security-reports">
|
||||
@@ -46,7 +46,7 @@ type ReportTab = 'risk' | 'vex' | 'evidence';
|
||||
@switch (activeTab()) {
|
||||
@case ('risk') {
|
||||
<section class="report-panel">
|
||||
<app-triage-workspace></app-triage-workspace>
|
||||
<app-security-risk-overview></app-security-risk-overview>
|
||||
</section>
|
||||
}
|
||||
@case ('vex') {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { SystemSettingsPageComponent } from './system-settings-page.component';
|
||||
|
||||
describe('SystemSettingsPageComponent', () => {
|
||||
let fixture: ComponentFixture<SystemSettingsPageComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SystemSettingsPageComponent],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SystemSettingsPageComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders system settings as a truthful handoff instead of a false health verdict', () => {
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text).toContain('System Settings');
|
||||
expect(text).toContain('handoff, not a health verdict');
|
||||
expect(text).not.toContain('All systems operational');
|
||||
});
|
||||
|
||||
it('links operators to the live health and diagnostics surfaces', () => {
|
||||
const hrefs = Array.from(fixture.nativeElement.querySelectorAll('a') as NodeListOf<HTMLAnchorElement>)
|
||||
.map((link) => link.getAttribute('href'));
|
||||
|
||||
expect(hrefs).toContain('/ops/operations/system-health');
|
||||
expect(hrefs).toContain('/ops/operations/doctor');
|
||||
});
|
||||
});
|
||||
@@ -13,16 +13,15 @@ import { RouterLink } from '@angular/router';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="system-settings">
|
||||
<h1 class="page-title">System</h1>
|
||||
<p class="page-subtitle">System health, diagnostics, and administrative tools (Admin only)</p>
|
||||
<h1 class="page-title">System Settings</h1>
|
||||
<p class="page-subtitle">Use the live health and diagnostics workspaces below to validate readiness. This setup route is a handoff, not a health verdict.</p>
|
||||
|
||||
<div class="settings-grid">
|
||||
<section class="settings-section">
|
||||
<h2>Health Check</h2>
|
||||
<p>View system health and component status.</p>
|
||||
<div class="health-status">
|
||||
<span class="health-indicator health-indicator--ok"></span>
|
||||
<span>All systems operational</span>
|
||||
<h2>Live Health</h2>
|
||||
<p>Open the live health surface to inspect service status, incidents, and the latest platform checks for the current scope.</p>
|
||||
<div class="truth-note">
|
||||
This setup page does not assert that the platform is healthy on its own.
|
||||
</div>
|
||||
<a class="btn btn--secondary" routerLink="/ops/operations/system-health">View Details</a>
|
||||
</section>
|
||||
@@ -64,18 +63,15 @@ import { RouterLink } from '@angular/router';
|
||||
}
|
||||
.settings-section h2 { margin: 0 0 0.5rem; font-size: 1rem; font-weight: var(--font-weight-semibold); }
|
||||
.settings-section p { margin: 0 0 1rem; font-size: 0.875rem; color: var(--color-text-secondary); }
|
||||
.health-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
.truth-note {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.health-indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.health-indicator--ok { background: var(--color-status-success); }
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
|
||||
import { UnknownsClient } from '../../core/api/unknowns.client';
|
||||
import type { UnknownStats } from '../../core/api/unknowns.models';
|
||||
import { UnknownsDashboardComponent } from './unknowns-dashboard.component';
|
||||
|
||||
describe('UnknownsDashboardComponent', () => {
|
||||
let fixture: ComponentFixture<UnknownsDashboardComponent>;
|
||||
let unknownsClient: jasmine.SpyObj<UnknownsClient>;
|
||||
|
||||
const statsFixture: UnknownStats = {
|
||||
total: 2,
|
||||
byType: {
|
||||
binary: 1,
|
||||
symbol: 1,
|
||||
package: 0,
|
||||
file: 0,
|
||||
license: 0,
|
||||
},
|
||||
byStatus: {
|
||||
open: 2,
|
||||
pending: 0,
|
||||
resolved: 0,
|
||||
unresolvable: 0,
|
||||
},
|
||||
resolutionRate: 0,
|
||||
avgConfidence: 74,
|
||||
lastUpdated: '2026-03-15T12:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
unknownsClient = jasmine.createSpyObj<UnknownsClient>('UnknownsClient', ['list', 'getStats']);
|
||||
unknownsClient.list.and.returnValue(
|
||||
of({
|
||||
items: [
|
||||
{
|
||||
id: 'unknown-1',
|
||||
type: 'binary',
|
||||
name: 'openssl',
|
||||
path: '/usr/lib/libssl.so',
|
||||
artifactDigest: 'sha256:artifact-a',
|
||||
artifactRef: 'registry.example/app@sha256:artifact-a',
|
||||
sha256: 'sha256:file-a',
|
||||
status: 'open',
|
||||
confidence: 92,
|
||||
createdAt: '2026-03-15T10:00:00Z',
|
||||
updatedAt: '2026-03-15T11:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
);
|
||||
unknownsClient.getStats.and.returnValue(of(statsFixture));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [UnknownsDashboardComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: UnknownsClient, useValue: unknownsClient },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(UnknownsDashboardComponent);
|
||||
});
|
||||
|
||||
it('loads unknown rows and summary stats through one truthful refresh flow', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(unknownsClient.list).toHaveBeenCalledWith({});
|
||||
expect(unknownsClient.getStats).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.nativeElement.textContent).toContain('Unknowns');
|
||||
expect(fixture.nativeElement.textContent).toContain('openssl');
|
||||
expect(fixture.nativeElement.textContent).toContain('2');
|
||||
});
|
||||
|
||||
it('surfaces an error state instead of an empty success-looking table when the APIs fail', () => {
|
||||
unknownsClient.list.and.returnValue(throwError(() => new Error('scanner backend 500')));
|
||||
unknownsClient.getStats.and.returnValue(throwError(() => new Error('scanner backend 500')));
|
||||
|
||||
fixture = TestBed.createComponent(UnknownsDashboardComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const error = fixture.nativeElement.querySelector('.unknowns-dashboard__error') as HTMLElement | null;
|
||||
expect(error?.textContent).toContain('Unknowns data is unavailable');
|
||||
expect(error?.textContent).toContain('scanner backend 500');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('No unknowns matched the current filters.');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// Sprint: SPRINT_20251229_033_FE - Unknowns Tracking UI
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { forkJoin } from 'rxjs';
|
||||
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -21,13 +22,31 @@ import {
|
||||
<header class="mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Unknowns Tracking</h1>
|
||||
<p class="text-gray-600 mt-1">Identify and resolve unknown components</p>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Unknowns</h1>
|
||||
<p class="text-gray-600 mt-1">Investigate components the scanner could not identify yet and resolve them with evidence-backed matches.</p>
|
||||
</div>
|
||||
<button (click)="refresh()" class="px-4 py-2 bg-blue-600 text-white rounded-md">Refresh</button>
|
||||
<button (click)="refresh()" class="px-4 py-2 bg-blue-600 text-white rounded-md" [disabled]="loading()">
|
||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<section class="unknowns-dashboard__error" role="alert">
|
||||
<div>
|
||||
<h2>Unknowns data is unavailable</h2>
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
<button type="button" (click)="refresh()">Retry</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (loading() && !stats()) {
|
||||
<section class="unknowns-dashboard__state" aria-live="polite">
|
||||
Loading unknowns and summary data...
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (stats()) {
|
||||
<section class="grid grid-cols-5 gap-4 mb-6">
|
||||
<div class="bg-white rounded-lg border p-4">
|
||||
@@ -69,49 +88,132 @@ import {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-lg border">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Type</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Component</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Artifact</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Confidence</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Status</th>
|
||||
<th class="text-right px-4 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
@for (unknown of unknowns(); track unknown.id) {
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3"><span class="px-2 py-1 text-xs bg-gray-100 rounded">{{ UNKNOWN_TYPE_LABELS[unknown.type] }}</span></td>
|
||||
<td class="px-4 py-3"><p class="font-medium">{{ unknown.name }}</p><p class="text-xs text-gray-500">{{ unknown.path }}</p></td>
|
||||
<td class="px-4 py-3 text-sm">{{ unknown.artifactRef }}</td>
|
||||
<td class="px-4 py-3"><span [class]="getConfidenceColor(unknown.confidence ?? 0)">{{ unknown.confidence ?? '-' }}%</span></td>
|
||||
<td class="px-4 py-3"><span class="px-2 py-1 text-xs rounded" [class]="UNKNOWN_STATUS_COLORS[unknown.status]">{{ unknown.status }}</span></td>
|
||||
<td class="px-4 py-3 text-right"><a [routerLink]="[unknown.id]" class="text-blue-600 hover:underline">Identify</a></td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-500">No unknowns found</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
@if (!error()) {
|
||||
<section class="bg-white rounded-lg border">
|
||||
@if (loading() && unknowns().length === 0) {
|
||||
<div class="unknowns-dashboard__state">Loading unknowns...</div>
|
||||
} @else {
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Type</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Component</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Artifact</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Confidence</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Status</th>
|
||||
<th class="text-right px-4 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
@for (unknown of unknowns(); track unknown.id) {
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3"><span class="px-2 py-1 text-xs bg-gray-100 rounded">{{ UNKNOWN_TYPE_LABELS[unknown.type] }}</span></td>
|
||||
<td class="px-4 py-3"><p class="font-medium">{{ unknown.name }}</p><p class="text-xs text-gray-500">{{ unknown.path }}</p></td>
|
||||
<td class="px-4 py-3 text-sm">{{ unknown.artifactRef }}</td>
|
||||
<td class="px-4 py-3"><span [class]="getConfidenceColor(unknown.confidence ?? 0)">{{ unknown.confidence ?? '-' }}%</span></td>
|
||||
<td class="px-4 py-3"><span class="px-2 py-1 text-xs rounded" [class]="UNKNOWN_STATUS_COLORS[unknown.status]">{{ unknown.status }}</span></td>
|
||||
<td class="px-4 py-3 text-right"><a [routerLink]="[unknown.id]" class="text-blue-600 hover:underline">Identify</a></td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-gray-500">
|
||||
No unknowns matched the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`.unknowns-dashboard { min-height: 100vh; background: var(--color-surface-primary); }`]
|
||||
styles: [`
|
||||
.unknowns-dashboard { min-height: 100vh; background: var(--color-surface-primary); }
|
||||
.unknowns-dashboard__error {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: var(--color-status-error-text, #991b1b);
|
||||
}
|
||||
.unknowns-dashboard__error h2,
|
||||
.unknowns-dashboard__error p {
|
||||
margin: 0;
|
||||
}
|
||||
.unknowns-dashboard__error p {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.unknowns-dashboard__error button {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.unknowns-dashboard__state {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class UnknownsDashboardComponent implements OnInit {
|
||||
private readonly client = inject(UnknownsClient);
|
||||
unknowns = signal<Unknown[]>([]);
|
||||
stats = signal<UnknownStats | null>(null);
|
||||
error = signal<string | null>(null);
|
||||
loading = signal(false);
|
||||
filter: UnknownFilter = {};
|
||||
readonly UNKNOWN_TYPE_LABELS = UNKNOWN_TYPE_LABELS;
|
||||
readonly UNKNOWN_STATUS_COLORS = UNKNOWN_STATUS_COLORS;
|
||||
readonly getConfidenceColor = getConfidenceColor;
|
||||
|
||||
ngOnInit(): void { this.loadUnknowns(); this.loadStats(); }
|
||||
loadUnknowns(): void { this.client.list(this.filter).subscribe({ next: (r) => this.unknowns.set(r.items) }); }
|
||||
loadStats(): void { this.client.getStats().subscribe({ next: (s) => this.stats.set(s) }); }
|
||||
refresh(): void { this.loadUnknowns(); this.loadStats(); }
|
||||
ngOnInit(): void {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
loadUnknowns(): void {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
loadStats(): void {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
forkJoin({
|
||||
list: this.client.list(this.filter),
|
||||
stats: this.client.getStats(),
|
||||
}).subscribe({
|
||||
next: ({ list, stats }) => {
|
||||
this.unknowns.set(list.items);
|
||||
this.stats.set(stats);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
this.unknowns.set([]);
|
||||
this.stats.set(null);
|
||||
this.error.set(
|
||||
err instanceof Error
|
||||
? `The scanner unknowns APIs returned an error. ${err.message}`
|
||||
: 'The scanner unknowns APIs returned an error. Retry the request or verify the scanner service.',
|
||||
);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"src/app/features/administration/administration-overview.component.spec.ts",
|
||||
"src/app/features/audit-log/audit-log-dashboard.component.spec.ts",
|
||||
"src/app/features/evidence-audit/evidence-audit-overview.component.spec.ts",
|
||||
"src/app/features/evidence-export/evidence-export.routes.spec.ts",
|
||||
"src/app/features/evidence-pack/evidence-pack-list.component.spec.ts",
|
||||
"src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts",
|
||||
"src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts",
|
||||
"src/app/features/deploy-diff/services/deploy-diff.service.spec.ts",
|
||||
@@ -41,8 +43,11 @@
|
||||
"src/app/features/notify/notify-panel.component.spec.ts",
|
||||
"src/app/features/reachability/reachability-center.component.spec.ts",
|
||||
"src/app/features/releases/release-ops-overview-page.component.spec.ts",
|
||||
"src/app/features/integration-hub/integration-hub.component.spec.ts",
|
||||
"src/app/features/security/security-reports-page.component.spec.ts",
|
||||
"src/app/features/registry-admin/components/plan-audit.component.spec.ts",
|
||||
"src/app/features/registry-admin/registry-admin.component.spec.ts",
|
||||
"src/app/features/settings/system/system-settings-page.component.spec.ts",
|
||||
"src/app/features/trust-admin/certificate-inventory.component.spec.ts",
|
||||
"src/app/features/trust-admin/issuer-trust-list.component.spec.ts",
|
||||
"src/app/features/trust-admin/trust-admin.component.spec.ts",
|
||||
@@ -54,6 +59,7 @@
|
||||
"src/app/features/vex-hub/vex-hub-source-contract.spec.ts",
|
||||
"src/app/shared/ui/filter-bar/filter-bar.component.spec.ts",
|
||||
"src/app/features/watchlist/watchlist-page.component.spec.ts",
|
||||
"src/app/features/unknowns-tracking/unknowns-dashboard.component.spec.ts",
|
||||
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts",
|
||||
"src/app/layout/app-sidebar/app-sidebar.component.spec.ts",
|
||||
"src/app/routes/evidence.routes.spec.ts",
|
||||
|
||||
Reference in New Issue
Block a user