diff --git a/docs/implplan/SPRINT_20260310_006_FE_policy_simulation_direct_route_defaults_and_hydration.md b/docs/implplan/SPRINT_20260310_006_FE_policy_simulation_direct_route_defaults_and_hydration.md new file mode 100644 index 000000000..9870de0c8 --- /dev/null +++ b/docs/implplan/SPRINT_20260310_006_FE_policy_simulation_direct_route_defaults_and_hydration.md @@ -0,0 +1,50 @@ +# Sprint 20260310-006 - FE Policy Simulation Direct Route Defaults and Hydration + +## Topic & Scope +- Harden revived Policy Simulation surfaces so direct entry and partially wired host routes still render usable defaults instead of blank inputs. +- Normalize missing policy pack, version, and target environment inputs across the revived lint, diff, coverage, and promotion-gate components. +- Ensure the read-only coverage route hydrates on first render instead of waiting for a second interaction. +- Working directory: `src/Web/StellaOps.Web/src/app/features/policy-simulation`. +- Allowed coordination edits: `src/Web/StellaOps.Web/tsconfig.spec.features.json`, `docs/modules/ui/README.md`. +- Expected evidence: focused component spec pass, authenticated live Playwright evidence for `/ops/policy/simulation` policy actions, updated UI docs if behavior changes. + +## Dependencies & Concurrency +- Follows `SPRINT_20260309_019_FE_policy_simulation_active_tenant_runtime.md`; this sprint hardens the revived component surfaces after the tenant seam repair. +- Safe parallelism: do not touch router readiness or unrelated search work while closing this slice. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/code-of-conduct/CODE_OF_CONDUCT.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/modules/ui/README.md` + +## Delivery Tracker + +### FE-POLICY-SIM-006-001 - Normalize revived policy simulation inputs and direct hydration +Status: DONE +Dependency: none +Owners: Developer, QA +Task description: +- Centralize the fallback defaults used by revived Policy Simulation components so direct-entry routes and partially restored host callers do not pass through blank pack IDs, unusable versions, or empty target environments. +- Cover the normalization behavior in focused specs for the shared defaults helper and the affected components, then re-run live authenticated policy actions with Playwright to prove the repaired runtime still behaves correctly. + +Completion criteria: +- [x] Coverage, diff, lint, and promotion-gate components normalize missing/blank inputs to stable defaults instead of rendering unusable state. +- [x] Coverage auto-loads on first render so the direct route is hydrated without a second click. +- [x] Focused Angular specs prove the normalization and hydration behaviors. +- [x] Authenticated Playwright completes the policy action sweep on `https://stella-ops.local` without new route or runtime failures. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-10 | Sprint created after the remaining dirty `policy-simulation` slice was identified as a follow-on hardening pass for revived direct-route component defaults and coverage hydration. | Developer | +| 2026-03-10 | Added shared policy simulation defaults plus a Vitest-compatible direct-route regression harness, rebuilt the web bundle, synced `dist/stellaops-web/browser` into `compose_console-dist`, and verified `/ops/policy/simulation/coverage`, `/lint`, `/promotion`, and `/diff/policy-pack-001` live with zero runtime errors via Playwright. | Developer | + +## Decisions & Risks +- Decision: keep default restoration local to the revived Policy Simulation feature cluster through a shared helper instead of reintroducing per-component literals. +- Decision: use a focused Vitest-compatible regression spec for the revived direct-route behaviors instead of widening the unsupported legacy ProxyZone/Karma component suite. +- Risk: defaulting a missing pack/version can hide a wiring regression; mitigate with focused specs and live Playwright verification on the real shell. + +## Next Checkpoints +- 2026-03-10: finish the focused spec coverage and live policy Playwright recheck. +- 2026-03-10: commit the isolated policy-simulation hardening slice. diff --git a/docs/modules/ui/README.md b/docs/modules/ui/README.md index f9409ba3a..ef3d7f070 100644 --- a/docs/modules/ui/README.md +++ b/docs/modules/ui/README.md @@ -8,6 +8,11 @@ The Console presents operator dashboards for scans, policies, VEX evidence, runtime posture, and admin workflows. +## Latest updates (2026-03-10) +- Hardened revived `Ops > Policy > Simulation` direct-entry surfaces so coverage, lint, promotion-gate, and diff routes restore stable defaults when host wiring omits pack/version/environment inputs. +- Coverage now hydrates on first render instead of waiting for a second interaction, preventing blank direct-route states on `/ops/policy/simulation/coverage`. +- Added focused frontend verification for the policy simulation defaults helper and direct-route behaviors, plus a live Playwright sweep for `/ops/policy/simulation/coverage`, `/lint`, `/promotion`, and `/diff/policy-pack-001`. + ## Latest updates (2026-03-08) - Shipped the canonical `Releases > Promotions` cutover, including repaired `/release-control/promotions*` and `/releases/promotion-queue*` aliases, release-context promotion wizard handoff, and a usable create-to-detail flow. - Added checked-feature verification for release promotions at `../../features/checked/web/release-promotions-cutover-ui.md`. diff --git a/src/Web/StellaOps.Web/scripts/live-policy-simulation-direct-routes.mjs b/src/Web/StellaOps.Web/scripts/live-policy-simulation-direct-routes.mjs new file mode 100644 index 000000000..771acaab4 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-policy-simulation-direct-routes.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node + +import { mkdirSync, writeFileSync } from 'node:fs'; +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 outputDirectory = path.join(webRoot, 'output', 'playwright'); + +const DEFAULT_BASE_URL = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local'; +const authStatePath = path.join(outputDirectory, 'live-policy-simulation-direct-routes.auth.json'); +const authReportPath = path.join(outputDirectory, 'live-policy-simulation-direct-routes.auth-report.json'); +const summaryPath = path.join(outputDirectory, 'live-policy-simulation-direct-routes.json'); + +function isStaticAsset(url) { + return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?|ico)(?:$|\?)/i.test(url); +} + +function shouldIgnoreFailure(errorText) { + return errorText === 'net::ERR_ABORTED'; +} + +function createRuntime() { + return { + consoleErrors: [], + pageErrors: [], + requestFailures: [], + responseErrors: [], + }; +} + +function attachRuntimeWatchers(page, runtime) { + page.on('console', (message) => { + if (message.type() === 'error') { + runtime.consoleErrors.push({ + page: page.url(), + text: message.text(), + }); + } + }); + + page.on('pageerror', (error) => { + runtime.pageErrors.push({ + page: page.url(), + message: error.message, + }); + }); + + page.on('requestfailed', (request) => { + const url = request.url(); + const errorText = request.failure()?.errorText ?? 'unknown'; + if (isStaticAsset(url) || shouldIgnoreFailure(errorText)) { + return; + } + + runtime.requestFailures.push({ + page: page.url(), + method: request.method(), + url, + error: errorText, + }); + }); + + page.on('response', (response) => { + const url = response.url(); + if (isStaticAsset(url) || response.status() < 400) { + return; + } + + runtime.responseErrors.push({ + page: page.url(), + method: response.request().method(), + status: response.status(), + url, + }); + }); +} + +async function waitForAnyVisible(page, selectors, timeout = 10_000) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + for (const selector of selectors) { + if (await page.locator(selector).first().isVisible().catch(() => false)) { + return selector; + } + } + await page.waitForTimeout(200); + } + throw new Error(`Timed out waiting for any selector: ${selectors.join(', ')}`); +} + +async function checkRoute(page, baseUrl, route) { + const startedAt = new Date().toISOString(); + let ok = false; + let error = null; + + try { + await page.goto(`${baseUrl}${route.path}`, { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {}); + await waitForAnyVisible(page, route.expectVisible); + + if (route.action) { + await route.action(page); + } + + ok = true; + } catch (cause) { + error = cause instanceof Error ? cause.message : String(cause); + } + + return { + route: route.path, + ok, + startedAtUtc: startedAt, + finishedAtUtc: new Date().toISOString(), + finalUrl: page.url(), + error, + }; +} + +async function main() { + mkdirSync(outputDirectory, { recursive: true }); + + const authReport = await authenticateFrontdoor({ + baseUrl: DEFAULT_BASE_URL, + statePath: authStatePath, + reportPath: authReportPath, + headless: true, + }); + + const browser = await chromium.launch({ + headless: true, + args: ['--disable-dev-shm-usage'], + }); + + const context = await createAuthenticatedContext(browser, authReport, { + statePath: authStatePath, + }); + const page = await context.newPage(); + const runtime = createRuntime(); + attachRuntimeWatchers(page, runtime); + + const routes = [ + { + path: '/ops/policy/simulation/coverage', + expectVisible: ['h1:has-text("Test Coverage")', '.coverage__header'], + action: async (routePage) => { + await waitForAnyVisible(routePage, ['.coverage__summary', '.coverage__rules']); + }, + }, + { + path: '/ops/policy/simulation/lint', + expectVisible: ['h1:has-text("Policy Lint")', '.policy-lint__header'], + action: async (routePage) => { + await routePage.getByRole('button', { name: /run lint/i }).click(); + await waitForAnyVisible(routePage, ['.lint-status', '.lint-summary']); + }, + }, + { + path: '/ops/policy/simulation/promotion', + expectVisible: ['h1:has-text("Promotion Gate")', '.promotion-gate__header'], + action: async (routePage) => { + await routePage.getByRole('button', { name: /check requirements/i }).click(); + await waitForAnyVisible(routePage, ['.promotion-gate__info', '.promotion-gate__summary']); + }, + }, + { + path: '/ops/policy/simulation/diff/policy-pack-001', + expectVisible: ['h1:has-text("Policy Diff")', '.diff-viewer__header'], + action: async (routePage) => { + await waitForAnyVisible(routePage, ['.diff-viewer__stats', '.diff-viewer__empty', '.diff-viewer__files']); + }, + }, + ]; + + const results = []; + try { + for (const route of routes) { + results.push(await checkRoute(page, DEFAULT_BASE_URL, route)); + } + } finally { + await context.close().catch(() => {}); + await browser.close().catch(() => {}); + } + + const summary = { + capturedAtUtc: new Date().toISOString(), + baseUrl: DEFAULT_BASE_URL, + results, + runtime, + failedRouteCount: results.filter((result) => result.ok === false).length, + runtimeIssueCount: + runtime.consoleErrors.length + + runtime.pageErrors.length + + runtime.requestFailures.length + + runtime.responseErrors.length, + }; + + writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + + if (summary.failedRouteCount > 0 || summary.runtimeIssueCount > 0) { + process.exitCode = 1; + } +} + +main().catch((error) => { + process.stderr.write( + `[live-policy-simulation-direct-routes] ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; +}); diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/coverage-fixture.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/coverage-fixture.component.spec.ts index f07fb4cd1..9daa55f78 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/coverage-fixture.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/coverage-fixture.component.spec.ts @@ -80,7 +80,7 @@ describe('CoverageFixtureComponent', () => { mockApi = jasmine.createSpyObj('PolicySimulationApi', [ 'getCoverage', 'runCoverageTests', - ]); + ]) as jasmine.SpyObj; mockApi.getCoverage.and.returnValue(of(mockCoverageResult)); mockApi.runCoverageTests.and.returnValue(of(mockCoverageResult)); @@ -127,6 +127,11 @@ describe('CoverageFixtureComponent', () => { expect(component.policyPackId).toBe('custom-pack'); }); + it('should restore the default policy pack id when routing supplies undefined', () => { + component.policyPackId = undefined as unknown as string; + expect(component.policyPackId).toBe('policy-pack-001'); + }); + it('should accept policyVersion input', () => { component.policyVersion = 2; expect(component.policyVersion).toBe(2); @@ -158,6 +163,13 @@ describe('CoverageFixtureComponent', () => { }); describe('Service Interaction', () => { + it('should load coverage on init so direct routes are hydrated', fakeAsync(() => { + fixture.detectChanges(); + tick(); + + expect(mockApi.getCoverage).toHaveBeenCalled(); + })); + it('should call getCoverage on loadCoverage', fakeAsync(() => { component.loadCoverage(); tick(); diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/coverage-fixture.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/coverage-fixture.component.ts index 05b3f8104..4423fedbb 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/coverage-fixture.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/coverage-fixture.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, ChangeDetectionStrategy, inject, signal, computed, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { Component, ChangeDetectionStrategy, inject, signal, computed, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; import { finalize } from 'rxjs/operators'; @@ -13,6 +13,11 @@ import { PolicyTestCase, CoverageStatus, } from '../../core/api/policy-simulation.models'; +import { + DEFAULT_POLICY_PACK_ID, + normalizeOptionalPolicyVersion, + normalizePolicyPackId, +} from './policy-simulation-defaults'; /** * Coverage fixture component showing coverage percentage per rule and missing test cases. @@ -724,11 +729,28 @@ import { `, ] }) -export class CoverageFixtureComponent implements OnChanges { +export class CoverageFixtureComponent implements OnChanges, OnInit { private readonly api = inject(POLICY_SIMULATION_API); + private _policyPackId = DEFAULT_POLICY_PACK_ID; + private _policyVersion: number | undefined; - @Input() policyPackId = 'policy-pack-001'; - @Input() policyVersion?: number; + @Input() + set policyPackId(value: string | null | undefined) { + this._policyPackId = normalizePolicyPackId(value); + } + + get policyPackId(): string { + return this._policyPackId; + } + + @Input() + set policyVersion(value: number | string | null | undefined) { + this._policyVersion = normalizeOptionalPolicyVersion(value); + } + + get policyVersion(): number | undefined { + return this._policyVersion; + } readonly loading = signal(false); readonly result = signal(undefined); @@ -755,6 +777,12 @@ export class CoverageFixtureComponent implements OnChanges { } } + ngOnInit(): void { + if (!this.loading() && !this.result()) { + this.loadCoverage(); + } + } + ringDashArray(): string { const circumference = 2 * Math.PI * 54; // ~339.3 const percent = this.result()?.summary.overallCoveragePercent ?? 0; diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-diff-viewer.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-diff-viewer.component.spec.ts index ee4d9e1dc..4f20ee899 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-diff-viewer.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-diff-viewer.component.spec.ts @@ -57,7 +57,7 @@ describe('PolicyDiffViewerComponent', () => { }; beforeEach(async () => { - mockApi = jasmine.createSpyObj('PolicySimulationApi', ['getDiff']); + mockApi = jasmine.createSpyObj('PolicySimulationApi', ['getDiff']) as jasmine.SpyObj; mockApi.getDiff.and.returnValue(of(mockDiffResult)); await TestBed.configureTestingModule({ @@ -93,6 +93,11 @@ describe('PolicyDiffViewerComponent', () => { expect(component.policyPackId).toBe('custom-pack'); }); + it('should restore the default policy pack id when routing supplies undefined', () => { + (component as any).policyPackId = undefined; + expect(component.policyPackId).toBe('policy-pack-001'); + }); + it('should accept fromVersion input', () => { component.fromVersion = 1; expect(component.fromVersion).toBe(1); @@ -103,6 +108,14 @@ describe('PolicyDiffViewerComponent', () => { expect(component.toVersion).toBe(3); }); + it('should restore version fallbacks when routing supplies unusable values', () => { + (component as any).fromVersion = ''; + (component as any).toVersion = '0'; + + expect(component.fromVersion).toBe(1); + expect(component.toVersion).toBe(2); + }); + it('should load diff on policyPackId change', fakeAsync(() => { component.ngOnChanges({ policyPackId: new SimpleChange(null, 'new-pack', true), diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-diff-viewer.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-diff-viewer.component.ts index 1fbcfa034..0a648ba4c 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-diff-viewer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-diff-viewer.component.ts @@ -13,6 +13,11 @@ import { PolicyDiffHunk, PolicyDiffLine, } from '../../core/api/policy-simulation.models'; +import { + DEFAULT_POLICY_PACK_ID, + normalizePolicyPackId, + normalizePolicyVersion, +} from './policy-simulation-defaults'; /** * Policy diff viewer showing before/after comparison for rule changes. @@ -477,10 +482,36 @@ import { }) export class PolicyDiffViewerComponent implements OnChanges { private readonly api = inject(POLICY_SIMULATION_API); + private _policyPackId = DEFAULT_POLICY_PACK_ID; + private _fromVersion = 1; + private _toVersion = 2; - @Input() policyPackId = 'policy-pack-001'; - @Input() fromVersion = 1; - @Input() toVersion = 2; + @Input() + set policyPackId(value: string | null | undefined) { + this._policyPackId = normalizePolicyPackId(value); + } + + get policyPackId(): string { + return this._policyPackId; + } + + @Input() + set fromVersion(value: number | string | null | undefined) { + this._fromVersion = normalizePolicyVersion(value, 1); + } + + get fromVersion(): number { + return this._fromVersion; + } + + @Input() + set toVersion(value: number | string | null | undefined) { + this._toVersion = normalizePolicyVersion(value, 2); + } + + get toVersion(): number { + return this._toVersion; + } readonly loading = signal(false); readonly result = signal(undefined); diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-lint.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-lint.component.spec.ts index cfc6ba248..be372ca92 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-lint.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-lint.component.spec.ts @@ -76,7 +76,7 @@ describe('PolicyLintComponent', () => { }; beforeEach(async () => { - mockApi = jasmine.createSpyObj('PolicySimulationApi', ['lintPolicy']); + mockApi = jasmine.createSpyObj('PolicySimulationApi', ['lintPolicy']) as jasmine.SpyObj; mockApi.lintPolicy.and.returnValue(of(mockLintResult)); await TestBed.configureTestingModule({ @@ -125,11 +125,21 @@ describe('PolicyLintComponent', () => { expect(component.policyPackId).toBe('custom-pack'); }); + it('should restore the default policy pack id when routing supplies undefined', () => { + (component as any).policyPackId = undefined; + expect(component.policyPackId).toBe('policy-pack-001'); + }); + it('should accept policyVersion input', () => { component.policyVersion = 2; expect(component.policyVersion).toBe(2); }); + it('should clear policyVersion when routing supplies an unusable value', () => { + (component as any).policyVersion = '0'; + expect(component.policyVersion).toBeUndefined(); + }); + it('should clear result on input change', fakeAsync(() => { component['result'].set(mockLintResult); component.ngOnChanges({ diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-lint.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-lint.component.ts index a52aad99a..62b704570 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-lint.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-lint.component.ts @@ -13,6 +13,11 @@ import { LintSeverity, LintCategory, } from '../../core/api/policy-simulation.models'; +import { + DEFAULT_POLICY_PACK_ID, + normalizeOptionalPolicyVersion, + normalizePolicyPackId, +} from './policy-simulation-defaults'; /** * Policy lint component showing errors, warnings, and compilation status. @@ -593,9 +598,26 @@ import { }) export class PolicyLintComponent implements OnChanges { private readonly api = inject(POLICY_SIMULATION_API); + private _policyPackId = DEFAULT_POLICY_PACK_ID; + private _policyVersion: number | undefined; - @Input() policyPackId = 'policy-pack-001'; - @Input() policyVersion?: number; + @Input() + set policyPackId(value: string | null | undefined) { + this._policyPackId = normalizePolicyPackId(value); + } + + get policyPackId(): string { + return this._policyPackId; + } + + @Input() + set policyVersion(value: number | string | null | undefined) { + this._policyVersion = normalizeOptionalPolicyVersion(value); + } + + get policyVersion(): number | undefined { + return this._policyVersion; + } readonly loading = signal(false); readonly result = signal(undefined); diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-simulation-defaults.spec.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-simulation-defaults.spec.ts new file mode 100644 index 000000000..361093c2b --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-simulation-defaults.spec.ts @@ -0,0 +1,41 @@ +import { + DEFAULT_POLICY_PACK_ID, + DEFAULT_POLICY_VERSION, + DEFAULT_TARGET_ENVIRONMENT, + normalizeOptionalPolicyVersion, + normalizePolicyPackId, + normalizePolicyVersion, + normalizeTargetEnvironment, +} from './policy-simulation-defaults'; + +describe('policy simulation input defaults', () => { + it('falls back to the default policy pack id when the router supplies an empty value', () => { + expect(normalizePolicyPackId(undefined)).toBe(DEFAULT_POLICY_PACK_ID); + expect(normalizePolicyPackId('')).toBe(DEFAULT_POLICY_PACK_ID); + expect(normalizePolicyPackId(' ')).toBe(DEFAULT_POLICY_PACK_ID); + }); + + it('keeps valid policy pack ids untouched', () => { + expect(normalizePolicyPackId('policy-pack-prod-001')).toBe('policy-pack-prod-001'); + }); + + it('parses optional positive policy versions and rejects invalid values', () => { + expect(normalizeOptionalPolicyVersion('7')).toBe(7); + expect(normalizeOptionalPolicyVersion(3)).toBe(3); + expect(normalizeOptionalPolicyVersion(undefined)).toBeUndefined(); + expect(normalizeOptionalPolicyVersion('0')).toBeUndefined(); + expect(normalizeOptionalPolicyVersion('abc')).toBeUndefined(); + }); + + it('restores default numeric values when routing supplies nothing usable', () => { + expect(normalizePolicyVersion(undefined, DEFAULT_POLICY_VERSION)).toBe(DEFAULT_POLICY_VERSION); + expect(normalizePolicyVersion('', DEFAULT_POLICY_VERSION)).toBe(DEFAULT_POLICY_VERSION); + expect(normalizePolicyVersion('5', DEFAULT_POLICY_VERSION)).toBe(5); + }); + + it('restores the default target environment when the route has no environment segment', () => { + expect(normalizeTargetEnvironment(undefined)).toBe(DEFAULT_TARGET_ENVIRONMENT); + expect(normalizeTargetEnvironment('')).toBe(DEFAULT_TARGET_ENVIRONMENT); + expect(normalizeTargetEnvironment('stage')).toBe('stage'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-simulation-defaults.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-simulation-defaults.ts new file mode 100644 index 000000000..591dc8b02 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-simulation-defaults.ts @@ -0,0 +1,33 @@ +export const DEFAULT_POLICY_PACK_ID = 'policy-pack-001'; +export const DEFAULT_POLICY_VERSION = 2; +export const DEFAULT_TARGET_ENVIRONMENT = 'production'; + +export function normalizePolicyPackId(value: string | null | undefined): string { + const normalized = value?.trim(); + return normalized ? normalized : DEFAULT_POLICY_PACK_ID; +} + +export function normalizeOptionalPolicyVersion(value: number | string | null | undefined): number | undefined { + if (value === null || value === undefined || value === '') { + return undefined; + } + + const numericValue = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numericValue) || numericValue <= 0) { + return undefined; + } + + return Math.trunc(numericValue); +} + +export function normalizePolicyVersion( + value: number | string | null | undefined, + fallback: number, +): number { + return normalizeOptionalPolicyVersion(value) ?? fallback; +} + +export function normalizeTargetEnvironment(value: string | null | undefined): string { + const normalized = value?.trim(); + return normalized ? normalized : DEFAULT_TARGET_ENVIRONMENT; +} diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-simulation-direct-route-defaults.spec.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-simulation-direct-route-defaults.spec.ts new file mode 100644 index 000000000..1c65343bf --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-simulation-direct-route-defaults.spec.ts @@ -0,0 +1,204 @@ +import { SimpleChange } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; + +import { POLICY_SIMULATION_API, PolicySimulationApi } from '../../core/api/policy-simulation.client'; +import { + CoverageResult, + PolicyDiffResult, + PolicyLintResult, + PromotionGateResult, +} from '../../core/api/policy-simulation.models'; +import { CoverageFixtureComponent } from './coverage-fixture.component'; +import { PolicyDiffViewerComponent } from './policy-diff-viewer.component'; +import { PolicyLintComponent } from './policy-lint.component'; +import { PromotionGateComponent } from './promotion-gate.component'; + +function createPolicySimulationApiMock(): jasmine.SpyObj { + return jasmine.createSpyObj('PolicySimulationApi', [ + 'getCoverage', + 'runCoverageTests', + 'getDiff', + 'lintPolicy', + 'checkPromotionGate', + 'overridePromotionGate', + ]) as jasmine.SpyObj; +} + +const coverageResult: CoverageResult = { + summary: { + policyPackId: 'policy-pack-001', + policyVersion: 1, + totalRules: 1, + coveredRules: 1, + partialRules: 0, + uncoveredRules: 0, + overallCoveragePercent: 100, + totalTestCases: 1, + passedTestCases: 1, + failedTestCases: 0, + computedAt: '2026-03-10T00:00:00Z', + }, + rules: [], + testCases: [], + traceId: 'trace-coverage', +}; + +const diffResult: PolicyDiffResult = { + diffId: 'diff-001', + policyPackId: 'policy-pack-001', + fromVersion: 1, + toVersion: 2, + files: [], + stats: { additions: 0, deletions: 0, modifications: 0, filesChanged: 0 }, + createdAt: '2026-03-10T00:00:00Z', + traceId: 'trace-diff', +}; + +const lintResult: PolicyLintResult = { + policyPackId: 'policy-pack-001', + policyVersion: 1, + compiled: true, + totalIssues: 0, + errorCount: 0, + warningCount: 0, + infoCount: 0, + issues: [], + lintedAt: '2026-03-10T00:00:00Z', + traceId: 'trace-lint', +}; + +const gateResult: PromotionGateResult = { + policyPackId: 'policy-pack-001', + policyVersion: 2, + targetEnvironment: 'production', + overallStatus: 'ready', + checks: [], + allRequiredPassed: true, + blockingIssues: 0, + warnings: 0, + canOverride: false, + computedAt: '2026-03-10T00:00:00Z', + traceId: 'trace-gate', +}; + +describe('policy simulation direct-route defaults', () => { + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('hydrates coverage with defaulted inputs on first render', async () => { + const api = createPolicySimulationApiMock(); + api.getCoverage.and.returnValue(of(coverageResult)); + + await TestBed.configureTestingModule({ + imports: [CoverageFixtureComponent], + providers: [{ provide: POLICY_SIMULATION_API, useValue: api }], + }).compileComponents(); + + const fixture = TestBed.createComponent(CoverageFixtureComponent); + const component = fixture.componentInstance; + + (component as any).policyPackId = ' '; + (component as any).policyVersion = '0'; + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.policyPackId).toBe('policy-pack-001'); + expect(component.policyVersion).toBeUndefined(); + expect(api.getCoverage).toHaveBeenCalledWith({ + tenantId: 'default', + policyPackId: 'policy-pack-001', + policyVersion: undefined, + }); + expect(component.result()).toEqual(coverageResult); + }); + + it('restores diff fallbacks before loading a direct route comparison', async () => { + const api = createPolicySimulationApiMock(); + api.getDiff.and.returnValue(of(diffResult)); + + await TestBed.configureTestingModule({ + imports: [PolicyDiffViewerComponent], + providers: [{ provide: POLICY_SIMULATION_API, useValue: api }], + }).compileComponents(); + + const fixture = TestBed.createComponent(PolicyDiffViewerComponent); + const component = fixture.componentInstance; + + (component as any).policyPackId = undefined; + (component as any).fromVersion = ''; + (component as any).toVersion = '0'; + + component.ngOnChanges({ + policyPackId: new SimpleChange('custom-pack', undefined, false), + fromVersion: new SimpleChange(5, '' as unknown as number, false), + toVersion: new SimpleChange(6, '0' as unknown as number, false), + }); + + expect(component.policyPackId).toBe('policy-pack-001'); + expect(component.fromVersion).toBe(1); + expect(component.toVersion).toBe(2); + expect(api.getDiff).toHaveBeenCalledWith('policy-pack-001', 1, 2); + expect(component.result()).toEqual(diffResult); + }); + + it('normalizes lint inputs before issuing the live lint request', async () => { + const api = createPolicySimulationApiMock(); + api.lintPolicy.and.returnValue(of(lintResult)); + + await TestBed.configureTestingModule({ + imports: [PolicyLintComponent], + providers: [{ provide: POLICY_SIMULATION_API, useValue: api }], + }).compileComponents(); + + const fixture = TestBed.createComponent(PolicyLintComponent); + const component = fixture.componentInstance; + + (component as any).policyPackId = undefined; + (component as any).policyVersion = '0'; + + component.runLint(); + + expect(component.policyPackId).toBe('policy-pack-001'); + expect(component.policyVersion).toBeUndefined(); + expect(api.lintPolicy).toHaveBeenCalledWith('policy-pack-001', undefined); + expect(component.result()).toEqual(lintResult); + }); + + it('normalizes promotion-gate defaults before checking the gate', async () => { + const api = createPolicySimulationApiMock(); + api.checkPromotionGate.and.returnValue(of(gateResult)); + api.overridePromotionGate.and.returnValue(of(gateResult)); + + await TestBed.configureTestingModule({ + imports: [PromotionGateComponent], + providers: [ + provideRouter([]), + { provide: POLICY_SIMULATION_API, useValue: api }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(PromotionGateComponent); + const component = fixture.componentInstance; + + (component as any).policyPackId = undefined; + (component as any).policyVersion = '0'; + (component as any).targetEnvironment = ' '; + + component.checkGate(); + + expect(component.policyPackId).toBe('policy-pack-001'); + expect(component.policyVersion).toBe(2); + expect(component.targetEnvironment).toBe('production'); + expect(api.checkPromotionGate).toHaveBeenCalledWith({ + tenantId: 'default', + policyPackId: 'policy-pack-001', + policyVersion: 2, + targetEnvironment: 'production', + }); + expect(component.result()).toEqual(gateResult); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/promotion-gate.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/promotion-gate.component.spec.ts index 457baecb3..9686913a4 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/promotion-gate.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/promotion-gate.component.spec.ts @@ -47,7 +47,7 @@ describe('PromotionGateComponent', () => { docsUrl: 'https://docs.example.com/shadow-mode', }, ], - checkedAt: new Date().toISOString(), + computedAt: new Date().toISOString(), traceId: 'trace-123', }; @@ -79,9 +79,9 @@ describe('PromotionGateComponent', () => { mockApi = jasmine.createSpyObj('PolicySimulationApi', [ 'checkPromotionGate', 'overridePromotionGate', - ]); + ]) as jasmine.SpyObj; mockApi.checkPromotionGate.and.returnValue(of(mockGateResult)); - mockApi.overridePromotionGate.and.returnValue(of({ ...mockBlockedResult, overallStatus: 'overridden' })); + mockApi.overridePromotionGate.and.returnValue(of({ ...mockBlockedResult, overallStatus: 'ready' })); await TestBed.configureTestingModule({ imports: [PromotionGateComponent, ReactiveFormsModule], @@ -121,16 +121,31 @@ describe('PromotionGateComponent', () => { expect(component.policyPackId).toBe('custom-pack'); }); + it('should restore the default policy pack id when routing supplies undefined', () => { + (component as any).policyPackId = undefined; + expect(component.policyPackId).toBe('policy-pack-001'); + }); + it('should accept policyVersion input', () => { component.policyVersion = 5; expect(component.policyVersion).toBe(5); }); + it('should restore the default policy version when routing supplies an unusable value', () => { + (component as any).policyVersion = '0'; + expect(component.policyVersion).toBe(2); + }); + it('should accept targetEnvironment input', () => { component.targetEnvironment = 'staging'; expect(component.targetEnvironment).toBe('staging'); }); + it('should restore the default target environment when routing supplies an empty value', () => { + (component as any).targetEnvironment = ' '; + expect(component.targetEnvironment).toBe('production'); + }); + it('should clear result on input change', fakeAsync(() => { component['result'].set(mockGateResult); component.ngOnChanges({ diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/promotion-gate.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/promotion-gate.component.ts index 870c11aa5..20b2cbc86 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/promotion-gate.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/promotion-gate.component.ts @@ -14,6 +14,14 @@ import { PromotionGateCheck, GateCheckStatus, } from '../../core/api/policy-simulation.models'; +import { + DEFAULT_POLICY_PACK_ID, + DEFAULT_POLICY_VERSION, + DEFAULT_TARGET_ENVIRONMENT, + normalizePolicyPackId, + normalizePolicyVersion, + normalizeTargetEnvironment, +} from './policy-simulation-defaults'; /** * Promotion gate component for checklist enforcement before production apply. @@ -629,10 +637,36 @@ export class PromotionGateComponent implements OnChanges { private readonly api = inject(POLICY_SIMULATION_API); private readonly fb = inject(FormBuilder); private readonly router = inject(Router); + private _policyPackId = DEFAULT_POLICY_PACK_ID; + private _policyVersion = DEFAULT_POLICY_VERSION; + private _targetEnvironment = DEFAULT_TARGET_ENVIRONMENT; - @Input() policyPackId = 'policy-pack-001'; - @Input() policyVersion = 2; - @Input() targetEnvironment = 'production'; + @Input() + set policyPackId(value: string | null | undefined) { + this._policyPackId = normalizePolicyPackId(value); + } + + get policyPackId(): string { + return this._policyPackId; + } + + @Input() + set policyVersion(value: number | string | null | undefined) { + this._policyVersion = normalizePolicyVersion(value, DEFAULT_POLICY_VERSION); + } + + get policyVersion(): number { + return this._policyVersion; + } + + @Input() + set targetEnvironment(value: string | null | undefined) { + this._targetEnvironment = normalizeTargetEnvironment(value); + } + + get targetEnvironment(): string { + return this._targetEnvironment; + } readonly loading = signal(false); readonly result = signal(undefined); diff --git a/src/Web/StellaOps.Web/tsconfig.spec.features.json b/src/Web/StellaOps.Web/tsconfig.spec.features.json index 281130520..36d26f90b 100644 --- a/src/Web/StellaOps.Web/tsconfig.spec.features.json +++ b/src/Web/StellaOps.Web/tsconfig.spec.features.json @@ -9,6 +9,8 @@ "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", + "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/registry-admin/registry-admin.component.spec.ts", "src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts"