Repair first-time identity, trust, and integrations operator journeys
Identity/Trust: replace developer jargon with operator-facing language on trust overview, trust admin summary, and trust analytics. Add context- aware error handling (404/503 vs generic) for fresh-install guidance. Add navigation cards for Watchlist and Analytics in trust overview grid. Integrations: replace raw alert() calls in test-connection and health- check actions with inline feedback banners using Angular signals. Add dismissible error banner for delete failures on integration detail. Supporting fixes: admin notifications, evidence audit, replay controls, notify panel, sidebar, route ownership, offline-kit, reachability, topology, and platform feeds components hardened with tests and operator-facing empty states. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
#!/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-ux-remediation-check.json');
|
||||
const authStatePath = path.join(outputDir, 'live-first-time-user-ux-remediation-check.state.json');
|
||||
const authReportPath = path.join(outputDir, 'live-first-time-user-ux-remediation-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 url = new URL(route, baseUrl);
|
||||
const scopedParams = new URLSearchParams(scopeQuery);
|
||||
for (const [key, value] of scopedParams.entries()) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function cleanText(value) {
|
||||
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
|
||||
}
|
||||
|
||||
function isStaticAsset(url) {
|
||||
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?|ico)(?:$|\?)/i.test(url);
|
||||
}
|
||||
|
||||
function isNavigationAbort(errorText = '') {
|
||||
return /aborted|net::err_abort/i.test(errorText);
|
||||
}
|
||||
|
||||
function createRuntime() {
|
||||
return {
|
||||
consoleErrors: [],
|
||||
pageErrors: [],
|
||||
requestFailures: [],
|
||||
responseErrors: [],
|
||||
};
|
||||
}
|
||||
|
||||
function attachRuntime(page, runtime) {
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
runtime.consoleErrors.push({ page: page.url(), text: message.text() });
|
||||
}
|
||||
});
|
||||
|
||||
page.on('pageerror', (error) => {
|
||||
if (page.url() === 'about:blank' && String(error).includes('sessionStorage')) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.pageErrors.push({
|
||||
page: page.url(),
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
const errorText = request.failure()?.errorText ?? 'unknown';
|
||||
if (isStaticAsset(request.url()) || isNavigationAbort(errorText)) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.requestFailures.push({
|
||||
page: page.url(),
|
||||
method: request.method(),
|
||||
url: request.url(),
|
||||
error: errorText,
|
||||
});
|
||||
});
|
||||
|
||||
page.on('response', (response) => {
|
||||
if (isStaticAsset(response.url())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status() >= 400) {
|
||||
runtime.responseErrors.push({
|
||||
page: page.url(),
|
||||
method: response.request().method(),
|
||||
status: response.status(),
|
||||
url: response.url(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function settle(page, ms = 1250) {
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 20_000 }).catch(() => {});
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
async function headingText(page) {
|
||||
const headings = page.locator('h1, main h1, main h2, [data-testid="page-title"], .page-title');
|
||||
const count = await headings.count().catch(() => 0);
|
||||
for (let index = 0; index < Math.min(count, 6); index += 1) {
|
||||
const text = cleanText(await headings.nth(index).innerText().catch(() => ''));
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function bodyText(page) {
|
||||
return cleanText(await page.locator('body').innerText().catch(() => ''));
|
||||
}
|
||||
|
||||
async function visibleAlerts(page) {
|
||||
return page
|
||||
.locator('[role="alert"], .error-banner, .warning-banner, .banner, .toast, .notification')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
async function capture(page, key, extra = {}) {
|
||||
return {
|
||||
key,
|
||||
url: page.url(),
|
||||
title: await page.title().catch(() => ''),
|
||||
heading: await headingText(page),
|
||||
alerts: await visibleAlerts(page),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function hrefs(page, selector = 'a') {
|
||||
return page.locator(selector).evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((node) => node.getAttribute('href'))
|
||||
.filter((value) => typeof value === 'string'),
|
||||
).catch(() => []);
|
||||
}
|
||||
|
||||
async function runCheck(page, key, route, evaluate) {
|
||||
await page.goto(buildUrl(route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
await settle(page);
|
||||
const result = await evaluate();
|
||||
return {
|
||||
key,
|
||||
route,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const authReport = await authenticateFrontdoor({
|
||||
statePath: authStatePath,
|
||||
reportPath: authReportPath,
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--ignore-certificate-errors', '--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
const context = await createAuthenticatedContext(browser, authReport, {
|
||||
statePath: authStatePath,
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
context.on('page', (page) => attachRuntime(page, runtime));
|
||||
|
||||
const page = await context.newPage();
|
||||
attachRuntime(page, runtime);
|
||||
|
||||
const results = [];
|
||||
|
||||
results.push(await runCheck(page, 'security-posture-canonical', '/security', async () => {
|
||||
const heading = await headingText(page);
|
||||
return {
|
||||
ok: heading === 'Security Posture',
|
||||
snapshot: await capture(page, 'security-posture-canonical'),
|
||||
};
|
||||
}));
|
||||
|
||||
results.push(await runCheck(page, 'security-posture-alias', '/security/posture?uxAlias=1#posture-check', async () => {
|
||||
const parsed = new URL(page.url());
|
||||
const heading = await headingText(page);
|
||||
return {
|
||||
ok: parsed.pathname === '/security'
|
||||
&& parsed.searchParams.get('uxAlias') === '1'
|
||||
&& parsed.hash === '#posture-check'
|
||||
&& heading === 'Security Posture',
|
||||
snapshot: await capture(page, 'security-posture-alias'),
|
||||
};
|
||||
}));
|
||||
|
||||
results.push(await runCheck(page, 'replay-and-verify', '/evidence/verify-replay', async () => {
|
||||
const heading = await headingText(page);
|
||||
return {
|
||||
ok: heading === 'Replay & Verify',
|
||||
snapshot: await capture(page, 'replay-and-verify'),
|
||||
};
|
||||
}));
|
||||
|
||||
results.push(await runCheck(page, 'release-health', '/releases/health', async () => {
|
||||
const heading = await headingText(page);
|
||||
return {
|
||||
ok: heading === 'Release Health',
|
||||
snapshot: await capture(page, 'release-health'),
|
||||
};
|
||||
}));
|
||||
|
||||
results.push(await runCheck(page, 'setup-guided-path', '/setup', async () => {
|
||||
const text = await bodyText(page);
|
||||
const links = await hrefs(page);
|
||||
return {
|
||||
ok: text.includes('Start guided setup')
|
||||
&& links.some((href) => href.includes('/setup-wizard/wizard'))
|
||||
&& links.some((href) => href.includes('/setup/notifications')),
|
||||
snapshot: await capture(page, 'setup-guided-path', { links: links.slice(0, 30) }),
|
||||
};
|
||||
}));
|
||||
|
||||
results.push(await runCheck(page, 'setup-notifications-ownership', '/setup/notifications', async () => {
|
||||
const text = await bodyText(page);
|
||||
const links = await hrefs(page);
|
||||
return {
|
||||
ok: text.includes('Setup-owned notification studio')
|
||||
&& links.some((href) => href.includes('/ops/operations/notifications'))
|
||||
&& links.some((href) => href.startsWith('/setup-wizard/wizard')),
|
||||
snapshot: await capture(page, 'setup-notifications-ownership', { links: links.slice(0, 30) }),
|
||||
};
|
||||
}));
|
||||
|
||||
results.push(await runCheck(page, 'operations-notifications-ownership', '/ops/operations/notifications', async () => {
|
||||
const text = await bodyText(page);
|
||||
const heading = await headingText(page);
|
||||
const links = await hrefs(page);
|
||||
return {
|
||||
ok: heading === 'Notification Operations'
|
||||
&& text.includes('Ownership and setup')
|
||||
&& links.includes('/setup/notifications'),
|
||||
snapshot: await capture(page, 'operations-notifications-ownership', { links: links.slice(0, 30) }),
|
||||
};
|
||||
}));
|
||||
|
||||
results.push(await runCheck(page, 'evidence-overview-operator-mode', '/evidence/overview', async () => {
|
||||
const text = await bodyText(page);
|
||||
return {
|
||||
ok: !text.includes('State mode:')
|
||||
&& text.includes('Operator mode keeps the action path concise.')
|
||||
&& text.includes('Auditor mode expands provenance and proof detail'),
|
||||
snapshot: await capture(page, 'evidence-overview-operator-mode'),
|
||||
};
|
||||
}));
|
||||
|
||||
results.push(await runCheck(page, 'sidebar-label-alignment', '/mission-control/board', async () => {
|
||||
const text = cleanText(await page.locator('aside.sidebar').innerText().catch(() => ''));
|
||||
const links = await hrefs(page, 'aside.sidebar a');
|
||||
return {
|
||||
ok: text.includes('Security Posture')
|
||||
&& text.includes('Release Health')
|
||||
&& links.includes('/security')
|
||||
&& links.includes('/releases/health')
|
||||
&& !links.includes('/security/posture'),
|
||||
snapshot: await capture(page, 'sidebar-label-alignment', { links }),
|
||||
};
|
||||
}));
|
||||
|
||||
const failedChecks = results.filter((result) => !result.ok);
|
||||
const runtimeIssueCount =
|
||||
runtime.consoleErrors.length
|
||||
+ runtime.pageErrors.length
|
||||
+ runtime.requestFailures.length
|
||||
+ runtime.responseErrors.length;
|
||||
|
||||
const summary = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
failedCheckCount: failedChecks.length,
|
||||
runtimeIssueCount,
|
||||
results,
|
||||
runtime,
|
||||
};
|
||||
|
||||
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
|
||||
await context.close();
|
||||
await browser.close();
|
||||
|
||||
if (failedChecks.length > 0 || runtimeIssueCount > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(async (error) => {
|
||||
const summary = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
fatalError: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -82,6 +82,11 @@ const suites = [
|
||||
script: 'live-releases-deployments-check.mjs',
|
||||
reportPath: path.join(outputDir, 'live-releases-deployments-check.json'),
|
||||
},
|
||||
{
|
||||
name: 'release-create-journey',
|
||||
script: 'live-release-create-journey.mjs',
|
||||
reportPath: path.join(outputDir, 'live-release-create-journey.json'),
|
||||
},
|
||||
{
|
||||
name: 'release-confidence-journey',
|
||||
script: 'live-release-confidence-journey.mjs',
|
||||
|
||||
@@ -89,6 +89,7 @@ describe('EnvironmentPosturePageComponent', () => {
|
||||
expect(component.environmentLabel()).toBe('Development');
|
||||
expect(component.regionLabel()).toBe('4 regions');
|
||||
expect(component.error()).toBeNull();
|
||||
expect((fixture.nativeElement.querySelector('h1') as HTMLElement)?.textContent).toContain('Release Health');
|
||||
});
|
||||
|
||||
it('shows an explicit guidance message when no environment context is available', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { AdminNotificationsComponent } from './admin-notifications.component';
|
||||
import { NOTIFY_API } from '../../core/api/notify.client';
|
||||
@@ -23,7 +24,7 @@ describe('AdminNotificationsComponent', () => {
|
||||
displayName: 'Security Team Slack',
|
||||
type: 'Slack',
|
||||
enabled: true,
|
||||
config: { target: '#security-alerts' },
|
||||
config: { secretRef: 'notify/slack-security', target: '#security-alerts' },
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
@@ -32,7 +33,7 @@ describe('AdminNotificationsComponent', () => {
|
||||
name: 'email-ops',
|
||||
type: 'Email',
|
||||
enabled: false,
|
||||
config: { target: 'ops@example.com' },
|
||||
config: { secretRef: 'notify/email-ops', target: 'ops@example.com' },
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
@@ -44,7 +45,7 @@ describe('AdminNotificationsComponent', () => {
|
||||
name: 'Critical Alerts',
|
||||
enabled: true,
|
||||
match: { eventKinds: ['vulnerability.detected'], minSeverity: 'critical' },
|
||||
actions: [{ channel: 'chn-1', digest: 'instant' }],
|
||||
actions: [{ actionId: 'action-1', channel: 'chn-1', digest: 'instant', enabled: true }],
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
@@ -54,23 +55,37 @@ describe('AdminNotificationsComponent', () => {
|
||||
deliveryId: 'dlv-1',
|
||||
tenantId: 'tenant-1',
|
||||
ruleId: 'rule-1',
|
||||
channelId: 'chn-1',
|
||||
eventKind: 'vulnerability.detected',
|
||||
target: '#security-alerts',
|
||||
actionId: 'action-1',
|
||||
eventId: 'event-1',
|
||||
kind: 'vulnerability.detected',
|
||||
status: 'Sent',
|
||||
attempts: 1,
|
||||
rendered: {
|
||||
channelType: 'Slack',
|
||||
format: 'Slack',
|
||||
target: '#security-alerts',
|
||||
title: 'Security alert',
|
||||
body: 'Body',
|
||||
},
|
||||
attempts: [{ timestamp: '2025-12-29T10:00:00Z', status: 'Succeeded' }],
|
||||
createdAt: '2025-12-29T10:00:00Z',
|
||||
},
|
||||
{
|
||||
deliveryId: 'dlv-2',
|
||||
tenantId: 'tenant-1',
|
||||
ruleId: 'rule-1',
|
||||
channelId: 'chn-1',
|
||||
eventKind: 'vulnerability.detected',
|
||||
target: '#security-alerts',
|
||||
actionId: 'action-1',
|
||||
eventId: 'event-2',
|
||||
kind: 'vulnerability.detected',
|
||||
status: 'Failed',
|
||||
attempts: 3,
|
||||
errorMessage: 'Connection timeout',
|
||||
statusReason: 'Connection timeout',
|
||||
rendered: {
|
||||
channelType: 'Slack',
|
||||
format: 'Slack',
|
||||
target: '#security-alerts',
|
||||
title: 'Security alert',
|
||||
body: 'Body',
|
||||
},
|
||||
attempts: [{ timestamp: '2025-12-29T09:00:00Z', status: 'Failed', reason: 'Connection timeout' }],
|
||||
createdAt: '2025-12-29T09:00:00Z',
|
||||
},
|
||||
];
|
||||
@@ -82,6 +97,7 @@ describe('AdminNotificationsComponent', () => {
|
||||
title: 'Critical Escalation',
|
||||
severity: 'critical',
|
||||
status: 'open',
|
||||
eventIds: ['event-1'],
|
||||
escalationLevel: 1,
|
||||
createdAt: '2025-12-29T08:00:00Z',
|
||||
},
|
||||
@@ -115,7 +131,7 @@ describe('AdminNotificationsComponent', () => {
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AdminNotificationsComponent],
|
||||
providers: [{ provide: NOTIFY_API, useValue: mockApi }],
|
||||
providers: [provideRouter([]), { provide: NOTIFY_API, useValue: mockApi }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AdminNotificationsComponent);
|
||||
@@ -187,6 +203,19 @@ describe('AdminNotificationsComponent', () => {
|
||||
|
||||
expect(component.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders ownership guidance back to the operator console', async () => {
|
||||
await component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
|
||||
|
||||
expect(text).toContain('Setup-owned notification studio');
|
||||
expect(
|
||||
links.some((link) => link.getAttribute('href')?.includes('/ops/operations/notifications'))
|
||||
).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computed properties', () => {
|
||||
@@ -337,18 +366,30 @@ describe('AdminNotificationsComponent', () => {
|
||||
await component.ngOnInit();
|
||||
});
|
||||
|
||||
it('should acknowledge incident', async () => {
|
||||
mockApi.acknowledgeIncident.and.returnValue(of({ success: true }));
|
||||
|
||||
it('should not acknowledge incident without an ack token', async () => {
|
||||
await component.acknowledgeIncident(mockIncidents[0]);
|
||||
|
||||
expect(mockApi.acknowledgeIncident).toHaveBeenCalledWith('inc-1', { note: 'Acknowledged from UI' });
|
||||
expect(mockApi.acknowledgeIncident).not.toHaveBeenCalled();
|
||||
expect(component.error()).toContain('acknowledgement token');
|
||||
});
|
||||
|
||||
it('should acknowledge incident when the live contract exposes an ack token', async () => {
|
||||
mockApi.acknowledgeIncident.and.returnValue(of({ success: true }));
|
||||
const acknowledgeableIncident = { ...mockIncidents[0], ackToken: 'ack-1' } as NotifyIncident & { ackToken: string };
|
||||
|
||||
await component.acknowledgeIncident(acknowledgeableIncident);
|
||||
|
||||
expect(mockApi.acknowledgeIncident).toHaveBeenCalledWith('inc-1', {
|
||||
ackToken: 'ack-1',
|
||||
note: 'Acknowledged from UI',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle acknowledge error', async () => {
|
||||
mockApi.acknowledgeIncident.and.returnValue(throwError(() => new Error('Failed')));
|
||||
const acknowledgeableIncident = { ...mockIncidents[0], ackToken: 'ack-1' } as NotifyIncident & { ackToken: string };
|
||||
|
||||
await component.acknowledgeIncident(mockIncidents[0]);
|
||||
await component.acknowledgeIncident(acknowledgeableIncident);
|
||||
|
||||
expect(component.error()).toBe('Failed to acknowledge incident');
|
||||
});
|
||||
|
||||
@@ -44,6 +44,22 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
|
||||
<p class="subtitle">Configure channels, rules, templates, and view delivery audit trail</p>
|
||||
</header>
|
||||
|
||||
<section class="owner-banner">
|
||||
<div>
|
||||
<strong>Setup-owned notification studio</strong>
|
||||
<p>
|
||||
Use this surface for channel lifecycle, routing policy, templates, and delivery governance.
|
||||
Use the Operations notifications console for live delivery checks, quick tests, and runtime review.
|
||||
</p>
|
||||
</div>
|
||||
<div class="owner-banner__actions">
|
||||
<a routerLink="/ops/operations/notifications" class="btn-secondary">Open operator console</a>
|
||||
<a routerLink="/setup-wizard/wizard" [queryParams]="{ step: 'notifications', mode: 'reconfigure' }" class="btn-secondary">
|
||||
Re-run notifications setup
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
@@ -328,7 +344,14 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
|
||||
<td>Level {{ incident.escalationLevel || 1 }}</td>
|
||||
<td>{{ incident.createdAt | date:'short' }}</td>
|
||||
<td>
|
||||
<button class="btn-icon" (click)="acknowledgeIncident(incident)">Acknowledge</button>
|
||||
<button
|
||||
class="btn-icon"
|
||||
[disabled]="!hasAckToken(incident)"
|
||||
[title]="hasAckToken(incident) ? 'Acknowledge incident' : 'Acknowledgement requires a token from the live incident contract.'"
|
||||
(click)="acknowledgeIncident(incident)"
|
||||
>
|
||||
Acknowledge
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -407,6 +430,21 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
|
||||
.page-header h1 { margin: 0; font-size: 1.75rem; }
|
||||
.subtitle { color: var(--color-text-secondary); margin-top: 0.25rem; }
|
||||
|
||||
.owner-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.owner-banner strong { display: block; margin-bottom: 0.35rem; }
|
||||
.owner-banner p { margin: 0; color: var(--color-text-secondary); max-width: 64ch; }
|
||||
.owner-banner__actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
|
||||
.stats-row { display: flex; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
|
||||
.stat-card {
|
||||
flex: 1; min-width: 100px; padding: 1rem; border-radius: var(--radius-lg);
|
||||
@@ -489,6 +527,10 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
|
||||
.config-section h3 { margin: 0 0 0.5rem; font-size: 1rem; }
|
||||
.section-desc { color: var(--color-text-secondary); font-size: 0.875rem; margin: 0 0 1rem; }
|
||||
.config-item { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem; background: var(--color-surface-secondary); border-radius: var(--radius-sm); margin-bottom: 0.5rem; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.owner-banner { flex-direction: column; }
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
@@ -566,7 +608,7 @@ export class AdminNotificationsComponent implements OnInit {
|
||||
const filter = this.deliveryFilter();
|
||||
const options = filter === 'all' ? { limit: 50 } : { status: filter as any, limit: 50 };
|
||||
const response = await firstValueFrom(this.api.listDeliveries(options));
|
||||
this.deliveries.set(response.items ?? []);
|
||||
this.deliveries.set([...(response.items ?? [])]);
|
||||
} catch (err) {
|
||||
this.error.set('Failed to load deliveries');
|
||||
} finally {
|
||||
@@ -577,7 +619,7 @@ export class AdminNotificationsComponent implements OnInit {
|
||||
async refreshIncidents(): Promise<void> {
|
||||
try {
|
||||
const response = await firstValueFrom(this.api.listIncidents());
|
||||
this.incidents.set(response.items ?? []);
|
||||
this.incidents.set([...(response.items ?? [])]);
|
||||
} catch (err) {
|
||||
this.error.set('Failed to load incidents');
|
||||
}
|
||||
@@ -630,9 +672,22 @@ export class AdminNotificationsComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
hasAckToken(incident: NotifyIncident): boolean {
|
||||
return Boolean((incident as NotifyIncident & { ackToken?: string }).ackToken);
|
||||
}
|
||||
|
||||
async acknowledgeIncident(incident: NotifyIncident): Promise<void> {
|
||||
const ackToken = (incident as NotifyIncident & { ackToken?: string }).ackToken;
|
||||
if (!ackToken) {
|
||||
this.error.set('Incident acknowledgment is unavailable until the live contract exposes an acknowledgement token.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await firstValueFrom(this.api.acknowledgeIncident(incident.incidentId, { note: 'Acknowledged from UI' }));
|
||||
await firstValueFrom(this.api.acknowledgeIncident(incident.incidentId, {
|
||||
ackToken,
|
||||
note: 'Acknowledged from UI',
|
||||
}));
|
||||
await this.refreshIncidents();
|
||||
} catch (err) {
|
||||
this.error.set('Failed to acknowledge incident');
|
||||
|
||||
@@ -275,6 +275,19 @@ describe('NotificationDashboardComponent', () => {
|
||||
expect(compiled.textContent).toContain('Notification Administration');
|
||||
});
|
||||
|
||||
it('renders ownership guidance back to setup and operations', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const links = Array.from(compiled.querySelectorAll('a')) as HTMLAnchorElement[];
|
||||
|
||||
expect(compiled.textContent).toContain('Setup-owned notification studio');
|
||||
expect(
|
||||
links.some((link) => link.getAttribute('href')?.includes('/ops/operations/notifications'))
|
||||
).toBeTrue();
|
||||
expect(
|
||||
links.some((link) => link.getAttribute('href')?.includes('/setup-wizard/wizard'))
|
||||
).toBeTrue();
|
||||
});
|
||||
|
||||
it('should display stats overview', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.stats-overview')).toBeTruthy();
|
||||
@@ -322,10 +335,10 @@ describe('NotificationDashboardComponent', () => {
|
||||
|
||||
const navLinks = fixture.debugElement
|
||||
.queryAll(By.directive(RouterLink))
|
||||
.map((debugElement) => debugElement.injector.get(RouterLink));
|
||||
.map((debugElement) => debugElement.injector.get(RouterLink))
|
||||
.filter((link) => link.queryParamsHandling === 'merge');
|
||||
|
||||
expect(navLinks.length).toBe(10);
|
||||
expect(navLinks.every((link) => link.queryParamsHandling === 'merge')).toBeTrue();
|
||||
});
|
||||
|
||||
it('should display sub-navigation when delivery tab is active', () => {
|
||||
@@ -342,10 +355,10 @@ describe('NotificationDashboardComponent', () => {
|
||||
|
||||
const navLinks = fixture.debugElement
|
||||
.queryAll(By.directive(RouterLink))
|
||||
.map((debugElement) => debugElement.injector.get(RouterLink));
|
||||
.map((debugElement) => debugElement.injector.get(RouterLink))
|
||||
.filter((link) => link.queryParamsHandling === 'merge');
|
||||
|
||||
expect(navLinks.length).toBe(8);
|
||||
expect(navLinks.every((link) => link.queryParamsHandling === 'merge')).toBeTrue();
|
||||
});
|
||||
|
||||
it('should display error banner when error exists', () => {
|
||||
|
||||
@@ -56,6 +56,26 @@ interface ConfigSubTab {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="owner-banner">
|
||||
<div>
|
||||
<strong>Setup-owned notification studio</strong>
|
||||
<p>
|
||||
Use this setup surface for channel lifecycle, routing policy, templates, throttles, and escalation design.
|
||||
Use the Operations notifications console for live delivery checks, quick tests, and runtime review.
|
||||
</p>
|
||||
</div>
|
||||
<div class="owner-banner__actions">
|
||||
<a routerLink="/ops/operations/notifications" class="btn btn-secondary">Open operator console</a>
|
||||
<a
|
||||
routerLink="/setup-wizard/wizard"
|
||||
[queryParams]="{ step: 'notifications', mode: 'reconfigure' }"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Re-run notifications setup
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Statistics Overview -->
|
||||
<section class="stats-overview" [class.loading]="loadingStats()">
|
||||
<div class="stat-card">
|
||||
@@ -197,6 +217,35 @@ interface ConfigSubTab {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.owner-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
.owner-banner strong {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.owner-banner p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 64ch;
|
||||
}
|
||||
|
||||
.owner-banner__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Statistics Overview */
|
||||
.stats-overview {
|
||||
display: grid;
|
||||
@@ -465,6 +514,10 @@ interface ConfigSubTab {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.owner-banner {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stats-overview {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { AdministrationOverviewComponent } from './administration-overview.component';
|
||||
|
||||
describe('AdministrationOverviewComponent', () => {
|
||||
let fixture: ComponentFixture<AdministrationOverviewComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AdministrationOverviewComponent],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AdministrationOverviewComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('surfaces a guided first-time setup path', () => {
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
|
||||
|
||||
expect(text).toContain('First-Time Setup Path');
|
||||
expect(text).toContain('Start guided setup');
|
||||
expect(
|
||||
links.some((link) => link.getAttribute('href')?.includes('/setup-wizard/wizard'))
|
||||
).toBeTrue();
|
||||
expect(
|
||||
links.some((link) => link.getAttribute('href')?.includes('/setup/notifications'))
|
||||
).toBeTrue();
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ interface SetupCard {
|
||||
<div>
|
||||
<h1 class="admin-overview__title">Setup</h1>
|
||||
<p class="admin-overview__subtitle">
|
||||
Manage topology, identity, tenants, notifications, and system controls.
|
||||
Set up identity, trust, integrations, topology, notifications, and system controls without leaving the product.
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-overview__meta">
|
||||
@@ -59,6 +59,18 @@ interface SetupCard {
|
||||
</div>
|
||||
|
||||
<aside class="admin-overview__aside">
|
||||
<section class="admin-overview__aside-section">
|
||||
<h2 class="admin-overview__section-heading">First-Time Setup Path</h2>
|
||||
<div class="admin-overview__quick-actions">
|
||||
<a routerLink="/setup-wizard/wizard">Start guided setup</a>
|
||||
<a routerLink="/setup/identity-access">1. Identity & Access</a>
|
||||
<a routerLink="/setup/trust-signing">2. Trust & Signing</a>
|
||||
<a routerLink="/setup/integrations">3. Integrations</a>
|
||||
<a routerLink="/setup/topology/overview">4. Topology</a>
|
||||
<a routerLink="/setup/notifications">5. Notifications</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-overview__aside-section">
|
||||
<h2 class="admin-overview__section-heading">Quick Actions</h2>
|
||||
<div class="admin-overview__quick-actions">
|
||||
@@ -72,7 +84,7 @@ interface SetupCard {
|
||||
<h2 class="admin-overview__section-heading">Suggested Next Steps</h2>
|
||||
<ul class="admin-overview__links admin-overview__links--compact">
|
||||
<li>Validate environment mapping and target ownership.</li>
|
||||
<li>Verify notification channels before first production promotion.</li>
|
||||
<li>Finish notifications setup before the first production promotion.</li>
|
||||
<li>Confirm audit trail visibility for approvers.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -30,6 +30,14 @@ describe('EvidenceAuditOverviewComponent (persona visibility)', () => {
|
||||
expect(toggle).toBeTruthy();
|
||||
});
|
||||
|
||||
it('removes developer-only state mode toggles from the operator overview', () => {
|
||||
service.setMode('operator');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).not.toContain('State mode:');
|
||||
expect(fixture.nativeElement.textContent).toContain('Operator mode keeps the action path concise.');
|
||||
});
|
||||
|
||||
it('should hide audit events and proof chains in operator mode', () => {
|
||||
service.setMode('operator');
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -42,17 +42,13 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty';
|
||||
<p class="overview-subtitle">
|
||||
Retrieve, verify, export, and audit evidence for every release, bundle, environment, and approval decision.
|
||||
</p>
|
||||
<p class="overview-hint">
|
||||
Operator mode keeps the action path concise. Auditor mode expands provenance and proof detail for formal review.
|
||||
</p>
|
||||
</div>
|
||||
<stella-view-mode-toggle />
|
||||
</header>
|
||||
|
||||
<section class="mode-toggle" aria-label="Evidence home state mode">
|
||||
<span>State mode:</span>
|
||||
<button type="button" [class.active]="mode() === 'normal'" (click)="setMode('normal')">Normal</button>
|
||||
<button type="button" [class.active]="mode() === 'degraded'" (click)="setMode('degraded')">Degraded</button>
|
||||
<button type="button" [class.active]="mode() === 'empty'" (click)="setMode('empty')">Empty</button>
|
||||
</section>
|
||||
|
||||
@if (isDegraded()) {
|
||||
<section class="state-banner" role="status">
|
||||
Evidence index is degraded. Replay and export links remain available, but latest-pack metrics
|
||||
@@ -232,6 +228,13 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty';
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
|
||||
.overview-hint {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
/* Section titles */
|
||||
.section-title {
|
||||
font-size: 0.85rem;
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('ReplayControlsComponent', () => {
|
||||
it('should display page header', () => {
|
||||
fixture.detectChanges();
|
||||
const header = fixture.nativeElement.querySelector('.page-header h1');
|
||||
expect(header.textContent).toBe('Verdict Replay');
|
||||
expect(header.textContent).toBe('Replay & Verify');
|
||||
});
|
||||
|
||||
describe('Request Replay Form', () => {
|
||||
|
||||
@@ -26,8 +26,8 @@ import { QuickVerifyDrawerComponent, VerifyResult } from '../../shared/component
|
||||
template: `
|
||||
<div class="replay-controls">
|
||||
<header class="page-header">
|
||||
<h1>Verdict Replay</h1>
|
||||
<p>Re-evaluate verdicts for determinism verification and audit trails.</p>
|
||||
<h1>Replay & Verify</h1>
|
||||
<p>Re-run decision evidence to confirm determinism and compare results before release or audit sign-off.</p>
|
||||
@if (releaseId() || runId()) {
|
||||
<p class="replay-context">
|
||||
Context:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectorRef, Component, inject, NgZone, OnInit } from '@angular/core';
|
||||
import { ChangeDetectorRef, Component, inject, NgZone, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { timeout } from 'rxjs';
|
||||
@@ -122,6 +122,12 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
|
||||
<button class="btn-secondary" (click)="editIntegration()">Edit Integration</button>
|
||||
<button class="btn-danger" (click)="deleteIntegration()">Delete Integration</button>
|
||||
</div>
|
||||
@if (deleteError()) {
|
||||
<div class="delete-error" role="alert">
|
||||
{{ deleteError() }}
|
||||
<button type="button" class="delete-error__dismiss" (click)="deleteError.set(null)">Dismiss</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('scopes-rules') {
|
||||
@@ -416,6 +422,28 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.loading { text-align: center; padding: 3rem; color: var(--color-text-secondary); }
|
||||
|
||||
.delete-error {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.delete-error__dismiss {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class IntegrationDetailComponent implements OnInit {
|
||||
@@ -438,6 +466,7 @@ export class IntegrationDetailComponent implements OnInit {
|
||||
checking = false;
|
||||
lastTestResult?: TestConnectionResponse;
|
||||
lastHealthResult?: IntegrationHealthResponse;
|
||||
readonly deleteError = signal<string | null>(null);
|
||||
readonly scopeRules = [
|
||||
'Read scope required for release and evidence queries.',
|
||||
'Write scope required only for connector mutation operations.',
|
||||
@@ -578,7 +607,7 @@ export class IntegrationDetailComponent implements OnInit {
|
||||
void this.router.navigate(this.integrationCommands());
|
||||
},
|
||||
error: (err) => {
|
||||
alert('Failed to delete integration: ' + err.message);
|
||||
this.deleteError.set('Failed to delete integration: ' + (err?.message || 'Unknown error'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { of, throwError } from 'rxjs';
|
||||
|
||||
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
|
||||
import { IntegrationService } from './integration.service';
|
||||
@@ -110,4 +110,111 @@ describe('IntegrationListComponent', () => {
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows inline success feedback instead of alert on successful test-connection', () => {
|
||||
integrationService.testConnection.and.returnValue(of({
|
||||
integrationId: 'int-1',
|
||||
success: true,
|
||||
message: 'Connector responded successfully.',
|
||||
duration: 'PT0.12S',
|
||||
testedAt: '2026-03-15T10:00:00Z',
|
||||
}));
|
||||
|
||||
component.testConnection(component.integrations[0]);
|
||||
|
||||
expect(component.actionFeedback()).toContain('Connection successful');
|
||||
expect(component.actionFeedbackTone()).toBe('success');
|
||||
});
|
||||
|
||||
it('shows inline error feedback instead of alert on failed test-connection', () => {
|
||||
integrationService.testConnection.and.returnValue(of({
|
||||
integrationId: 'int-1',
|
||||
success: false,
|
||||
message: 'Timeout after 5 seconds.',
|
||||
duration: 'PT5S',
|
||||
testedAt: '2026-03-15T10:00:00Z',
|
||||
}));
|
||||
|
||||
component.testConnection(component.integrations[0]);
|
||||
|
||||
expect(component.actionFeedback()).toContain('Connection failed');
|
||||
expect(component.actionFeedbackTone()).toBe('error');
|
||||
});
|
||||
|
||||
it('shows inline error feedback when test-connection throws', () => {
|
||||
integrationService.testConnection.and.returnValue(throwError(() => new Error('Network unreachable')));
|
||||
|
||||
component.testConnection(component.integrations[0]);
|
||||
|
||||
expect(component.actionFeedback()).toContain('Failed to test connection');
|
||||
expect(component.actionFeedbackTone()).toBe('error');
|
||||
});
|
||||
|
||||
it('shows inline feedback instead of alert on health check', () => {
|
||||
integrationService.getHealth.and.returnValue(of({
|
||||
integrationId: 'int-1',
|
||||
status: HealthStatus.Healthy,
|
||||
message: 'All endpoints reachable.',
|
||||
checkedAt: '2026-03-15T10:00:00Z',
|
||||
}));
|
||||
|
||||
component.checkHealth(component.integrations[0]);
|
||||
|
||||
expect(component.actionFeedback()).toContain('Healthy');
|
||||
expect(component.actionFeedbackTone()).toBe('success');
|
||||
});
|
||||
|
||||
it('shows inline error feedback when health check reports unhealthy', () => {
|
||||
integrationService.getHealth.and.returnValue(of({
|
||||
integrationId: 'int-1',
|
||||
status: HealthStatus.Unhealthy,
|
||||
message: 'Connection refused.',
|
||||
checkedAt: '2026-03-15T10:00:00Z',
|
||||
}));
|
||||
|
||||
component.checkHealth(component.integrations[0]);
|
||||
|
||||
expect(component.actionFeedback()).toContain('Unhealthy');
|
||||
expect(component.actionFeedbackTone()).toBe('error');
|
||||
});
|
||||
|
||||
it('shows inline error feedback when health check throws', () => {
|
||||
integrationService.getHealth.and.returnValue(throwError(() => new Error('Service unavailable')));
|
||||
|
||||
component.checkHealth(component.integrations[0]);
|
||||
|
||||
expect(component.actionFeedback()).toContain('Failed to check health');
|
||||
expect(component.actionFeedbackTone()).toBe('error');
|
||||
});
|
||||
|
||||
it('renders feedback banner in the DOM when actionFeedback is set', () => {
|
||||
integrationService.testConnection.and.returnValue(of({
|
||||
integrationId: 'int-1',
|
||||
success: true,
|
||||
message: 'OK',
|
||||
duration: 'PT0.05S',
|
||||
testedAt: '2026-03-15T10:00:00Z',
|
||||
}));
|
||||
|
||||
component.testConnection(component.integrations[0]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const banner = fixture.nativeElement.querySelector('.action-feedback') as HTMLElement;
|
||||
expect(banner).toBeTruthy();
|
||||
expect(banner.textContent).toContain('Connection successful');
|
||||
});
|
||||
|
||||
it('removes feedback banner when dismissed', () => {
|
||||
component.actionFeedback.set('Test message');
|
||||
component.actionFeedbackTone.set('success');
|
||||
fixture.detectChanges();
|
||||
|
||||
const dismissBtn = fixture.nativeElement.querySelector('.action-feedback__close') as HTMLButtonElement;
|
||||
expect(dismissBtn).toBeTruthy();
|
||||
dismissBtn.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const banner = fixture.nativeElement.querySelector('.action-feedback');
|
||||
expect(banner).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectorRef, Component, inject, NgZone, OnInit } from '@angular/core';
|
||||
import { ChangeDetectorRef, Component, inject, NgZone, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
@@ -52,6 +52,13 @@ import {
|
||||
|
||||
<st-doctor-checks-inline category="integration" heading="Integration Health Checks" />
|
||||
|
||||
@if (actionFeedback()) {
|
||||
<div class="action-feedback" [class.action-feedback--error]="actionFeedbackTone() === 'error'" role="status">
|
||||
{{ actionFeedback() }}
|
||||
<button type="button" class="action-feedback__close" (click)="actionFeedback.set(null)">Dismiss</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="loading">Loading integrations...</div>
|
||||
} @else if (loadErrorMessage) {
|
||||
@@ -248,6 +255,34 @@ import {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.action-feedback {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--color-status-success-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
color: var(--color-status-success-text);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.action-feedback--error {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.action-feedback__close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
@@ -309,6 +344,8 @@ export class IntegrationListComponent implements OnInit {
|
||||
totalCount = 0;
|
||||
totalPages = 1;
|
||||
loadErrorMessage: string | null = null;
|
||||
readonly actionFeedback = signal<string | null>(null);
|
||||
readonly actionFeedbackTone = signal<'success' | 'error'>('success');
|
||||
|
||||
private integrationType?: IntegrationType;
|
||||
|
||||
@@ -360,11 +397,18 @@ export class IntegrationListComponent implements OnInit {
|
||||
testConnection(integration: Integration): void {
|
||||
this.integrationService.testConnection(integration.id).subscribe({
|
||||
next: (result) => {
|
||||
alert(result.success ? `Connection successful: ${result.message || 'Connector responded successfully.'}` : `Connection failed: ${result.message || 'Unknown error'}`);
|
||||
if (result.success) {
|
||||
this.actionFeedbackTone.set('success');
|
||||
this.actionFeedback.set(`Connection successful: ${result.message || 'Connector responded successfully.'}`);
|
||||
} else {
|
||||
this.actionFeedbackTone.set('error');
|
||||
this.actionFeedback.set(`Connection failed: ${result.message || 'Unknown error'}`);
|
||||
}
|
||||
this.loadIntegrations();
|
||||
},
|
||||
error: (err) => {
|
||||
alert('Failed to test connection: ' + err.message);
|
||||
this.actionFeedbackTone.set('error');
|
||||
this.actionFeedback.set('Failed to test connection: ' + (err?.message || 'Unknown error'));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -372,11 +416,14 @@ export class IntegrationListComponent implements OnInit {
|
||||
checkHealth(integration: Integration): void {
|
||||
this.integrationService.getHealth(integration.id).subscribe({
|
||||
next: (result) => {
|
||||
alert(`Health: ${getHealthStatusLabel(result.status)} - ${result.message || 'No detail returned.'}`);
|
||||
const isError = result.status === HealthStatus.Unhealthy;
|
||||
this.actionFeedbackTone.set(isError ? 'error' : 'success');
|
||||
this.actionFeedback.set(`Health: ${getHealthStatusLabel(result.status)} \u2014 ${result.message || 'No detail returned.'}`);
|
||||
this.loadIntegrations();
|
||||
},
|
||||
error: (err) => {
|
||||
alert('Failed to check health: ' + err.message);
|
||||
this.actionFeedbackTone.set('error');
|
||||
this.actionFeedback.set('Failed to check health: ' + (err?.message || 'Unknown error'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<header class="notify-panel__header">
|
||||
<div>
|
||||
<p class="eyebrow">Notifications</p>
|
||||
<h1>Notify control plane</h1>
|
||||
<p>Manage channels, routing rules, deliveries, and preview payloads offline.</p>
|
||||
<h1>Notification Operations</h1>
|
||||
<p>Monitor delivery health, test channels, and verify live notification outcomes without leaving operations.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -15,6 +15,24 @@
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="notify-card">
|
||||
<header class="notify-card__header">
|
||||
<div>
|
||||
<h2>Ownership and setup</h2>
|
||||
<p>
|
||||
Use Setup Notifications for channel lifecycle, templates, throttles, and policy changes.
|
||||
Use this Operations console for runtime validation, quick tests, and live delivery review.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="notify-actions">
|
||||
<a class="ghost-button" routerLink="/setup/notifications">Open setup notifications</a>
|
||||
<a class="ghost-button" routerLink="/setup-wizard/wizard" [queryParams]="{ step: 'notifications', mode: 'reconfigure' }">
|
||||
Re-run notifications setup
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="notify-card">
|
||||
<header class="notify-card__header">
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { NOTIFY_API } from '../../core/api/notify.client';
|
||||
import { MockNotifyApiService } from '../../testing/mock-notify-api.service';
|
||||
@@ -12,6 +13,7 @@ describe('NotifyPanelComponent', () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NotifyPanelComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
MockNotifyApiService,
|
||||
{ provide: NOTIFY_API, useExisting: MockNotifyApiService },
|
||||
],
|
||||
@@ -78,4 +80,16 @@ describe('NotifyPanelComponent', () => {
|
||||
links.some((link) => link.getAttribute('href')?.includes('/setup/trust-signing/watchlist/alerts'))
|
||||
).toBeTrue();
|
||||
});
|
||||
|
||||
it('links back to the setup-owned notifications studio', () => {
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
const links = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('a')
|
||||
) as HTMLAnchorElement[];
|
||||
|
||||
expect(text).toContain('Ownership and setup');
|
||||
expect(
|
||||
links.some((link) => link.getAttribute('href')?.includes('/setup/notifications'))
|
||||
).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ import { ContextHeaderComponent } from '../../shared/ui/context-header/context-h
|
||||
<div class="page-shortcuts">
|
||||
<a routerLink="/ops/operations/feeds-airgap">Feeds & Airgap</a>
|
||||
<a routerLink="/evidence/exports">Evidence Exports</a>
|
||||
<a routerLink="/evidence/verify-replay">Verify & Replay</a>
|
||||
<a routerLink="/evidence/verify-replay">Replay & Verify</a>
|
||||
<a routerLink="/setup/trust-signing">Trust & Signing</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ type FeedsAirgapAction = 'import' | 'export' | null;
|
||||
<div class="panel__links">
|
||||
<a [routerLink]="OPERATIONS_PATHS.offlineKit + '/bundles'">Open Offline Kit Bundles</a>
|
||||
<a routerLink="/evidence/exports">Export Evidence Bundle</a>
|
||||
<a routerLink="/evidence/verify-replay">Open Verify & Replay</a>
|
||||
<a routerLink="/evidence/verify-replay">Open Replay & Verify</a>
|
||||
</div>
|
||||
}
|
||||
@if (tab() === 'version-locks') {
|
||||
|
||||
@@ -348,7 +348,7 @@ export class ReachabilityCenterComponent implements OnInit {
|
||||
return 'Triage';
|
||||
}
|
||||
if (returnTo.includes('/evidence/verify-replay')) {
|
||||
return 'Verify & Replay';
|
||||
return 'Replay & Verify';
|
||||
}
|
||||
return 'Previous workspace';
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ export class WitnessPageComponent {
|
||||
return 'Triage';
|
||||
}
|
||||
if (returnTo.includes('/evidence/verify-replay')) {
|
||||
return 'Verify & Replay';
|
||||
return 'Replay & Verify';
|
||||
}
|
||||
if (returnTo.includes('/releases/runs')) {
|
||||
return 'Release run';
|
||||
|
||||
@@ -48,7 +48,7 @@ interface EvidenceCapsuleRow {
|
||||
template: `
|
||||
<section class="posture">
|
||||
<header>
|
||||
<h1>Environment Posture</h1>
|
||||
<h1>Release Health</h1>
|
||||
<p>{{ environmentLabel() }} · {{ regionLabel() }}</p>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -92,6 +92,16 @@ describe('TrustAdminComponent', () => {
|
||||
expect(component.activeTab()).toBe('overview');
|
||||
});
|
||||
|
||||
it('renders operator-facing summary card labels instead of developer jargon', () => {
|
||||
fixture.detectChanges();
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
|
||||
expect(text).toContain('Active keys used for evidence and release signing');
|
||||
expect(text).toContain('Publishers allowed to contribute trusted content');
|
||||
expect(text).toContain('Tracked for expiry, chain integrity, and revocation');
|
||||
expect(text).not.toContain('Administration inventory projection');
|
||||
});
|
||||
|
||||
it('preserves scope query params on every trust shell tab', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
<span class="summary-card__value">{{ overview()!.inventory.keys }}</span>
|
||||
<span class="summary-card__label">Signing Keys</span>
|
||||
<span class="summary-card__detail">
|
||||
Keys available for current signing workflows
|
||||
Active keys used for evidence and release signing
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,7 +86,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
<span class="summary-card__value">{{ overview()!.inventory.issuers }}</span>
|
||||
<span class="summary-card__label">Trusted Issuers</span>
|
||||
<span class="summary-card__detail">
|
||||
Publishers currently allowed to influence trust
|
||||
Publishers allowed to contribute trusted content
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,7 +97,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
<span class="summary-card__value">{{ overview()!.inventory.certificates }}</span>
|
||||
<span class="summary-card__label">Certificates</span>
|
||||
<span class="summary-card__detail">
|
||||
Certificate expiry and revocation stay visible here
|
||||
Tracked for expiry, chain integrity, and revocation
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -189,11 +189,29 @@ describe('TrustAnalyticsComponent', () => {
|
||||
expect(component.unacknowledgedAlerts()).toEqual([]);
|
||||
});
|
||||
|
||||
it('surfaces load failures', () => {
|
||||
it('surfaces a helpful error message when analytics APIs fail', () => {
|
||||
mockTrustApi.getAnalyticsSummary.and.returnValue(throwError(() => new Error('analytics unavailable')));
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.error()).toBe('Failed to load analytics data. Please try again.');
|
||||
expect(component.error()).toContain('Failed to load trust analytics');
|
||||
expect(component.error()).toContain('fresh install');
|
||||
});
|
||||
|
||||
it('surfaces a specific message for 404 backend errors', () => {
|
||||
mockTrustApi.getAnalyticsSummary.and.returnValue(throwError(() => ({ status: 404 })));
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.error()).toContain('not yet available');
|
||||
expect(component.error()).toContain('accumulates');
|
||||
});
|
||||
|
||||
it('surfaces a specific message for 503 backend errors', () => {
|
||||
mockTrustApi.getAnalyticsSummary.and.returnValue(throwError(() => ({ status: 503 })));
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.error()).toContain('not yet available');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1231,8 +1231,18 @@ export class TrustAnalyticsComponent implements OnInit, OnDestroy {
|
||||
this.issuerReliabilityAnalytics.set(issuerReliability);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load analytics:', err);
|
||||
this.error.set('Failed to load analytics data. Please try again.');
|
||||
const status = (err as { status?: number })?.status;
|
||||
if (status === 404 || status === 503) {
|
||||
this.error.set(
|
||||
'Trust analytics data is not yet available. The analytics backend accumulates verification and issuer data over time. ' +
|
||||
'Retry after trust operations (signing, issuer registration, certificate verification) have been exercised.'
|
||||
);
|
||||
} else {
|
||||
this.error.set(
|
||||
'Failed to load trust analytics. This can happen on a fresh install before the analytics service has processed trust events. ' +
|
||||
'Use the Refresh button to retry, or check the trust signing keys and issuers tabs to confirm the trust backend is reachable.'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { TrustOverviewComponent } from './trust-overview.component';
|
||||
|
||||
describe('TrustOverviewComponent', () => {
|
||||
let component: TrustOverviewComponent;
|
||||
let fixture: ComponentFixture<TrustOverviewComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TrustOverviewComponent],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TrustOverviewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders operator-facing heading instead of developer jargon', () => {
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
|
||||
expect(text).toContain('Trust & Signing Overview');
|
||||
expect(text).not.toContain('Administration Overview');
|
||||
expect(text).not.toContain('administration trust-signing projection');
|
||||
});
|
||||
|
||||
it('presents all trust surfaces as navigation cards', () => {
|
||||
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
|
||||
const hrefs = links.map((link) => link.getAttribute('href'));
|
||||
|
||||
expect(hrefs).toContain('/keys');
|
||||
expect(hrefs).toContain('/issuers');
|
||||
expect(hrefs).toContain('/certificates');
|
||||
expect(hrefs).toContain('/watchlist');
|
||||
expect(hrefs).toContain('/analytics');
|
||||
expect(hrefs).toContain('/evidence/overview');
|
||||
});
|
||||
|
||||
it('describes issuers in operator language', () => {
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
|
||||
expect(text).toContain('Register, block, and review publisher trust levels');
|
||||
expect(text).not.toContain('canonical setup shell');
|
||||
});
|
||||
|
||||
it('describes certificates in operator language', () => {
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
|
||||
expect(text).toContain('Track certificate expiry');
|
||||
expect(text).not.toContain('enrollment state');
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,10 @@ import { RouterLink } from '@angular/router';
|
||||
template: `
|
||||
<section class="trust-overview">
|
||||
<div class="trust-overview__panel">
|
||||
<h2>Administration Overview</h2>
|
||||
<h2>Trust & Signing Overview</h2>
|
||||
<p>
|
||||
This workspace is anchored to the live administration trust-signing projection exposed by the
|
||||
rebuilt platform. Use the tabs to move into specific inventory surfaces as they are aligned to the
|
||||
current backend contracts.
|
||||
Manage signing keys, trusted issuers, and certificates from one workspace.
|
||||
Use the tabs above to open specific trust surfaces.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -26,19 +25,31 @@ import { RouterLink } from '@angular/router';
|
||||
|
||||
<article class="trust-overview__card">
|
||||
<h3>Trusted Issuers</h3>
|
||||
<p>Inspect issuer onboarding and trust policy configuration from the canonical setup shell.</p>
|
||||
<p>Register, block, and review publisher trust levels before relying on external advisory content.</p>
|
||||
<a routerLink="issuers">Open issuer inventory</a>
|
||||
</article>
|
||||
|
||||
<article class="trust-overview__card">
|
||||
<h3>Certificates</h3>
|
||||
<p>Check certificate enrollment state and follow evidence consumers that depend on the trust chain.</p>
|
||||
<p>Track certificate expiry, verify chain integrity, and revoke certificates when trust must be withdrawn.</p>
|
||||
<a routerLink="certificates">Open certificate inventory</a>
|
||||
</article>
|
||||
|
||||
<article class="trust-overview__card">
|
||||
<h3>Watchlist</h3>
|
||||
<p>Monitor trust events, create alerts for key expiry or issuer changes, and tune thresholds.</p>
|
||||
<a routerLink="watchlist">Open watchlist</a>
|
||||
</article>
|
||||
|
||||
<article class="trust-overview__card">
|
||||
<h3>Analytics</h3>
|
||||
<p>Review verification success rates, issuer reliability, and failure trends over time.</p>
|
||||
<a routerLink="analytics">Open trust analytics</a>
|
||||
</article>
|
||||
|
||||
<article class="trust-overview__card">
|
||||
<h3>Evidence</h3>
|
||||
<p>Cross-check trust-signing outputs against evidence and replay flows before promotions.</p>
|
||||
<p>Cross-check trust-signing outputs against evidence and replay results before promoting releases.</p>
|
||||
<a routerLink="/evidence/overview">Open evidence overview</a>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -128,15 +128,17 @@ describe('AppSidebarComponent', () => {
|
||||
expect(hrefs).toContain('/releases/health');
|
||||
});
|
||||
|
||||
it('shows Security Posture under Vulnerabilities section', () => {
|
||||
it('shows the security posture landing under the Security Posture section', () => {
|
||||
setScopes([
|
||||
StellaOpsScopes.SCANNER_READ,
|
||||
]);
|
||||
const fixture = createComponent();
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
|
||||
const hrefs = links.map((link) => link.getAttribute('href'));
|
||||
|
||||
expect(hrefs).toContain('/security/posture');
|
||||
expect(text).toContain('Security Posture');
|
||||
expect(hrefs).toContain('/security');
|
||||
});
|
||||
|
||||
it('shows Audit section with Logs and Bundles', () => {
|
||||
|
||||
@@ -664,7 +664,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
},
|
||||
{
|
||||
id: 'rel-health',
|
||||
label: 'Health',
|
||||
label: 'Release Health',
|
||||
route: '/releases/health',
|
||||
icon: 'activity',
|
||||
},
|
||||
@@ -724,7 +724,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
// ── Security & Audit ─────────────────────────────────────────────
|
||||
{
|
||||
id: 'vulnerabilities',
|
||||
label: 'Vulnerabilities',
|
||||
label: 'Security Posture',
|
||||
icon: 'shield',
|
||||
route: '/security',
|
||||
menuGroupId: 'security-audit',
|
||||
@@ -740,12 +740,12 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
StellaOpsScopes.VULN_VIEW,
|
||||
],
|
||||
children: [
|
||||
{ id: 'sec-posture', label: 'Posture', route: '/security', icon: 'shield' },
|
||||
{ id: 'sec-triage', label: 'Triage', route: '/triage/artifacts', icon: 'list' },
|
||||
{ id: 'sec-supply-chain', label: 'Supply-Chain Data', route: '/security/supply-chain-data', icon: 'graph' },
|
||||
{ id: 'sec-reachability', label: 'Reachability', route: '/security/reachability', icon: 'cpu' },
|
||||
{ id: 'sec-unknowns', label: 'Unknowns', route: '/security/unknowns', icon: 'help-circle' },
|
||||
{ id: 'sec-reports', label: 'Reports', route: '/security/reports', icon: 'book-open' },
|
||||
{ id: 'sec-posture', label: 'Security Posture', route: '/security/posture', icon: 'shield' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -59,6 +59,14 @@ describe('EVIDENCE_ROUTES', () => {
|
||||
expect(paths).toContain('audit-log');
|
||||
});
|
||||
|
||||
it('uses Replay & Verify as the canonical replay route label', () => {
|
||||
const replayRoute = EVIDENCE_ROUTES.find((r) => r.path === 'verify-replay');
|
||||
|
||||
expect(replayRoute).toBeDefined();
|
||||
expect(replayRoute?.title).toBe('Replay & Verify');
|
||||
expect(replayRoute?.data?.['breadcrumb']).toBe('Replay & Verify');
|
||||
});
|
||||
|
||||
it('should use loadChildren for lazy-loaded thread and workspace routes', () => {
|
||||
const lazyRoutes = ['threads', 'workspaces/auditor', 'workspaces/developer'];
|
||||
for (const path of lazyRoutes) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Routes } from '@angular/router';
|
||||
* /evidence/workspaces/developer/:artifactDigest - Developer workspace lens
|
||||
* /evidence/capsules - Decision capsule list
|
||||
* /evidence/capsules/:capsuleId - Decision capsule detail
|
||||
* /evidence/verify-replay - Verify & Replay
|
||||
* /evidence/verify-replay - Replay & Verify
|
||||
* /evidence/proofs - Proof chains
|
||||
* /evidence/exports - Evidence exports
|
||||
* /evidence/proof-chain - Proof chain (alias)
|
||||
@@ -76,8 +76,8 @@ export const EVIDENCE_ROUTES: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'verify-replay',
|
||||
title: 'Verify & Replay',
|
||||
data: { breadcrumb: 'Verify & Replay' },
|
||||
title: 'Replay & Verify',
|
||||
data: { breadcrumb: 'Replay & Verify' },
|
||||
loadComponent: () =>
|
||||
import('../features/evidence-export/replay-controls.component').then((m) => m.ReplayControlsComponent),
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SETTINGS_ROUTES } from '../features/settings/settings.routes';
|
||||
import { OPERATIONS_ROUTES } from './operations.routes';
|
||||
import { RELEASES_ROUTES } from './releases.routes';
|
||||
import { LEGACY_REDIRECT_ROUTE_TEMPLATES } from './legacy-redirects.routes';
|
||||
import { SECURITY_RISK_ROUTES } from './security-risk.routes';
|
||||
import { SETUP_ROUTES } from './setup.routes';
|
||||
|
||||
type RedirectFn = Exclude<NonNullable<Route['redirectTo']>, string>;
|
||||
@@ -87,11 +88,32 @@ describe('Route surface ownership', () => {
|
||||
const environmentDetailRoute = RELEASES_ROUTES.find((route) => route.path === 'environments/:environmentId');
|
||||
|
||||
expect(healthRoute?.title).toBe('Release Health');
|
||||
expect(healthRoute?.data?.['breadcrumb']).toBe('Release Health');
|
||||
expect(typeof healthRoute?.loadComponent).toBe('function');
|
||||
expect(typeof environmentsRoute?.loadComponent).toBe('function');
|
||||
expect(typeof environmentDetailRoute?.loadComponent).toBe('function');
|
||||
});
|
||||
|
||||
it('redirects the legacy security posture alias to the canonical security landing', () => {
|
||||
const postureRoute = SECURITY_RISK_ROUTES.find((route) => route.path === 'posture');
|
||||
const redirect = postureRoute?.redirectTo;
|
||||
|
||||
if (typeof redirect !== 'function') {
|
||||
throw new Error('security posture alias must expose a redirect function.');
|
||||
}
|
||||
|
||||
expect(
|
||||
invokeRedirect(redirect, {
|
||||
params: {},
|
||||
queryParams: {
|
||||
tenant: 'demo-prod',
|
||||
regions: 'us-east',
|
||||
environments: 'stage',
|
||||
},
|
||||
}),
|
||||
).toBe('/security?tenant=demo-prod®ions=us-east&environments=stage');
|
||||
});
|
||||
|
||||
it('redirects hotfix creation aliases into the canonical release creation workflow', () => {
|
||||
const hotfixCreateRoute = RELEASES_ROUTES.find((route) => route.path === 'hotfixes/new');
|
||||
const redirect = hotfixCreateRoute?.redirectTo;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, Routes } from '@angular/router';
|
||||
|
||||
function redirectToTriageWorkspace(path: string) {
|
||||
function redirectWithQueryAndFragment(path: string) {
|
||||
return ({ queryParams, fragment }: { queryParams: Record<string, string>; fragment?: string | null }) => {
|
||||
const router = inject(Router);
|
||||
const target = router.parseUrl(path);
|
||||
@@ -42,8 +42,8 @@ function redirectToDecisioning(path: string) {
|
||||
export const SECURITY_RISK_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
title: 'Risk Overview',
|
||||
data: { breadcrumb: 'Risk Overview' },
|
||||
title: 'Security Posture',
|
||||
data: { breadcrumb: 'Security Posture' },
|
||||
loadComponent: () =>
|
||||
import('../features/security-risk/security-risk-overview.component').then(
|
||||
(m) => m.SecurityRiskOverviewComponent
|
||||
@@ -52,11 +52,9 @@ export const SECURITY_RISK_ROUTES: Routes = [
|
||||
{
|
||||
path: 'posture',
|
||||
title: 'Security Posture',
|
||||
data: { breadcrumb: 'Posture' },
|
||||
loadComponent: () =>
|
||||
import('../features/security-risk/security-risk-overview.component').then(
|
||||
(m) => m.SecurityRiskOverviewComponent
|
||||
),
|
||||
data: { breadcrumb: 'Security Posture' },
|
||||
pathMatch: 'full',
|
||||
redirectTo: redirectWithQueryAndFragment('/security'),
|
||||
},
|
||||
{
|
||||
path: 'triage',
|
||||
@@ -352,7 +350,7 @@ export const SECURITY_RISK_ROUTES: Routes = [
|
||||
title: 'Artifacts',
|
||||
data: { breadcrumb: 'Artifacts' },
|
||||
pathMatch: 'full',
|
||||
redirectTo: redirectToTriageWorkspace('/triage/artifacts'),
|
||||
redirectTo: redirectWithQueryAndFragment('/triage/artifacts'),
|
||||
},
|
||||
{
|
||||
path: 'artifacts/:artifactId',
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
"src/app/features/admin-notifications/components/notification-rule-list.component.spec.ts",
|
||||
"src/app/features/admin-notifications/components/notification-dashboard.component.spec.ts",
|
||||
"src/app/features/admin-notifications/components/channel-management.component.spec.ts",
|
||||
"src/app/features/admin-notifications/admin-notifications.component.spec.ts",
|
||||
"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/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",
|
||||
@@ -35,6 +38,7 @@
|
||||
"src/app/features/policy-simulation/policy-simulation-direct-route-defaults.spec.ts",
|
||||
"src/app/features/policy-simulation/policy-simulation-defaults.spec.ts",
|
||||
"src/app/features/policy-simulation/simulation-dashboard.component.spec.ts",
|
||||
"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/registry-admin/components/plan-audit.component.spec.ts",
|
||||
@@ -51,6 +55,10 @@
|
||||
"src/app/shared/ui/filter-bar/filter-bar.component.spec.ts",
|
||||
"src/app/features/watchlist/watchlist-page.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",
|
||||
"src/app/routes/route-surface-ownership.spec.ts",
|
||||
"src/app/core/testing/environment-posture-page.component.spec.ts",
|
||||
"src/tests/settings/admin-settings-page.component.spec.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user