Harden policy simulation direct-route defaults
This commit is contained in:
@@ -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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user