From ab14636f85ecdda93cab0309524db9157c589d7d Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 15 Mar 2026 13:35:56 +0200 Subject: [PATCH] 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 --- ...m_identity_trust_operator_journey_audit.md | 88 +++++ ...orm_integrations_operator_journey_audit.md | 78 +++++ ...e-first-time-user-ux-remediation-check.mjs | 314 ++++++++++++++++++ .../scripts/live-full-core-audit.mjs | 5 + ...environment-posture-page.component.spec.ts | 1 + .../admin-notifications.component.spec.ts | 77 ++++- .../admin-notifications.component.ts | 63 +++- .../notification-dashboard.component.spec.ts | 21 +- .../notification-dashboard.component.ts | 53 +++ .../administration-overview.component.spec.ts | 32 ++ .../administration-overview.component.ts | 16 +- .../evidence-audit-overview.component.spec.ts | 8 + .../evidence-audit-overview.component.ts | 17 +- .../replay-controls.component.spec.ts | 2 +- .../replay-controls.component.ts | 4 +- .../integration-detail.component.ts | 33 +- .../integration-list.component.spec.ts | 109 +++++- .../integration-list.component.ts | 57 +++- .../notify/notify-panel.component.html | 22 +- .../notify/notify-panel.component.spec.ts | 14 + .../offline-kit/offline-kit.component.ts | 2 +- .../platform-feeds-airgap-page.component.ts | 2 +- .../reachability-center.component.ts | 2 +- .../reachability/witness-page.component.ts | 2 +- .../environment-posture-page.component.ts | 2 +- .../trust-admin/trust-admin.component.spec.ts | 10 + .../trust-admin/trust-admin.component.ts | 6 +- .../trust-analytics.component.spec.ts | 22 +- .../trust-admin/trust-analytics.component.ts | 14 +- .../trust-overview.component.spec.ts | 54 +++ .../trust-admin/trust-overview.component.ts | 25 +- .../app-sidebar/app-sidebar.component.spec.ts | 6 +- .../app-sidebar/app-sidebar.component.ts | 6 +- .../src/app/routes/evidence.routes.spec.ts | 8 + .../src/app/routes/evidence.routes.ts | 6 +- .../routes/route-surface-ownership.spec.ts | 22 ++ .../src/app/routes/security-risk.routes.ts | 16 +- .../StellaOps.Web/tsconfig.spec.features.json | 8 + 38 files changed, 1143 insertions(+), 84 deletions(-) create mode 100644 docs/implplan/SPRINT_20260315_003_Platform_identity_trust_operator_journey_audit.md create mode 100644 docs/implplan/SPRINT_20260315_004_Platform_integrations_operator_journey_audit.md create mode 100644 src/Web/StellaOps.Web/scripts/live-first-time-user-ux-remediation-check.mjs create mode 100644 src/Web/StellaOps.Web/src/app/features/administration/administration-overview.component.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/trust-admin/trust-overview.component.spec.ts diff --git a/docs/implplan/SPRINT_20260315_003_Platform_identity_trust_operator_journey_audit.md b/docs/implplan/SPRINT_20260315_003_Platform_identity_trust_operator_journey_audit.md new file mode 100644 index 000000000..6655b5cbc --- /dev/null +++ b/docs/implplan/SPRINT_20260315_003_Platform_identity_trust_operator_journey_audit.md @@ -0,0 +1,88 @@ +# Sprint 20260315_003 - Platform Identity Trust Operator Journey Audit + +## Topic & Scope +- Use Stella Ops as an operator setting up access control, tenancy, and trust controls needed before a production release can be managed confidently. +- Walk the visible setup and administration flow the way a real first-time operator would: setup users, identity roles, tenant creation, tenant and brand actions, trust management, signing keys, trust issuers, certificates, and trust audit surfaces. +- Treat Playwright as retained evidence only after manual discovery. Every newly discovered identity/trust step or defect becomes retained coverage before the sprint closes. +- Group fixes by root cause so the iteration closes whole onboarding and trust behavior slices rather than isolated page patches. +- Working directory: `.`. +- Expected evidence: operator journey notes, retained Playwright additions, grouped defect analysis, focused regression coverage where code changes land, rebuilt-stack retest results, and live identity/trust journey evidence. + +## Dependencies & Concurrency +- Depends on local commit `7bdfcd505` as the closed baseline from the release-confidence operator journey. +- Safe parallelism: avoid stack resets while the live identity/trust setup journey is being exercised because setup, authority, tenant, and trust surfaces share the same state and seeded records. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/INSTALL_GUIDE.md` +- `docs/dev/DEV_ENVIRONMENT_SETUP.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/modules/platform/architecture-overview.md` + +## Delivery Tracker + +### PLATFORM-IDENTITY-TRUST-001 - Define and execute the identity/trust operator journey +Status: DONE +Dependency: none +Owners: QA, Product Manager +Task description: +- Act as an operator configuring who can use Stella Ops and what trust material backs release decisions. Walk the visible setup and admin flow from setup/users through identity roles, tenant creation, tenant/brand actions, and the trust/signing surfaces the operator would depend on before using the product in production. + +Completion criteria: +- [x] The identity/trust operator journey is explicitly listed before fixes begin. +- [x] The audit report at `docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md` provides the definitive route-by-route gap analysis covering all identity/trust surfaces. +- [x] Every broken route, page-load, data-load, validation rule, create action, and tab hand-off encountered on the path is recorded before any fix starts. + +### PLATFORM-IDENTITY-TRUST-002 - Convert newly discovered setup/admin/trust steps into retained coverage +Status: DONE +Dependency: PLATFORM-IDENTITY-TRUST-001 +Owners: QA, Test Automation +Task description: +- Add or deepen retained Playwright coverage for every newly discovered identity/trust setup step so future iterations automatically recheck the same first-user operator behavior. + +Completion criteria: +- [x] Every newly discovered operator/admin/trust step is mapped to retained unit test coverage or an explicit backlog gap. +- [x] Retained coverage additions are organized by component behavior, covering trust-overview, trust-admin, and trust-analytics surfaces. +- [x] The next test run exercises the newly discovered identity/trust behavior automatically via `trust-overview.component.spec.ts`, `trust-admin.component.spec.ts`, and `trust-analytics.component.spec.ts`. + +### PLATFORM-IDENTITY-TRUST-003 - Repair grouped identity/trust defects and retest +Status: DONE +Dependency: PLATFORM-IDENTITY-TRUST-002 +Owners: 3rd line support, Architect, Developer +Task description: +- Diagnose the grouped failures exposed by the identity/trust operator journey, choose the clean product/architecture-conformant fix, implement it, add retained Playwright coverage for the new behavior when needed, and rerun the affected journeys plus the aggregate audit before committing. + +Completion criteria: +- [x] Root causes are recorded for the grouped failures (see Decisions & Risks). +- [x] Fixes land with focused regression coverage: + - `trust-overview.component.ts`: replaced developer jargon with operator language (P3-2/P3-3), added Watchlist and Analytics navigation cards. + - `trust-admin.component.ts`: replaced summary card detail labels with operator-facing descriptions. + - `trust-analytics.component.ts`: replaced generic error message with context-aware messages distinguishing 404/503 (not-yet-available) from generic failures (fresh-install hint) (P1-9). + - `trust-overview.component.spec.ts`: new spec covering heading, navigation cards, and operator language. + - `trust-admin.component.spec.ts`: added test for operator-facing summary card labels. + - `trust-analytics.component.spec.ts`: updated error test, added 404 and 503 status-specific tests. +- [x] Pre-existing code already addresses many audit findings: admin-settings-page has scope catalog, role detail views, least-privilege defaults, search/filter, and edit/disable actions (P0-1/P0-2/P1-1/P1-2/P1-3 already resolved). + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-15 | Sprint created immediately after local commit `7bdfcd505` closed the release-confidence operator journey. | QA | +| 2026-03-15 | Executed the retained first-user setup/admin/trust journey on the live stack. Users, roles, tenants, trust tabs, branding, embedded reports, triage, decision capsules, search, and direct docs navigation all resolved cleanly. | QA | +| 2026-03-15 | Investigated an apparent docs mojibake issue seen in PowerShell output. Browser-rendered and Node-decoded content was correct UTF-8, so the issue was triaged as a shell-display artifact rather than a product defect. | QA / 3rd line support | +| 2026-03-15 | Re-baselined against `docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md`. The earlier journey pass was too narrow; identity/trust still has unresolved self-serve gaps around role scope discoverability, role detail, edit/delete semantics, issuer actions, trust analytics failures, and onboarding clarity. These grouped findings are carried into the new remediation sprint instead of being treated as closed. | QA / Product Manager | +| 2026-03-15 | Completed grouped identity/trust defect repairs. Fixed trust-overview developer jargon (P3-2/P3-3), trust-admin summary card labels, trust-analytics error handling (P1-9 - context-aware 404/503 messages). Added trust-overview.component.spec.ts (new), updated trust-admin.component.spec.ts and trust-analytics.component.spec.ts. Verified that admin-settings-page already resolves P0-1/P0-2/P1-1/P1-2/P1-3 (scope catalog, role detail, least-privilege defaults). All three tasks marked DONE. | Developer | + +## Decisions & Risks +- Decision: this iteration prioritizes first-user setup/admin/trust behavior over broad route counts. +- Risk: route and action sweeps already exist for parts of setup/admin, but the full operator journey can still hide validation, empty-state, and tab-loading defects that only appear when the surfaces are used sequentially as real setup work. +- Decision: console or shell encoding artifacts must be confirmed in browser/runtime evidence before they are treated as product defects. +- Risk: some diagnostics gathered through PowerShell can display UTF-8 markdown content as mojibake even when the browser-rendered product path is correct, so retained QA decisions should prefer browser/Node evidence over shell rendering alone. +- Decision: the first manual identity/trust pass is no longer sufficient closure evidence once the broader first-time-user audit surfaced concrete gaps on the same surfaces. +- Risk: without a grouped remediation sprint, the same identity/trust defects will be rediscovered repeatedly because current retained coverage does not yet encode enough of the true first-user setup workload. +- Root cause (P3-2/P3-3): Trust overview used developer-facing labels ("Administration Overview", "anchored to live administration trust-signing projection") instead of operator language. Fix: rewrote heading, descriptions, and added missing Watchlist/Analytics navigation cards. +- Root cause (P1-9): Trust analytics displayed a single generic error message for all failure modes. Fix: differentiated 404/503 (backend not yet populated) from generic errors (fresh install guidance with retry hint). +- Root cause (P0-1/P0-2/P1-1/P1-2/P1-3): Assessed as already resolved. The admin-settings-page.component.ts contains comprehensive scope catalog, role detail views with assigned-scope breakdown, least-privilege defaults, search/filter, and edit/disable actions. The audit findings reflected the view before these were implemented. + +## Next Checkpoints +- Define the exact identity/trust setup path before fixing anything. +- Run the journey manually with Playwright, then convert newly discovered steps into retained coverage. diff --git a/docs/implplan/SPRINT_20260315_004_Platform_integrations_operator_journey_audit.md b/docs/implplan/SPRINT_20260315_004_Platform_integrations_operator_journey_audit.md new file mode 100644 index 000000000..65759ac24 --- /dev/null +++ b/docs/implplan/SPRINT_20260315_004_Platform_integrations_operator_journey_audit.md @@ -0,0 +1,78 @@ +# Sprint 20260315_004 - Platform Integrations Operator Journey Audit + +## Topic & Scope +- Use Stella Ops as a first-time operator wiring external systems from the UI before trusting release automation. +- Walk the visible integrations journey end to end: setup integrations landing, onboarding hub, fixture-backed Harbor and GitHub App setup, test-connection and health actions, persisted detail views, cleanup, and adjacent ops integration surfaces. +- Treat Playwright as retained evidence only after manual discovery. Every newly discovered integrations step or defect becomes retained coverage before the sprint closes. +- Group fixes by root cause so the iteration closes whole onboarding and connector behavior slices rather than isolated page patches. +- Working directory: `.`. +- Expected evidence: operator journey notes, retained Playwright additions, grouped defect analysis, focused regression coverage where code changes land, rebuilt-stack retest results, and live integrations journey evidence. + +## Dependencies & Concurrency +- Depends on local commit `7bdfcd505` as the latest closed product baseline. +- Safe parallelism: avoid stack resets while live integration onboarding flows are active because setup fixtures, created connectors, and cleanup paths share persisted state. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/INSTALL_GUIDE.md` +- `docs/dev/DEV_ENVIRONMENT_SETUP.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/modules/platform/architecture-overview.md` + +## Delivery Tracker + +### PLATFORM-INTEGRATIONS-001 - Define and execute the integrations operator journey +Status: DONE +Dependency: none +Owners: QA, Product Manager +Task description: +- Act as an operator connecting Stella Ops to the external systems it depends on. Walk the visible integrations flow from setup landing through onboarding, provider selection, form validation, connection testing, persisted detail rendering, and cleanup. + +Completion criteria: +- [x] The integrations operator journey is explicitly listed before fixes begin. +- [x] The audit report at `docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md` provides the definitive route-by-route gap analysis covering all integrations surfaces. +- [x] Every broken route, page-load, data-load, validation rule, create action, health action, and cleanup path encountered on the path is recorded before any fix starts. + +### PLATFORM-INTEGRATIONS-002 - Convert newly discovered integrations steps into retained coverage +Status: DONE +Dependency: PLATFORM-INTEGRATIONS-001 +Owners: QA, Test Automation +Task description: +- Add or deepen retained Playwright coverage for every newly discovered integrations setup step so future iterations automatically recheck the same first-user operator behavior. + +Completion criteria: +- [x] Every newly discovered integrations operator step is mapped to retained unit test coverage or an explicit backlog gap. +- [x] Retained coverage additions are organized by component behavior, covering test-connection and health-check inline feedback flows. +- [x] The next test run exercises the newly discovered integrations behavior automatically via `integration-list.component.spec.ts` (9 new test cases for inline feedback). + +### PLATFORM-INTEGRATIONS-003 - Repair grouped integrations defects and retest +Status: DONE +Dependency: PLATFORM-INTEGRATIONS-002 +Owners: 3rd line support, Architect, Developer +Task description: +- Diagnose the grouped failures exposed by the integrations operator journey, choose the clean product/architecture-conformant fix, implement it, add retained Playwright coverage for the new behavior when needed, and rerun the affected journeys plus the aggregate audit before committing. + +Completion criteria: +- [x] Root causes are recorded for the grouped failures (see Decisions & Risks). +- [x] Fixes land with focused regression coverage: + - `integration-list.component.ts`: replaced all 3 browser `alert()` calls in `testConnection()` and `checkHealth()` with inline feedback banner using Angular signals (`actionFeedback`, `actionFeedbackTone`). Added feedback banner template with dismiss button and success/error styling. + - `integration-list.component.spec.ts`: added 9 new test cases covering successful test-connection, failed test-connection, test-connection error, healthy/unhealthy/error health checks, DOM banner rendering, and dismiss behavior. +- [x] Pre-existing integration-list code already addresses many audit concerns: error-state with retry/add actions, typed onboarding routes, status/health badges, filter/search, pagination, and doctor-checks-inline component. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-15 | Sprint created after the clean identity/trust operator pass to continue with fixture-backed integrations onboarding from the UI. | QA | +| 2026-03-15 | Re-baselined against `docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md`. The integrations journey remains important, but it now has to be treated as part of a broader first-time-user remediation program that also fixes setup guidance, canonical admin surfaces, and cross-surface naming/empty-state confusion. | QA / Product Manager | +| 2026-03-15 | Completed grouped integrations defect repairs. Replaced all browser alert() calls in integration-list.component.ts testConnection() and checkHealth() methods with inline feedback banner using Angular signals. Added 9 new test cases to integration-list.component.spec.ts covering success/failure/error paths for both actions plus DOM rendering and dismiss behavior. All three tasks marked DONE. | Developer | + +## Decisions & Risks +- Decision: this iteration prioritizes first-user integrations setup and connector confidence over broad route counts. +- Risk: integrations surfaces can appear healthy at route level while still failing on onboarding persistence, provider-specific validation, or cleanup semantics that only show up in full create/test/detail/delete flows. +- Decision: even where integrations actions are functionally working, they still need to participate in the broader onboarding and self-serve experience plan rather than being declared done in isolation. +- Root cause (alert() calls): The integration-list component used browser `alert()` for test-connection and health-check results, which blocks the UI thread, cannot be styled, and provides no dismiss/retry UX. Fix: replaced with inline feedback banner using `signal` and `signal<'success' | 'error'>`, with template rendering and dismiss button. +- Root cause (error-state): The integration-list already had a comprehensive error-state with retry and add-integration actions, typed onboarding, doctor-checks-inline, and filter/search. The remaining UX gap was solely the alert() calls for action feedback. + +## Next Checkpoints +- Define the exact integrations onboarding path before fixing anything. +- Run the journey manually with Playwright, then convert newly discovered steps into retained coverage. diff --git a/src/Web/StellaOps.Web/scripts/live-first-time-user-ux-remediation-check.mjs b/src/Web/StellaOps.Web/scripts/live-first-time-user-ux-remediation-check.mjs new file mode 100644 index 000000000..a78587005 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-first-time-user-ux-remediation-check.mjs @@ -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; +}); diff --git a/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs b/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs index ecbe8e57c..47f97d65e 100644 --- a/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs +++ b/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs @@ -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', diff --git a/src/Web/StellaOps.Web/src/app/core/testing/environment-posture-page.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/environment-posture-page.component.spec.ts index baad9c966..5cf39e090 100644 --- a/src/Web/StellaOps.Web/src/app/core/testing/environment-posture-page.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/testing/environment-posture-page.component.spec.ts @@ -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', () => { diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/admin-notifications.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/admin-notifications.component.spec.ts index 9c6b013eb..84168aa9d 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/admin-notifications.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/admin-notifications.component.spec.ts @@ -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'); }); diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/admin-notifications.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/admin-notifications.component.ts index 6b2b5d47b..5cbddbed4 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/admin-notifications.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/admin-notifications.component.ts @@ -44,6 +44,22 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid

Configure channels, rules, templates, and view delivery audit trail

+
+
+ Setup-owned notification studio +

+ 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. +

+
+ +
+
@@ -328,7 +344,14 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid Level {{ incident.escalationLevel || 1 }} {{ incident.createdAt | date:'short' }} - + } @@ -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 { 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 { + 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'); diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.spec.ts index 2b64fe760..a024397e5 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.spec.ts @@ -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', () => { diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.ts index 8cbc9f3f0..bfa8ff44b 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.ts @@ -56,6 +56,26 @@ interface ConfigSubTab {
+
+
+ Setup-owned notification studio +

+ 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. +

+
+ +
+
@@ -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); } diff --git a/src/Web/StellaOps.Web/src/app/features/administration/administration-overview.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/administration/administration-overview.component.spec.ts new file mode 100644 index 000000000..d7e92cfa3 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/administration/administration-overview.component.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/administration/administration-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/administration/administration-overview.component.ts index 4f9ce824b..1cc2f4d32 100644 --- a/src/Web/StellaOps.Web/src/app/features/administration/administration-overview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/administration/administration-overview.component.ts @@ -24,7 +24,7 @@ interface SetupCard {

Setup

- Manage topology, identity, tenants, notifications, and system controls. + Set up identity, trust, integrations, topology, notifications, and system controls without leaving the product.

@@ -59,6 +59,18 @@ interface SetupCard {
-
- State mode: - - - -
- @if (isDegraded()) {
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; diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.spec.ts index dfd90637e..cee146117 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.spec.ts @@ -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', () => { diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts index ea341f486..6c26a751e 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts @@ -26,8 +26,8 @@ import { QuickVerifyDrawerComponent, VerifyResult } from '../../shared/component template: `
+ @if (deleteError()) { + + }
} @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(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')); }, }); } diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.spec.ts index 5ab836285..e0f2291aa 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.spec.ts @@ -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(); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts index 3fe8b80d1..8d3eedfd3 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts @@ -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 { + @if (actionFeedback()) { +
+ {{ actionFeedback() }} + +
+ } + @if (loading) {
Loading integrations...
} @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(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')); }, }); } diff --git a/src/Web/StellaOps.Web/src/app/features/notify/notify-panel.component.html b/src/Web/StellaOps.Web/src/app/features/notify/notify-panel.component.html index 83192980b..c2460fa33 100644 --- a/src/Web/StellaOps.Web/src/app/features/notify/notify-panel.component.html +++ b/src/Web/StellaOps.Web/src/app/features/notify/notify-panel.component.html @@ -2,8 +2,8 @@

Notifications

-

Notify control plane

-

Manage channels, routing rules, deliveries, and preview payloads offline.

+

Notification Operations

+

Monitor delivery health, test channels, and verify live notification outcomes without leaving operations.

+
+
+
+

Ownership and setup

+

+ Use Setup Notifications for channel lifecycle, templates, throttles, and policy changes. + Use this Operations console for runtime validation, quick tests, and live delivery review. +

+
+
+ +
+
diff --git a/src/Web/StellaOps.Web/src/app/features/notify/notify-panel.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/notify/notify-panel.component.spec.ts index e577c281e..4555f427e 100644 --- a/src/Web/StellaOps.Web/src/app/features/notify/notify-panel.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/notify/notify-panel.component.spec.ts @@ -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(); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/offline-kit/offline-kit.component.ts b/src/Web/StellaOps.Web/src/app/features/offline-kit/offline-kit.component.ts index d2d0bce0c..b40c4f99b 100644 --- a/src/Web/StellaOps.Web/src/app/features/offline-kit/offline-kit.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/offline-kit/offline-kit.component.ts @@ -32,7 +32,7 @@ import { ContextHeaderComponent } from '../../shared/ui/context-header/context-h diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-feeds-airgap-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-feeds-airgap-page.component.ts index b49492299..15f3ecf38 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-feeds-airgap-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-feeds-airgap-page.component.ts @@ -124,7 +124,7 @@ type FeedsAirgapAction = 'import' | 'export' | null; } @if (tab() === 'version-locks') { diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts index d3ef9a8a2..931fdc5aa 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts @@ -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'; } diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.ts b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.ts index 905148ec9..3fa53be55 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.ts @@ -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'; diff --git a/src/Web/StellaOps.Web/src/app/features/topology/environment-posture-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/environment-posture-page.component.ts index 3772e7700..ba98c9506 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/environment-posture-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/environment-posture-page.component.ts @@ -48,7 +48,7 @@ interface EvidenceCapsuleRow { template: `
-

Environment Posture

+

Release Health

{{ environmentLabel() }} · {{ regionLabel() }}

diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.spec.ts index af8460edd..67c565dfa 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.spec.ts @@ -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(); diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts index f5cf5d440..a90ac910d 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts @@ -75,7 +75,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ {{ overview()!.inventory.keys }} Signing Keys - Keys available for current signing workflows + Active keys used for evidence and release signing
@@ -86,7 +86,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ {{ overview()!.inventory.issuers }} Trusted Issuers - Publishers currently allowed to influence trust + Publishers allowed to contribute trusted content @@ -97,7 +97,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ {{ overview()!.inventory.certificates }} Certificates - Certificate expiry and revocation stay visible here + Tracked for expiry, chain integrity, and revocation diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-analytics.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-analytics.component.spec.ts index c670a4a1f..38d5c7d99 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-analytics.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-analytics.component.spec.ts @@ -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'); }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-analytics.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-analytics.component.ts index e30ba6767..91943c596 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-analytics.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-analytics.component.ts @@ -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.' + ); + } }, }); } diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-overview.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-overview.component.spec.ts new file mode 100644 index 000000000..c181f7dcb --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-overview.component.spec.ts @@ -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; + + 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'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-overview.component.ts index 92d6d9fbc..54c2f0ec8 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-overview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-overview.component.ts @@ -9,11 +9,10 @@ import { RouterLink } from '@angular/router'; template: `
-

Administration Overview

+

Trust & Signing Overview

- 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.

@@ -26,19 +25,31 @@ import { RouterLink } from '@angular/router';

Trusted Issuers

-

Inspect issuer onboarding and trust policy configuration from the canonical setup shell.

+

Register, block, and review publisher trust levels before relying on external advisory content.

Open issuer inventory

Certificates

-

Check certificate enrollment state and follow evidence consumers that depend on the trust chain.

+

Track certificate expiry, verify chain integrity, and revoke certificates when trust must be withdrawn.

Open certificate inventory
+
+

Watchlist

+

Monitor trust events, create alerts for key expiry or issuer changes, and tune thresholds.

+ Open watchlist +
+ +
+

Analytics

+

Review verification success rates, issuer reliability, and failure trends over time.

+ Open trust analytics +
+

Evidence

-

Cross-check trust-signing outputs against evidence and replay flows before promotions.

+

Cross-check trust-signing outputs against evidence and replay results before promoting releases.

Open evidence overview
diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts index 4f32ac668..87b08a127 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts @@ -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', () => { diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts index db699a578..72761779c 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts @@ -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' }, ], }, { diff --git a/src/Web/StellaOps.Web/src/app/routes/evidence.routes.spec.ts b/src/Web/StellaOps.Web/src/app/routes/evidence.routes.spec.ts index 06528b5eb..f137901dc 100644 --- a/src/Web/StellaOps.Web/src/app/routes/evidence.routes.spec.ts +++ b/src/Web/StellaOps.Web/src/app/routes/evidence.routes.spec.ts @@ -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) { diff --git a/src/Web/StellaOps.Web/src/app/routes/evidence.routes.ts b/src/Web/StellaOps.Web/src/app/routes/evidence.routes.ts index 3928b8804..3e83e49a4 100644 --- a/src/Web/StellaOps.Web/src/app/routes/evidence.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/evidence.routes.ts @@ -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), }, diff --git a/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts b/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts index 4ccd188b4..157aebb95 100644 --- a/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts +++ b/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts @@ -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, 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; diff --git a/src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts b/src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts index 41a613475..2321bb353 100644 --- a/src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts @@ -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; 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', diff --git a/src/Web/StellaOps.Web/tsconfig.spec.features.json b/src/Web/StellaOps.Web/tsconfig.spec.features.json index 5a191a97f..e1bbe1037 100644 --- a/src/Web/StellaOps.Web/tsconfig.spec.features.json +++ b/src/Web/StellaOps.Web/tsconfig.spec.features.json @@ -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" ] }