Harden policy simulation direct-route defaults

This commit is contained in:
master
2026-03-10 09:09:29 +02:00
parent db7371de03
commit eae2dfc9d4
15 changed files with 739 additions and 18 deletions

View File

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

View File

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

View File

@@ -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;
});

View File

@@ -80,7 +80,7 @@ describe('CoverageFixtureComponent', () => {
mockApi = jasmine.createSpyObj('PolicySimulationApi', [
'getCoverage',
'runCoverageTests',
]);
]) as jasmine.SpyObj<PolicySimulationApi>;
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();

View File

@@ -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<CoverageResult | undefined>(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;

View File

@@ -57,7 +57,7 @@ describe('PolicyDiffViewerComponent', () => {
};
beforeEach(async () => {
mockApi = jasmine.createSpyObj('PolicySimulationApi', ['getDiff']);
mockApi = jasmine.createSpyObj('PolicySimulationApi', ['getDiff']) as jasmine.SpyObj<PolicySimulationApi>;
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),

View File

@@ -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<PolicyDiffResult | undefined>(undefined);

View File

@@ -76,7 +76,7 @@ describe('PolicyLintComponent', () => {
};
beforeEach(async () => {
mockApi = jasmine.createSpyObj('PolicySimulationApi', ['lintPolicy']);
mockApi = jasmine.createSpyObj('PolicySimulationApi', ['lintPolicy']) as jasmine.SpyObj<PolicySimulationApi>;
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({

View File

@@ -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<PolicyLintResult | undefined>(undefined);

View File

@@ -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');
});
});

View File

@@ -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;
}

View File

@@ -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<PolicySimulationApi> {
return jasmine.createSpyObj('PolicySimulationApi', [
'getCoverage',
'runCoverageTests',
'getDiff',
'lintPolicy',
'checkPromotionGate',
'overridePromotionGate',
]) as jasmine.SpyObj<PolicySimulationApi>;
}
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);
});
});

View File

@@ -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<PolicySimulationApi>;
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({

View File

@@ -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<PromotionGateResult | undefined>(undefined);

View File

@@ -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"