Align release publisher scopes and preserve promotion submit context

This commit is contained in:
master
2026-03-10 19:01:16 +02:00
parent f401a7182c
commit d93006a8fa
11 changed files with 365 additions and 9 deletions

View File

@@ -406,7 +406,7 @@ services:
Platform__EnvironmentSettings__TokenEndpoint: "https://stella-ops.local/connect/token"
Platform__EnvironmentSettings__RedirectUri: "https://stella-ops.local/auth/callback"
Platform__EnvironmentSettings__PostLogoutRedirectUri: "https://stella-ops.local/"
Platform__EnvironmentSettings__Scope: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin"
Platform__EnvironmentSettings__Scope: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin"
STELLAOPS_ROUTER_URL: "http://router.stella-ops.local"
STELLAOPS_PLATFORM_URL: "http://platform.stella-ops.local"
STELLAOPS_AUTHORITY_URL: "http://authority.stella-ops.local"
@@ -509,7 +509,7 @@ services:
STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__ClientId: "stella-ops-ui"
STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__DisplayName: "Stella Ops Console"
STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowedGrantTypes: "authorization_code refresh_token"
STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowedScopes: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin"
STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowedScopes: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin"
STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__RedirectUris: "https://stella-ops.local/auth/callback https://stella-ops.local/auth/silent-refresh https://127.1.0.1/auth/callback https://127.1.0.1/auth/silent-refresh"
STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__PostLogoutRedirectUris: "https://stella-ops.local/ https://127.1.0.1/"
STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__RequirePkce: "true"

View File

@@ -6,7 +6,7 @@
"tokenEndpoint": "https://stella-ops.local/connect/token",
"redirectUri": "https://stella-ops.local/auth/callback",
"postLogoutRedirectUri": "https://stella-ops.local/",
"scope": "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write",
"scope": "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write",
"audience": "stella-ops-api",
"dpopAlgorithms": [
"ES256"

View File

@@ -647,7 +647,7 @@ VALUES
'orch:read', 'analytics.read', 'advisory:read', 'advisory-ai:view', 'advisory-ai:operate',
'vex:read', 'vexhub:read',
'exceptions:read', 'exceptions:approve', 'aoc:verify', 'findings:read',
'release:read', 'scheduler:read', 'scheduler:operate',
'release:read', 'release:write', 'release:publish', 'scheduler:read', 'scheduler:operate',
'notify.viewer', 'notify.operator', 'notify.admin', 'notify.escalate',
'evidence:read',
'export.viewer', 'export.operator', 'export.admin',

View File

@@ -0,0 +1,48 @@
# Sprint 20260310_032 - Release Scope Alignment For Promotions
## Topic & Scope
- Repair the live promotion submit path by aligning the scratch-setup UI client scope contract with the release backend authorization model.
- Ensure wiped local installs converge on the same scope set through compose bootstrap configuration and Authority demo seed data.
- Working directory: `src/Authority/`.
- Cross-module edits explicitly allowed: `src/Web/StellaOps.Web/src/config`, `src/Web/StellaOps.Web/scripts`, and `devops/compose`.
- Expected evidence: focused Authority bootstrap coverage, rebuilt/redeployed local stack, live Playwright promotion submit sweep, refreshed authenticated route coverage.
## Dependencies & Concurrency
- Depends on the local Docker stack being available for rebuild/redeploy on `https://stella-ops.local`.
- Safe parallelism: limited to Authority bootstrap scope provisioning, local setup config, and release-promotion Playwright harnesses.
## Documentation Prerequisites
- `docs/qa/feature-checks/FLOW.md`
- `docs/modules/ui/v2-rewire/authority-matrix.md`
- `docs/technical/architecture/console-admin-rbac.md`
## Delivery Tracker
### TASK-01 - Align release publisher scopes across scratch setup sources
Status: DONE
Dependency: none
Owners: QA, 3rd line support, Product Manager, Architect, Developer
Task description:
- Live Playwright proved that `/releases/promotions/create` can preview and enumerate targets but fails the final submit with `403` from `POST /api/v1/release-orchestrator/releases/:id/promote`.
- Root cause must be fixed at the product contract layer: the release backend correctly requires `release:publish`, while the local UI client and scratch Authority bootstrap sources still only provision `release:read`.
Completion criteria:
- [x] The shipped web config and local environment override request `release:read`, `release:write`, and `release:publish`.
- [x] The compose bootstrap client and first-run Authority seed data provision the same release scopes on wiped installs.
- [x] Focused regression coverage proves bootstrap client provisioning retains the release publisher scopes.
- [x] A live Playwright promotion-submit sweep passes without `403` and lands on the canonical promotion detail route.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-10 | Sprint created after live Playwright confirmed promotion preview works but submit fails with `403` because the release backend requires `release:publish` while the local UI client only requests/allows `release:read`. | QA |
| 2026-03-10 | Patched the release publisher scope set across the shipped web config, compose bootstrap client, runtime env override, and Authority scratch/demo seeds. Added focused Authority bootstrap coverage and a dedicated live Playwright promotion submit harness. | QA / Developer |
| 2026-03-10 | Rebuilt all 59 Docker images from the local matrix, tore the stack down with volumes, redeployed from scratch, resynced the rebuilt web dist, and reauthenticated against the fresh install. Live Playwright now confirms promotion submit returns `200`, lands on `/releases/promotions/:id`, preserves tenant/region/environment/time-window scope, and the canonical route sweep passes `111/111` on the rebuilt stack. | QA / Developer |
## Decisions & Risks
- Decision: keep the backend `release:publish` gate intact and repair the client/bootstrap scope contract instead of weakening release approval authorization.
- Decision: patch both compose runtime bootstrap and persisted Authority demo seed data so scratch rebuilds and fresh database installs converge on the same allowed scope set.
- Decision: preserve current scope query parameters on successful promotion submit so the user remains in the same tenant/region/environment context after the wizard transitions to the promotion detail route.
## Next Checkpoints
- Continue the next deep action sweep from the rebuilt local stack.

View File

@@ -39,7 +39,8 @@ public class StandardPluginBootstrapperTests
ClientId = "stella-ops-ui",
DisplayName = "Stella Ops Console",
AllowedGrantTypes = "authorization_code refresh_token",
AllowedScopes = $"openid profile {StellaOpsScopes.UiRead} {StellaOpsScopes.RegistryAdmin}",
AllowedScopes =
$"openid profile {StellaOpsScopes.UiRead} {StellaOpsScopes.RegistryAdmin} {StellaOpsScopes.ReleaseRead} {StellaOpsScopes.ReleaseWrite} {StellaOpsScopes.ReleasePublish}",
RedirectUris = "https://stella-ops.local/auth/callback https://stella-ops.local/auth/silent-refresh",
PostLogoutRedirectUris = "https://stella-ops.local/",
RequirePkce = true
@@ -69,6 +70,9 @@ public class StandardPluginBootstrapperTests
var client = await clientStore.FindByClientIdAsync("stella-ops-ui", TestContext.Current.CancellationToken);
Assert.NotNull(client);
Assert.Contains(StellaOpsScopes.RegistryAdmin, client!.AllowedScopes);
Assert.Contains(StellaOpsScopes.ReleaseRead, client.AllowedScopes);
Assert.Contains(StellaOpsScopes.ReleaseWrite, client.AllowedScopes);
Assert.Contains(StellaOpsScopes.ReleasePublish, client.AllowedScopes);
Assert.Contains("authorization_code", client.AllowedGrantTypes);
Assert.True(client.RequirePkce);
Assert.Equal("demo-prod", client.Properties[AuthorityClientMetadataKeys.Tenant]);

View File

@@ -91,7 +91,7 @@ VALUES
'airgap:seal', 'airgap:status:read',
'orch:read', 'analytics.read', 'advisory:read', 'vex:read', 'vexhub:read',
'exceptions:read', 'exceptions:approve', 'aoc:verify', 'findings:read',
'release:read', 'scheduler:read', 'scheduler:operate',
'release:read', 'release:write', 'release:publish', 'scheduler:read', 'scheduler:operate',
'notify.viewer', 'notify.operator', 'notify.admin', 'notify.escalate',
'evidence:read',
'export.viewer', 'export.operator', 'export.admin',

View File

@@ -0,0 +1,227 @@
#!/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 statePath = path.join(outputDirectory, 'live-frontdoor-auth-state.json');
const reportPath = path.join(outputDirectory, 'live-frontdoor-auth-report.json');
const resultPath = path.join(outputDirectory, 'live-release-promotion-submit-check.json');
const scopeQuery = 'tenant=demo-prod&regions=us-east&environments=stage&timeWindow=7d';
const createRoute = `/releases/promotions/create?${scopeQuery}`;
const promotionDetailPattern = /^\/releases\/promotions\/(?!create$)[^/]+$/i;
const expectedScopeEntries = [
['tenant', 'demo-prod'],
['regions', 'us-east'],
['environments', 'stage'],
['timeWindow', '7d'],
];
function isStaticAsset(url) {
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?|ico)(?:$|\?)/i.test(url);
}
function isAbortedNavigationFailure(failure) {
if (!failure) {
return false;
}
return /aborted|net::err_abort/i.test(failure);
}
function collectScopeIssues(label, targetUrl) {
const issues = [];
const parsed = new URL(targetUrl);
for (const [key, expectedValue] of expectedScopeEntries) {
const actualValue = parsed.searchParams.get(key);
if (actualValue !== expectedValue) {
issues.push(`${label} missing preserved scope ${key}=${expectedValue}; actual=${actualValue ?? '<null>'}`);
}
}
return issues;
}
async function clickNext(page) {
await page.getByRole('button', { name: 'Next ->' }).click();
}
async function main() {
mkdirSync(outputDirectory, { recursive: true });
const authReport = await authenticateFrontdoor({
statePath,
reportPath,
headless: true,
});
const browser = await chromium.launch({
headless: true,
args: ['--disable-dev-shm-usage'],
});
const context = await createAuthenticatedContext(browser, authReport, { statePath });
const page = await context.newPage();
const runtimeIssues = [];
const responseErrors = [];
const requestFailures = [];
const consoleErrors = [];
let promoteResponse = null;
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
page.on('requestfailed', (request) => {
const url = request.url();
if (isStaticAsset(url) || isAbortedNavigationFailure(request.failure()?.errorText)) {
return;
}
requestFailures.push({
method: request.method(),
url,
error: request.failure()?.errorText ?? 'unknown',
page: page.url(),
});
});
page.on('response', async (response) => {
const url = response.url();
if (isStaticAsset(url)) {
return;
}
if (url.includes('/api/v1/release-orchestrator/releases/') && url.endsWith('/promote')) {
try {
promoteResponse = {
status: response.status(),
url,
body: await response.json(),
};
} catch {
promoteResponse = {
status: response.status(),
url,
body: null,
};
}
}
if (response.status() >= 400) {
responseErrors.push({
status: response.status(),
method: response.request().method(),
url,
page: page.url(),
});
}
});
const result = {
checkedAtUtc: new Date().toISOString(),
route: createRoute,
finalUrl: null,
promoteResponse,
scopeIssues: [],
consoleErrors,
requestFailures,
responseErrors,
runtimeIssues,
runtimeIssueCount: 0,
};
try {
await page.goto(`https://stella-ops.local${createRoute}`, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await page.getByLabel('Release/Bundle identity').fill('rel-001');
await page.getByRole('button', { name: 'Load Target Environments' }).click();
await page.locator('#target-env').selectOption('env-staging');
await page.getByRole('button', { name: 'Refresh Gate Preview' }).click();
await clickNext(page);
await page.getByLabel('Justification').fill('Release approval path validated end to end.');
await clickNext(page);
const submitResponsePromise = page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
response.url().includes('/api/v1/release-orchestrator/releases/') &&
response.url().endsWith('/promote'),
{ timeout: 30_000 },
);
await page.getByRole('button', { name: 'Submit Promotion Request' }).click({
noWaitAfter: true,
timeout: 10_000,
});
const submitResponse = await submitResponsePromise;
result.promoteResponse = promoteResponse ?? {
status: submitResponse.status(),
url: submitResponse.url(),
body: null,
};
await page.waitForURL((url) => promotionDetailPattern.test(url.pathname), {
timeout: 30_000,
});
result.finalUrl = page.url();
result.scopeIssues.push(...collectScopeIssues('finalUrl', result.finalUrl));
const finalPathname = new URL(result.finalUrl).pathname;
if (!promotionDetailPattern.test(finalPathname)) {
runtimeIssues.push(`Promotion submit did not land on a canonical detail route; actual path=${finalPathname}`);
}
if ((result.promoteResponse?.status ?? 0) >= 400) {
runtimeIssues.push(`Promotion submit returned ${result.promoteResponse.status}`);
}
const errorBannerVisible = await page.getByText('Failed to submit promotion request.').isVisible().catch(() => false);
if (errorBannerVisible) {
runtimeIssues.push('Promotion submit surfaced an error banner after submit.');
}
} catch (error) {
runtimeIssues.push(error instanceof Error ? error.message : String(error));
result.finalUrl = page.url();
} finally {
result.runtimeIssues = [
...runtimeIssues,
...result.scopeIssues,
...responseErrors.map((entry) => `${entry.method} ${entry.url} -> ${entry.status}`),
...requestFailures.map((entry) => `${entry.method} ${entry.url} failed: ${entry.error}`),
...consoleErrors,
];
result.runtimeIssueCount = result.runtimeIssues.length;
writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
await context.close();
await browser.close();
}
if (result.runtimeIssueCount > 0) {
throw new Error(result.runtimeIssues.join('; '));
}
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}
main().catch((error) => {
process.stderr.write(`[live-release-promotion-submit-check] ${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});

View File

@@ -786,7 +786,10 @@ export class CreatePromotionComponent implements OnInit {
.subscribe((created) => {
this.submitting.set(false);
if (created) {
this.router.navigate(['../', created.id], { relativeTo: this.route });
this.router.navigate(['../', created.id], {
relativeTo: this.route,
queryParamsHandling: 'preserve',
});
}
});
}

View File

@@ -8,7 +8,7 @@
"redirectUri": "/auth/callback",
"silentRefreshRedirectUri": "/auth/silent-refresh",
"postLogoutRedirectUri": "/",
"scope": "openid profile email offline_access ui.read ui.admin authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit registry.admin",
"scope": "openid profile email offline_access ui.read ui.admin authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit registry.admin",
"audience": "/scanner",
"dpopAlgorithms": ["ES256"],
"refreshLeewaySeconds": 60

View File

@@ -7,7 +7,7 @@
"logoutEndpoint": "https://authority.example.dev/connect/logout",
"redirectUri": "http://localhost:4400/auth/callback",
"postLogoutRedirectUri": "http://localhost:4400/",
"scope": "openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit registry.admin",
"scope": "openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read release:read release:write release:publish vuln:view vuln:investigate vuln:operate vuln:audit registry.admin",
"audience": "https://scanner.example.dev",
"dpopAlgorithms": ["ES256"],
"refreshLeewaySeconds": 60

View File

@@ -193,4 +193,78 @@ describe('CreatePromotionComponent release-context handoff', () => {
},
);
});
it('preserves current scope when promotion submit navigates to the canonical detail route', async () => {
const approvalApi = {
getAvailableEnvironments: jasmine.createSpy('getAvailableEnvironments').and.returnValue(of([])),
getPromotionPreview: jasmine.createSpy('getPromotionPreview').and.returnValue(of(null)),
submitPromotionRequest: jasmine.createSpy('submitPromotionRequest').and.returnValue(
of({
id: 'apr-321',
releaseId: 'rel-123',
releaseName: 'API Gateway',
releaseVersion: '2.1.0',
sourceEnvironment: 'stage',
targetEnvironment: 'production',
requestedBy: 'ops',
requestedAt: '2026-03-08T09:30:00Z',
urgency: 'normal',
justification: 'Promote',
status: 'pending',
currentApprovals: 0,
requiredApprovals: 2,
gatesPassed: true,
scheduledTime: null,
expiresAt: '2026-03-10T09:30:00Z',
}),
),
};
await TestBed.configureTestingModule({
imports: [CreatePromotionComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({
tenant: 'demo-prod',
regions: 'us-east',
environments: 'stage',
timeWindow: '7d',
}),
},
},
},
{ provide: APPROVAL_API, useValue: approvalApi },
],
}).compileComponents();
const fixture = TestBed.createComponent(CreatePromotionComponent);
const component = fixture.componentInstance;
const router = TestBed.inject(Router);
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
component.releaseId.set('rel-123');
component.targetEnvironmentId.set('env-production');
component.justification.set('Release approval path validated end to end.');
component.submit();
expect(approvalApi.submitPromotionRequest).toHaveBeenCalledWith('rel-123', {
targetEnvironmentId: 'env-production',
urgency: 'normal',
justification: 'Release approval path validated end to end.',
notifyApprovers: true,
scheduledTime: null,
});
expect(navigateSpy).toHaveBeenCalledWith(
['../', 'apr-321'],
{
relativeTo: TestBed.inject(ActivatedRoute),
queryParamsHandling: 'preserve',
},
);
});
});