From 425bccf10a211366f68924fbb93c5a75d170e82e Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 10 Mar 2026 07:37:20 +0200 Subject: [PATCH] Preserve topology and triage scope in live setup flows --- ...004_FE_setup_topology_live_action_sweep.md | 74 +++ docs/modules/ui/v2-rewire/pack-22.md | 1 + .../live-setup-topology-action-sweep.mjs | 522 ++++++++++++++++++ .../security-findings-page.component.spec.ts | 89 +++ .../topology-scope-links.component.spec.ts | 322 +++++++++++ .../security-findings-page.component.ts | 5 +- .../environment-posture-page.component.ts | 6 +- .../topology-agents-page.component.ts | 12 +- ...ology-environment-detail-page.component.ts | 8 +- .../topology/topology-hosts-page.component.ts | 6 +- .../topology/topology-map-page.component.ts | 7 +- .../topology-overview-page.component.ts | 31 +- ...topology-promotion-paths-page.component.ts | 2 +- ...ogy-regions-environments-page.component.ts | 12 +- .../topology/topology-shell.component.ts | 22 +- .../topology-targets-page.component.ts | 6 +- .../ui/tabbed-nav/tabbed-nav.component.ts | 4 +- 17 files changed, 1077 insertions(+), 52 deletions(-) create mode 100644 docs/implplan/SPRINT_20260310_004_FE_setup_topology_live_action_sweep.md create mode 100644 src/Web/StellaOps.Web/scripts/live-setup-topology-action-sweep.mjs create mode 100644 src/Web/StellaOps.Web/src/app/core/testing/security-findings-page.component.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts diff --git a/docs/implplan/SPRINT_20260310_004_FE_setup_topology_live_action_sweep.md b/docs/implplan/SPRINT_20260310_004_FE_setup_topology_live_action_sweep.md new file mode 100644 index 000000000..437413a17 --- /dev/null +++ b/docs/implplan/SPRINT_20260310_004_FE_setup_topology_live_action_sweep.md @@ -0,0 +1,74 @@ +# Sprint 20260310_004 - Setup Topology Live Action Sweep + +## Topic & Scope +- Verify the Setup/Topology slice against the rebuilt `https://stella-ops.local` stack with real Playwright interactions, not route-only checks. +- Treat scope preservation as part of correctness: topology tabs and operator actions must keep the active tenant/region/environment/time-window context. +- Working directory: `src/Web/StellaOps.Web`. +- Expected evidence: live Playwright sweep JSON, focused Angular tests, execution log updates, and a scoped commit. + +## Dependencies & Concurrency +- Depends on the rebuilt web bundle and healthy frontdoor stack already running through `devops/compose/docker-compose.stella-ops.yml`. +- Safe to run in parallel with backend/search work as long as edits stay inside `src/Web/StellaOps.Web` and this sprint file. + +## Documentation Prerequisites +- `docs/qa/feature-checks/FLOW.md` +- `docs/modules/platform/architecture-overview.md` + +## Delivery Tracker + +### FE-TOPO-LIVE-001 - Capture live topology action evidence +Status: DONE +Dependency: none +Owners: QA, Developer +Task description: +- Add a dedicated live Playwright script for Setup/Topology that exercises the shell tabs, overview CTAs, environment inventory actions, and environment detail actions on the authenticated frontdoor. +- The sweep must fail when routes misnavigate, when runtime errors surface, or when actions drop active scope query parameters that should remain stable across topology flows. + +Completion criteria: +- [x] A committed live sweep script exists under `src/Web/StellaOps.Web/scripts/`. +- [x] The sweep captures fresh evidence under `src/Web/StellaOps.Web/output/playwright/`. +- [x] Any failures are diagnosed to code-level root causes before implementation changes begin. + +### FE-TOPO-LIVE-002 - Repair topology scope-preserving navigation +Status: DONE +Dependency: FE-TOPO-LIVE-001 +Owners: Developer +Task description: +- Apply the existing Stella Ops scope-preserving navigation pattern to the topology shell and topology operator actions so the live context survives shell navigation and drilldowns. +- Keep the fix scoped to topology unless a broader shared change is clearly required and low risk. + +Completion criteria: +- [x] Topology shell navigation preserves active scope. +- [x] Topology CTA/drilldown actions preserve active scope while adding route-specific parameters. +- [x] Focused tests cover the changed navigation contracts. + +### FE-TOPO-LIVE-003 - Reverify live topology slice after fixes +Status: DONE +Dependency: FE-TOPO-LIVE-002 +Owners: QA +Task description: +- Rebuild the web bundle if needed, sync it into the live stack, rerun the exact topology sweep, and confirm the slice is clean. + +Completion criteria: +- [x] The topology sweep passes with zero failed actions. +- [x] The topology sweep reports zero runtime issues. +- [x] Execution Log records the before/after evidence and the commit hash. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-10 | Sprint created for the next live QA/developer iteration on Setup/Topology after the clean canonical frontdoor, policy, releases, notifications, and mission-control sweeps. | QA | +| 2026-03-10 | First authenticated live topology sweep failed 25 actions with 0 runtime issues. Every failure traced to scope loss across topology shell tabs, CTA/drilldown links, or environment detail operator actions; two tab failures were harness selector collisions on partial `Security` and `Evidence` matches. | QA | +| 2026-03-10 | Root cause analysis found two product defects: Topology links were not consistently using scope-preserving navigation, and `SecurityFindingsPageComponent.reloadFromFilters()` rewrote `/security/triage` without merge semantics, stripping the incoming topology scope. | Developer | +| 2026-03-10 | Added focused navigation regressions for topology and security findings. Focused Angular run passed `6/6` assertions across `2` spec files. | Test Automation | +| 2026-03-10 | Rebuilt the web bundle, synced `dist/stellaops-web/browser` into `compose_console-dist`, and reran the same live topology sweep. Final evidence is clean with `0` failed actions and `0` runtime issues in `src/Web/StellaOps.Web/output/playwright/live-setup-topology-action-sweep.json`. Commit hash pending local commit. | QA | + +## Decisions & Risks +- Decision: treat scope preservation as a correctness requirement in topology because the active platform context changes the data surface on every page and drilldown. +- Risk: `TabbedNavComponent` is shared across multiple shells. If topology needs scope-preserving shell tabs, prefer an opt-in contract instead of a silent repo-wide behavior change. +- Decision: `TabItem` now supports opt-in `queryParamsHandling`, and Topology explicitly sets `merge` on its shell tabs. This preserves scope without changing every other shared tabbed navigation surface. +- Decision: fixing the destination rewrite in `/security/triage` is mandatory. Accepting a scoped entry link is not sufficient if the landing page immediately discards the topology context. + +## Next Checkpoints +- Capture the first failing topology live sweep. +- Repair the navigation contracts and re-run the same sweep before committing. diff --git a/docs/modules/ui/v2-rewire/pack-22.md b/docs/modules/ui/v2-rewire/pack-22.md index d29dbbae6..1fd2e6762 100644 --- a/docs/modules/ui/v2-rewire/pack-22.md +++ b/docs/modules/ui/v2-rewire/pack-22.md @@ -156,6 +156,7 @@ Implementation update (2026-02-20): - `/topology/promotion-paths`. - Generic inventory fallback remains only for non-primary Topology routes (`/topology/workflows`, `/topology/gate-profiles`). - Region/environment global multi-select filters propagate as comma-joined query scope on Topology reads. +- Topology shell tabs, drilldowns, and downstream triage handoffs preserve the active query scope so operator flows stay bound to the same tenant/region/environment/time-window context. ### Operations diff --git a/src/Web/StellaOps.Web/scripts/live-setup-topology-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-setup-topology-action-sweep.mjs new file mode 100644 index 000000000..149c92020 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-setup-topology-action-sweep.mjs @@ -0,0 +1,522 @@ +#!/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 webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const outputDir = path.join(webRoot, 'output', 'playwright'); +const outputPath = path.join(outputDir, 'live-setup-topology-action-sweep.json'); +const authStatePath = path.join(outputDir, 'live-setup-topology-action-sweep.state.json'); +const authReportPath = path.join(outputDir, 'live-setup-topology-action-sweep.auth.json'); +const topologyScope = { + tenant: 'demo-prod', + regions: 'us-east', + environments: 'stage', + timeWindow: '7d', +}; +const topologyScopeQuery = new URLSearchParams(topologyScope).toString(); +const STEP_TIMEOUT_MS = 30_000; + +function isStaticAsset(url) { + return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url); +} + +function createRuntime() { + return { + consoleErrors: [], + pageErrors: [], + requestFailures: [], + responseErrors: [], + }; +} + +function attachRuntimeObservers(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) => { + if (isStaticAsset(request.url())) { + return; + } + + runtime.requestFailures.push({ + page: page.url(), + method: request.method(), + url: request.url(), + error: request.failure()?.errorText ?? 'unknown', + }); + }); + + 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) { + await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {}); + await page.waitForTimeout(750); + + const loadingBanners = page.locator('text=/Loading /i'); + const count = await loadingBanners.count().catch(() => 0); + if (count > 0) { + await loadingBanners.first().waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {}); + } + + await page.waitForTimeout(750); +} + +async function headingText(page) { + const headings = page.locator('h1, h2, [data-testid="page-title"], .page-title'); + const count = await headings.count(); + for (let index = 0; index < Math.min(count, 5); index += 1) { + const text = (await headings.nth(index).innerText().catch(() => '')).trim(); + if (text) { + return text; + } + } + + return ''; +} + +async function captureSnapshot(page, label) { + const alerts = await page + .locator('[role="alert"], .banner--error, .error-banner, .toast, .notification') + .evaluateAll((nodes) => + nodes + .map((node) => (node.textContent || '').trim().replace(/\s+/g, ' ')) + .filter(Boolean) + .slice(0, 8), + ) + .catch(() => []); + + return { + label, + url: page.url(), + title: await page.title(), + heading: await headingText(page), + alerts, + }; +} + +async function persistSummary(summary) { + summary.lastUpdatedAtUtc = new Date().toISOString(); + await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); +} + +function withScope(route) { + const separator = route.includes('?') ? '&' : '?'; + return `https://stella-ops.local${route}${separator}${topologyScopeQuery}`; +} + +async function navigate(page, route) { + await page.goto(withScope(route), { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + await settle(page); +} + +function hasExpectedQuery(urlString, expectedQuery = {}) { + const url = new URL(urlString); + for (const [key, value] of Object.entries(expectedQuery)) { + if (url.searchParams.get(key) !== value) { + return false; + } + } + return true; +} + +async function resolveLink(page, options) { + if (options.hrefIncludes) { + const candidates = page.locator(`a[href*="${options.hrefIncludes}"]`); + const count = await candidates.count(); + for (let index = 0; index < count; index += 1) { + const candidate = candidates.nth(index); + const text = ((await candidate.innerText().catch(() => '')) || '').trim(); + if (!options.name || text === options.name || text.includes(options.name)) { + return candidate; + } + } + } + + if (options.name) { + const link = page.getByRole('link', { name: options.name }).first(); + if ((await link.count()) > 0) { + return link; + } + } + + return null; +} + +async function clickLinkAction(page, route, options) { + await navigate(page, route); + const link = await resolveLink(page, options); + if (!link) { + return { + action: options.action, + ok: false, + reason: 'missing-link', + snapshot: await captureSnapshot(page, `missing:${options.action}`), + }; + } + + await link.click({ timeout: 10_000 }); + await settle(page); + + const url = new URL(page.url()); + const ok = url.pathname === options.expectedPath && hasExpectedQuery(page.url(), options.expectedQuery); + + return { + action: options.action, + ok, + finalUrl: page.url(), + snapshot: await captureSnapshot(page, `after:${options.action}`), + }; +} + +async function fillOverviewSearch(page) { + await navigate(page, '/setup/topology/overview'); + const input = page.locator('#topology-overview-search'); + await input.fill('stage'); + await page.getByRole('button', { name: 'Go' }).click({ timeout: 10_000 }); + await settle(page); + + const ok = + new URL(page.url()).pathname === '/setup/topology/environments/stage/posture' && + hasExpectedQuery(page.url(), topologyScope); + + return { + action: 'overview-search:Go', + ok, + finalUrl: page.url(), + snapshot: await captureSnapshot(page, 'after:overview-search:Go'), + }; +} + +async function clickEnvironmentDetailTab(page, tabLabel, expectedText) { + await navigate(page, '/setup/topology/environments/stage/posture'); + await page.getByRole('button', { name: tabLabel, exact: true }).click({ timeout: 10_000 }); + await settle(page); + + const ok = await page.locator(`text=${expectedText}`).first().isVisible().catch(() => false); + return { + action: `environment-tab:${tabLabel}`, + ok, + finalUrl: page.url(), + snapshot: await captureSnapshot(page, `after:environment-tab:${tabLabel}`), + }; +} + +async function verifyEmptyInventoryState(page, route, expectedText) { + await navigate(page, route); + const ok = await page.locator(`text=${expectedText}`).first().isVisible().catch(() => false); + return { + action: `empty-state:${route}`, + ok, + finalUrl: page.url(), + snapshot: await captureSnapshot(page, `after:empty-state:${route}`), + }; +} + +async function runAction(page, route, actionFactory) { + const startedAtUtc = new Date().toISOString(); + const startedAt = Date.now(); + process.stdout.write(`[live-setup-topology-action-sweep] START ${route}\n`); + + try { + const result = await Promise.race([ + actionFactory(page, route), + new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Timed out after ${STEP_TIMEOUT_MS}ms.`)), STEP_TIMEOUT_MS); + }), + ]); + + const completed = { + ...result, + startedAtUtc, + durationMs: Date.now() - startedAt, + }; + process.stdout.write( + `[live-setup-topology-action-sweep] DONE ${completed.action} ok=${completed.ok} durationMs=${completed.durationMs}\n`, + ); + return completed; + } catch (error) { + const failed = { + action: route, + ok: false, + reason: 'exception', + error: error instanceof Error ? error.message : String(error), + startedAtUtc, + durationMs: Date.now() - startedAt, + snapshot: await captureSnapshot(page, `failure:${route}`), + }; + process.stdout.write( + `[live-setup-topology-action-sweep] FAIL ${route} error=${failed.error} durationMs=${failed.durationMs}\n`, + ); + return failed; + } +} + +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) => attachRuntimeObservers(page, runtime)); + + const page = await context.newPage(); + attachRuntimeObservers(page, runtime); + + const actions = [ + ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', { + action: 'tab:Map', + hrefIncludes: '/setup/topology/map', + expectedPath: '/setup/topology/map', + expectedQuery: topologyScope, + })], + ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', { + action: 'tab:Regions & Environments', + hrefIncludes: '/setup/topology/regions', + expectedPath: '/setup/topology/regions', + expectedQuery: topologyScope, + })], + ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', { + action: 'tab:Targets', + name: 'Targets', + hrefIncludes: '/setup/topology/targets', + expectedPath: '/setup/topology/targets', + expectedQuery: topologyScope, + })], + ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', { + action: 'tab:Hosts', + hrefIncludes: '/setup/topology/hosts', + expectedPath: '/setup/topology/hosts', + expectedQuery: topologyScope, + })], + ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', { + action: 'tab:Agents', + hrefIncludes: '/setup/topology/agents', + expectedPath: '/setup/topology/agents', + expectedQuery: topologyScope, + })], + ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', { + action: 'tab:Promotion Graph', + hrefIncludes: '/setup/topology/promotion-graph', + expectedPath: '/setup/topology/promotion-graph', + expectedQuery: topologyScope, + })], + ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', { + action: 'tab:Workflows', + hrefIncludes: '/setup/topology/workflows', + expectedPath: '/setup/topology/workflows', + expectedQuery: topologyScope, + })], + ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', { + action: 'tab:Gate Profiles', + hrefIncludes: '/setup/topology/gate-profiles', + expectedPath: '/setup/topology/gate-profiles', + expectedQuery: topologyScope, + })], + ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', { + action: 'tab:Connectivity', + hrefIncludes: '/setup/topology/connectivity', + expectedPath: '/setup/topology/connectivity', + expectedQuery: topologyScope, + })], + ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', { + action: 'tab:Runtime Drift', + hrefIncludes: '/setup/topology/runtime-drift', + expectedPath: '/setup/topology/runtime-drift', + expectedQuery: topologyScope, + })], + ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', { + action: 'overview:Open Regions & Environments', + name: 'Open Regions & Environments', + expectedPath: '/setup/topology/regions', + expectedQuery: topologyScope, + })], + ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', { + action: 'overview:Open Environment Inventory', + name: 'Open Environment Inventory', + expectedPath: '/setup/topology/environments', + expectedQuery: topologyScope, + })], + ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', { + action: 'overview:Open Agents', + name: 'Open Agents', + expectedPath: '/setup/topology/agents', + expectedQuery: topologyScope, + })], + ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', { + action: 'overview:Open Promotion Paths', + name: 'Open Promotion Paths', + expectedPath: '/setup/topology/promotion-graph', + expectedQuery: topologyScope, + })], + ['/setup/topology/overview', (currentPage) => fillOverviewSearch(currentPage)], + ['/setup/topology/environments', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments', { + action: 'environments:Open', + name: 'Open', + hrefIncludes: '/setup/topology/environments/stage/posture', + expectedPath: '/setup/topology/environments/stage/posture', + expectedQuery: topologyScope, + })], + ['/setup/topology/environments', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments', { + action: 'environments:Open Targets', + name: 'Open Targets', + hrefIncludes: '/setup/topology/targets', + expectedPath: '/setup/topology/targets', + expectedQuery: { + ...topologyScope, + environment: 'stage', + }, + })], + ['/setup/topology/environments', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments', { + action: 'environments:Open Agents', + name: 'Open Agents', + hrefIncludes: '/setup/topology/agents', + expectedPath: '/setup/topology/agents', + expectedQuery: { + ...topologyScope, + environment: 'stage', + }, + })], + ['/setup/topology/environments', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments', { + action: 'environments:Open Runs', + name: 'Open Runs', + hrefIncludes: '/releases/runs', + expectedPath: '/releases/runs', + expectedQuery: { + ...topologyScope, + environment: 'stage', + }, + })], + ['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Overview', 'Operator Actions')], + ['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Targets', 'Targets')], + ['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Runs', 'Runs')], + ['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Agents', 'Agents')], + ['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Security', 'Security')], + ['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Evidence', 'Evidence')], + ['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Data Quality', 'Data Quality')], + ['/setup/topology/environments/stage/posture', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments/stage/posture', { + action: 'environment-detail:Open Targets', + name: 'Open Targets', + hrefIncludes: '/setup/topology/targets', + expectedPath: '/setup/topology/targets', + expectedQuery: { + ...topologyScope, + environment: 'stage', + }, + })], + ['/setup/topology/environments/stage/posture', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments/stage/posture', { + action: 'environment-detail:Open Agents', + name: 'Open Agents', + hrefIncludes: '/setup/topology/agents', + expectedPath: '/setup/topology/agents', + expectedQuery: { + ...topologyScope, + environment: 'stage', + }, + })], + ['/setup/topology/environments/stage/posture', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments/stage/posture', { + action: 'environment-detail:Open Runs', + name: 'Open Runs', + hrefIncludes: '/releases/runs', + expectedPath: '/releases/runs', + expectedQuery: { + ...topologyScope, + environment: 'stage', + }, + })], + ['/setup/topology/environments/stage/posture', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments/stage/posture', { + action: 'environment-detail:Open Security Triage', + name: 'Open Security Triage', + hrefIncludes: '/security/triage', + expectedPath: '/security/triage', + expectedQuery: { + ...topologyScope, + environment: 'stage', + }, + })], + ['/setup/topology/targets', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/targets', 'No targets for current filters.')], + ['/setup/topology/hosts', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/hosts', 'No hosts for current filters.')], + ['/setup/topology/agents', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/agents', 'No groups for current filters.')], + ]; + + const summary = { + generatedAtUtc: new Date().toISOString(), + baseUrl: 'https://stella-ops.local', + scope: topologyScope, + actions: [], + runtime: runtime, + }; + + for (const [route, actionFactory] of actions) { + const result = await runAction(page, route, actionFactory); + summary.actions.push(result); + await persistSummary(summary); + } + + summary.failedActionCount = summary.actions.filter((action) => !action.ok).length; + summary.runtimeIssueCount = + runtime.consoleErrors.length + + runtime.pageErrors.length + + runtime.requestFailures.length + + runtime.responseErrors.length; + + await persistSummary(summary); + await context.close(); + await browser.close(); + + if (summary.failedActionCount > 0 || summary.runtimeIssueCount > 0) { + process.exitCode = 1; + } +} + +await main(); diff --git a/src/Web/StellaOps.Web/src/app/core/testing/security-findings-page.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/security-findings-page.component.spec.ts new file mode 100644 index 000000000..d9318bf64 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/testing/security-findings-page.component.spec.ts @@ -0,0 +1,89 @@ +import { HttpClient } from '@angular/common/http'; +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router'; +import { BehaviorSubject, of } from 'rxjs'; + +import { PlatformContextStore } from '../context/platform-context.store'; +import { SecurityFindingsPageComponent } from '../../features/security/security-findings-page.component'; + +describe('SecurityFindingsPageComponent', () => { + const queryParamMap$ = new BehaviorSubject( + convertToParamMap({ + tenant: 'demo-prod', + regions: 'us-east', + environments: 'stage', + timeWindow: '7d', + environment: 'stage', + pivot: 'cve', + }), + ); + + beforeEach(() => { + queryParamMap$.next( + convertToParamMap({ + tenant: 'demo-prod', + regions: 'us-east', + environments: 'stage', + timeWindow: '7d', + environment: 'stage', + pivot: 'cve', + }), + ); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [SecurityFindingsPageComponent], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + queryParamMap: queryParamMap$.asObservable(), + }, + }, + { + provide: HttpClient, + useValue: { + get: () => of({ items: [], total: 0, pivot: 'cve', facets: [] }), + }, + }, + { + provide: PlatformContextStore, + useValue: { + initialize: () => undefined, + contextVersion: signal(0), + selectedRegions: () => ['us-east'], + selectedEnvironments: () => ['stage'], + }, + }, + ], + }); + }); + + it('merges the active query scope when triage filters rewrite the url', () => { + const fixture = TestBed.createComponent(SecurityFindingsPageComponent); + const component = fixture.componentInstance; + const router = TestBed.inject(Router); + const route = TestBed.inject(ActivatedRoute); + const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + fixture.detectChanges(); + component.reloadFromFilters(); + + expect(navigateSpy).toHaveBeenCalledWith([], { + relativeTo: route, + replaceUrl: true, + queryParamsHandling: 'merge', + queryParams: { + pivot: 'cve', + q: null, + severity: null, + reachability: null, + vex: null, + exception: null, + blocks: null, + }, + }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts new file mode 100644 index 000000000..a70bdc7d6 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts @@ -0,0 +1,322 @@ +import { HttpClient } from '@angular/common/http'; +import { signal, Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ActivatedRoute, Router, RouterLink, convertToParamMap, provideRouter } from '@angular/router'; +import { BehaviorSubject, of } from 'rxjs'; + +import { PlatformContextStore } from '../context/platform-context.store'; +import { TopologyDataService } from '../../features/topology/topology-data.service'; +import { EnvironmentPosturePageComponent } from '../../features/topology/environment-posture-page.component'; +import { TopologyAgentsPageComponent } from '../../features/topology/topology-agents-page.component'; +import { TopologyEnvironmentDetailPageComponent } from '../../features/topology/topology-environment-detail-page.component'; +import { TopologyHostsPageComponent } from '../../features/topology/topology-hosts-page.component'; +import { TopologyMapPageComponent } from '../../features/topology/topology-map-page.component'; +import { TopologyOverviewPageComponent } from '../../features/topology/topology-overview-page.component'; +import { TopologyPromotionPathsPageComponent } from '../../features/topology/topology-promotion-paths-page.component'; +import { TopologyRegionsEnvironmentsPageComponent } from '../../features/topology/topology-regions-environments-page.component'; +import { TopologyShellComponent } from '../../features/topology/topology-shell.component'; +import { TopologyTargetsPageComponent } from '../../features/topology/topology-targets-page.component'; + +const routeData$ = new BehaviorSubject>({}); +const queryParamMap$ = new BehaviorSubject(convertToParamMap({})); +const paramMap$ = new BehaviorSubject(convertToParamMap({ environmentId: 'stage' })); + +const mockContextStore = { + initialize: () => undefined, + contextVersion: signal(0), + regionSummary: () => 'US East', + environmentSummary: () => 'Staging', + selectedRegions: () => ['us-east'], + selectedEnvironments: () => ['stage'], + regions: () => [ + { regionId: 'us-east', displayName: 'US East', sortOrder: 10, enabled: true }, + ], + environments: () => [ + { + environmentId: 'stage', + regionId: 'us-east', + environmentType: 'staging', + displayName: 'Staging', + sortOrder: 20, + enabled: true, + }, + ], +}; + +const mockTopologyDataService = { + list: jasmine.createSpy('list').and.callFake((endpoint: string) => { + switch (endpoint) { + case '/api/v2/topology/regions': + return of([{ regionId: 'us-east', displayName: 'US East', environmentCount: 1, targetCount: 1 }]); + case '/api/v2/topology/environments': + return of([ + { + environmentId: 'stage', + displayName: 'Staging', + regionId: 'us-east', + environmentType: 'staging', + targetCount: 1, + }, + ]); + case '/api/v2/topology/targets': + return of([ + { + targetId: 'target-1', + name: 'api-web', + regionId: 'us-east', + environmentId: 'stage', + targetType: 'vm', + healthStatus: 'healthy', + hostId: 'host-1', + agentId: 'agent-1', + componentVersionId: 'component-1', + lastSyncAt: '2026-03-10T00:00:00Z', + }, + ]); + case '/api/v2/topology/hosts': + return of([ + { + hostId: 'host-1', + hostName: 'host-1', + regionId: 'us-east', + environmentId: 'stage', + runtimeType: 'containerd', + status: 'healthy', + targetCount: 1, + agentId: 'agent-1', + lastSeenAt: '2026-03-10T00:00:00Z', + }, + ]); + case '/api/v2/topology/agents': + return of([ + { + agentId: 'agent-1', + agentName: 'agent-1', + regionId: 'us-east', + environmentId: 'stage', + status: 'active', + assignedTargetCount: 1, + capabilities: ['deploy'], + lastHeartbeatAt: '2026-03-10T00:00:00Z', + }, + ]); + case '/api/v2/topology/promotion-paths': + return of([ + { + pathId: 'path-1', + regionId: 'us-east', + sourceEnvironmentId: 'dev', + targetEnvironmentId: 'stage', + status: 'running', + requiredApprovals: 1, + gateProfileId: 'gate-1', + }, + ]); + default: + return of([]); + } + }), +}; + +const mockHttpClient = { + get: jasmine.createSpy('get').and.callFake((url: string) => { + switch (url) { + case '/api/v2/releases/activity': + return of({ + items: [ + { + activityId: 'run-1', + releaseId: 'release-1', + releaseName: 'Release 1', + status: 'blocked', + correlationKey: 'corr-1', + occurredAt: '2026-03-10T00:00:00Z', + }, + ], + }); + case '/api/v2/security/findings': + return of({ + items: [ + { + findingId: 'finding-1', + cveId: 'CVE-2026-0001', + severity: 'high', + effectiveDisposition: 'action_required', + }, + ], + }); + case '/api/v2/evidence/packs': + return of({ + items: [ + { + capsuleId: 'capsule-1', + status: 'fresh', + updatedAt: '2026-03-10T00:00:00Z', + }, + ], + }); + case '/api/v2/topology/environments': + return of({ + items: [ + { + environmentId: 'stage', + displayName: 'Staging', + regionId: 'us-east', + status: 'healthy', + }, + ], + }); + default: + return of({ items: [] }); + } + }), +}; + +function configureTestingModule(component: Type): void { + routeData$.next({}); + queryParamMap$.next(convertToParamMap({})); + paramMap$.next(convertToParamMap({ environmentId: 'stage' })); + mockTopologyDataService.list.calls.reset(); + mockHttpClient.get.calls.reset(); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [component], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + data: routeData$.asObservable(), + queryParamMap: queryParamMap$.asObservable(), + paramMap: paramMap$.asObservable(), + }, + }, + { provide: PlatformContextStore, useValue: mockContextStore }, + { provide: TopologyDataService, useValue: mockTopologyDataService }, + { provide: HttpClient, useValue: mockHttpClient }, + ], + }); +} + +function routerLinksFor(component: Type): RouterLink[] { + const fixture = TestBed.createComponent(component); + fixture.detectChanges(); + fixture.detectChanges(); + return fixture.debugElement.queryAll(By.directive(RouterLink)).map((debugElement) => debugElement.injector.get(RouterLink)); +} + +describe('Topology scope-preserving links', () => { + it('marks topology shell tabs to merge the active query scope', () => { + configureTestingModule(TopologyShellComponent); + + const links = routerLinksFor(TopologyShellComponent); + + expect(links.length).toBe(11); + expect(links.every((link) => link.queryParamsHandling === 'merge')).toBeTrue(); + }); + + it('marks topology page links to merge the active query scope', () => { + const cases: Array<{ component: Type; routeData?: Record; expectedMinCount: number }> = [ + { component: TopologyOverviewPageComponent, expectedMinCount: 4 }, + { component: TopologyRegionsEnvironmentsPageComponent, routeData: { defaultView: 'flat' }, expectedMinCount: 4 }, + { component: TopologyEnvironmentDetailPageComponent, expectedMinCount: 4 }, + { component: TopologyTargetsPageComponent, expectedMinCount: 3 }, + { component: TopologyHostsPageComponent, expectedMinCount: 3 }, + { component: TopologyAgentsPageComponent, expectedMinCount: 3 }, + { component: EnvironmentPosturePageComponent, expectedMinCount: 3 }, + ]; + + for (const testCase of cases) { + configureTestingModule(testCase.component); + routeData$.next(testCase.routeData ?? {}); + + const links = routerLinksFor(testCase.component); + + expect(links.length).toBeGreaterThanOrEqual(testCase.expectedMinCount); + expect(links.every((link) => link.queryParamsHandling === 'merge')).toBeTrue(); + } + }); + + it('marks promotion inventory links to merge the active query scope', () => { + configureTestingModule(TopologyPromotionPathsPageComponent); + + const fixture = TestBed.createComponent(TopologyPromotionPathsPageComponent); + fixture.componentInstance.viewMode.set('inventory'); + fixture.detectChanges(); + fixture.detectChanges(); + + const links = fixture.debugElement.queryAll(By.directive(RouterLink)).map((debugElement) => debugElement.injector.get(RouterLink)); + + expect(links.length).toBeGreaterThan(0); + expect(links.every((link) => link.queryParamsHandling === 'merge')).toBeTrue(); + }); + + it('merges query scope for topology overview drilldowns', () => { + configureTestingModule(TopologyOverviewPageComponent); + + const fixture = TestBed.createComponent(TopologyOverviewPageComponent); + const component = fixture.componentInstance; + const router = TestBed.inject(Router); + const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + component.openHit({ + id: 'env:stage', + label: 'Staging', + sublabel: 'us-east', + type: 'environment', + } as never); + component.openHit({ + id: 'target:target-1', + label: 'api-web', + sublabel: 'vm', + type: 'target', + } as never); + component.openHit({ + id: 'host:host-1', + label: 'host-1', + sublabel: 'containerd', + type: 'host', + } as never); + component.openHit({ + id: 'agent:agent-1', + label: 'agent-1', + sublabel: 'deploy', + type: 'agent', + } as never); + component.openTarget('target-1'); + + expect(navigateSpy.calls.allArgs()).toEqual([ + [['/setup/topology/environments', 'stage', 'posture'], { queryParamsHandling: 'merge' }], + [['/setup/topology/targets'], { queryParams: { targetId: 'target-1' }, queryParamsHandling: 'merge' }], + [['/setup/topology/hosts'], { queryParams: { hostId: 'host-1' }, queryParamsHandling: 'merge' }], + [['/setup/topology/agents'], { queryParams: { agentId: 'agent-1' }, queryParamsHandling: 'merge' }], + [['/setup/topology/targets'], { queryParams: { targetId: 'target-1' }, queryParamsHandling: 'merge' }], + ]); + }); + + it('merges query scope for topology map node navigation', () => { + configureTestingModule(TopologyMapPageComponent); + + const fixture = TestBed.createComponent(TopologyMapPageComponent); + const component = fixture.componentInstance; + const router = TestBed.inject(Router); + const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + component['navigateToNode']({ id: 'region:us-east', kind: 'region', label: 'US East', sublabel: '1 env' }); + component['navigateToNode']({ + id: 'env:stage', + kind: 'environment', + label: 'Staging', + sublabel: 'us-east', + environmentId: 'stage', + }); + component['navigateToNode']({ id: 'agent:agent-1', kind: 'agent', label: 'agent-1', sublabel: 'active' }); + + expect(navigateSpy.calls.allArgs()).toEqual([ + [['/setup/topology/regions'], { queryParamsHandling: 'merge' }], + [['/setup/topology/environments', 'stage', 'posture'], { queryParamsHandling: 'merge' }], + [['/setup/topology/agents'], { queryParams: { agentId: 'agent-1' }, queryParamsHandling: 'merge' }], + ]); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/security/security-findings-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/security-findings-page.component.ts index 020d5558a..f9950f529 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/security-findings-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/security-findings-page.component.ts @@ -173,7 +173,7 @@ type EvidenceTab = 'why' | 'sbom' | 'reachability' | 'vex' | 'waiver' | 'policy'

Evidence Rail

@if (selected()) {

- {{ selected()!.cveId }} · {{ selected()!.componentName }} · {{ selected()!.region }}/{{ selected()!.environment }} + {{ selected()!.cveId }} · {{ selected()!.componentName }} · {{ selected()!.region }}/{{ selected()!.environment }}