feat(ui): ship topology and trust admin cutover
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, Routes } from '@angular/router';
|
||||
|
||||
import {
|
||||
requireAnyScopeGuard,
|
||||
@@ -78,6 +79,30 @@ const requireSetupGuard = requireAnyScopeGuard(
|
||||
'/console/profile',
|
||||
);
|
||||
|
||||
function preserveAppRedirect(template: string) {
|
||||
return ({
|
||||
params,
|
||||
queryParams,
|
||||
fragment,
|
||||
}: {
|
||||
params: Record<string, string>;
|
||||
queryParams: Record<string, string>;
|
||||
fragment?: string | null;
|
||||
}) => {
|
||||
const router = inject(Router);
|
||||
let targetPath = template;
|
||||
|
||||
for (const [name, value] of Object.entries(params ?? {})) {
|
||||
targetPath = targetPath.replaceAll(`:${name}`, encodeURIComponent(value));
|
||||
}
|
||||
|
||||
const target = router.parseUrl(targetPath);
|
||||
target.queryParams = { ...queryParams };
|
||||
target.fragment = fragment ?? null;
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
@@ -170,6 +195,29 @@ export const routes: Routes = [
|
||||
data: { breadcrumb: 'Console Admin' },
|
||||
loadChildren: () => import('./features/console-admin/console-admin.routes').then((m) => m.consoleAdminRoutes),
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
children: [
|
||||
{ path: '', redirectTo: '/administration', pathMatch: 'full' },
|
||||
{ path: 'notifications', redirectTo: preserveAppRedirect('/setup/notifications'), pathMatch: 'full' },
|
||||
{ path: 'notifications/:page', redirectTo: preserveAppRedirect('/setup/notifications/:page'), pathMatch: 'full' },
|
||||
{ path: 'trust', redirectTo: preserveAppRedirect('/setup/trust-signing'), pathMatch: 'full' },
|
||||
{ path: 'trust/:page', redirectTo: preserveAppRedirect('/setup/trust-signing/:page'), pathMatch: 'full' },
|
||||
{
|
||||
path: 'trust/:page/:child',
|
||||
redirectTo: preserveAppRedirect('/setup/trust-signing/:page/:child'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{ path: 'issuers', redirectTo: preserveAppRedirect('/setup/trust-signing/issuers'), pathMatch: 'full' },
|
||||
{
|
||||
path: 'issuers/:page',
|
||||
redirectTo: preserveAppRedirect('/setup/trust-signing/issuers'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{ path: 'registries', redirectTo: preserveAppRedirect('/setup/integrations'), pathMatch: 'full' },
|
||||
{ path: '**', redirectTo: '/administration' },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'platform-ops',
|
||||
loadChildren: () => import('./routes/platform-ops.routes').then((m) => m.PLATFORM_OPS_ROUTES),
|
||||
@@ -192,8 +240,43 @@ export const routes: Routes = [
|
||||
path: 'ops',
|
||||
loadChildren: () => import('./routes/platform-ops.routes').then((m) => m.PLATFORM_OPS_ROUTES),
|
||||
},
|
||||
{ path: 'setup', redirectTo: '/setup', pathMatch: 'full' },
|
||||
{ path: 'setup/:rest', redirectTo: '/setup/:rest' },
|
||||
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
||||
{
|
||||
path: 'setup/regions-environments',
|
||||
redirectTo: preserveAppRedirect('/setup/topology/regions'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'setup/promotion-paths',
|
||||
redirectTo: preserveAppRedirect('/setup/topology/promotion-graph'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'setup/workflows-gates',
|
||||
redirectTo: preserveAppRedirect('/setup/topology/workflows'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'setup/gate-profiles',
|
||||
redirectTo: preserveAppRedirect('/setup/topology/gate-profiles'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'setup/trust-signing',
|
||||
redirectTo: preserveAppRedirect('/setup/trust-signing'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'setup/trust-signing/:page',
|
||||
redirectTo: preserveAppRedirect('/setup/trust-signing/:page'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'setup/trust-signing/:page/:child',
|
||||
redirectTo: preserveAppRedirect('/setup/trust-signing/:page/:child'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{ path: 'setup/:rest', redirectTo: preserveAppRedirect('/ops/platform-setup/:rest'), pathMatch: 'full' },
|
||||
{ path: '**', redirectTo: '/ops' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -588,14 +588,14 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
{
|
||||
id: 'admin-notifications',
|
||||
label: 'Notification Admin',
|
||||
route: '/admin/notifications',
|
||||
route: '/setup/notifications',
|
||||
icon: 'bell-config',
|
||||
tooltip: 'Configure notification rules, channels, and templates',
|
||||
},
|
||||
{
|
||||
id: 'admin-trust',
|
||||
label: 'Trust Management',
|
||||
route: '/admin/trust',
|
||||
route: '/setup/trust-signing',
|
||||
icon: 'certificate',
|
||||
tooltip: 'Manage signing keys, issuers, and certificates',
|
||||
},
|
||||
@@ -623,7 +623,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
{
|
||||
id: 'issuer-trust',
|
||||
label: 'Issuer Directory',
|
||||
route: '/admin/issuers',
|
||||
route: '/setup/trust-signing/issuers',
|
||||
icon: 'shield-check',
|
||||
tooltip: 'Manage issuer trust and key lifecycle',
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@ interface TopoLink extends d3.SimulationLinkDatum<TopoNode> {
|
||||
<section class="setup-home">
|
||||
<header class="setup-home__header">
|
||||
<h1 class="setup-home__title">Platform Setup</h1>
|
||||
<p class="setup-home__subtitle">Configure inventory, promotion, workflow, policy. Explore the topology graph below.</p>
|
||||
<p class="setup-home__subtitle">Configure canonical setup inventory, promotion, workflow, policy, and trust handoffs. Explore the topology graph below.</p>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
@@ -370,13 +370,14 @@ export class PlatformSetupHomeComponent implements AfterViewInit, OnDestroy {
|
||||
private allLinks: TopoLink[] = [];
|
||||
|
||||
readonly quickLinks = [
|
||||
{ title: 'Regions & Environments', description: 'Region-first setup and risk tiers.', route: '/platform/setup/regions-environments' },
|
||||
{ title: 'Promotion Paths', description: 'Promotion flow graph and rules.', route: '/platform/setup/promotion-paths' },
|
||||
{ title: 'Workflows & Gates', description: 'Workflow and gate profile mapping.', route: '/platform/setup/workflows-gates' },
|
||||
{ title: 'Gate Profiles', description: 'Strict, risk-aware, and expedited lanes.', route: '/platform/setup/gate-profiles' },
|
||||
{ title: 'Release Templates', description: 'Release template and evidence defaults.', route: '/platform/setup/release-templates' },
|
||||
{ title: 'Feed Policy', description: 'Freshness thresholds and staleness.', route: '/platform/setup/feed-policy' },
|
||||
{ title: 'Defaults & Guardrails', description: 'Policy impact labels and degraded-mode.', route: '/platform/setup/defaults-guardrails' },
|
||||
{ title: 'Regions & Environments', description: 'Region-first setup and risk tiers.', route: '/setup/topology/regions' },
|
||||
{ title: 'Promotion Paths', description: 'Promotion flow graph and rules.', route: '/setup/topology/promotion-graph' },
|
||||
{ title: 'Workflows & Gates', description: 'Workflow inventory and gate bindings.', route: '/setup/topology/workflows' },
|
||||
{ title: 'Gate Profiles', description: 'Strict, risk-aware, and expedited lanes.', route: '/setup/topology/gate-profiles' },
|
||||
{ title: 'Release Templates', description: 'Release template and evidence defaults.', route: '/ops/platform-setup/release-templates' },
|
||||
{ title: 'Policy Bindings', description: 'Freshness thresholds and feed-policy bindings.', route: '/ops/platform-setup/policy-bindings' },
|
||||
{ title: 'Defaults & Guardrails', description: 'Policy impact labels and degraded-mode.', route: '/ops/platform-setup/defaults-guardrails' },
|
||||
{ title: 'Trust & Signing', description: 'Keys, issuers, watchlist, and trust audit workflows.', route: '/setup/trust-signing' },
|
||||
];
|
||||
|
||||
private readonly nodeColors: Record<TopoNodeKind, string> = {
|
||||
|
||||
@@ -12,28 +12,22 @@ export const PLATFORM_SETUP_ROUTES: Routes = [
|
||||
path: 'regions-environments',
|
||||
title: 'Setup Regions & Environments',
|
||||
data: { breadcrumb: 'Regions & Environments' },
|
||||
loadComponent: () =>
|
||||
import('./platform-setup-regions-environments-page.component').then(
|
||||
(m) => m.PlatformSetupRegionsEnvironmentsPageComponent,
|
||||
),
|
||||
redirectTo: '/setup/topology/regions',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'promotion-paths',
|
||||
title: 'Setup Promotion Paths',
|
||||
data: { breadcrumb: 'Promotion Paths' },
|
||||
loadComponent: () =>
|
||||
import('./platform-setup-promotion-paths-page.component').then(
|
||||
(m) => m.PlatformSetupPromotionPathsPageComponent,
|
||||
),
|
||||
redirectTo: '/setup/topology/promotion-graph',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'workflows-gates',
|
||||
title: 'Setup Workflows & Gates',
|
||||
data: { breadcrumb: 'Workflows & Gates' },
|
||||
loadComponent: () =>
|
||||
import('./platform-setup-workflows-gates-page.component').then(
|
||||
(m) => m.PlatformSetupWorkflowsGatesPageComponent,
|
||||
),
|
||||
redirectTo: '/setup/topology/workflows',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'release-templates',
|
||||
@@ -57,10 +51,8 @@ export const PLATFORM_SETUP_ROUTES: Routes = [
|
||||
path: 'gate-profiles',
|
||||
title: 'Gate Profiles',
|
||||
data: { breadcrumb: 'Gate Profiles' },
|
||||
loadComponent: () =>
|
||||
import('./platform-setup-gate-profiles-page.component').then(
|
||||
(m) => m.PlatformSetupGateProfilesPageComponent,
|
||||
),
|
||||
redirectTo: '/setup/topology/gate-profiles',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'defaults-guardrails',
|
||||
@@ -75,9 +67,7 @@ export const PLATFORM_SETUP_ROUTES: Routes = [
|
||||
path: 'trust-signing',
|
||||
title: 'Trust & Signing',
|
||||
data: { breadcrumb: 'Trust & Signing' },
|
||||
loadComponent: () =>
|
||||
import('../../settings/trust/trust-settings-page.component').then(
|
||||
(m) => m.TrustSettingsPageComponent,
|
||||
),
|
||||
redirectTo: '/setup/trust-signing',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,7 +3,32 @@
|
||||
* Sprint: SPRINT_20260118_002_FE_settings_consolidation
|
||||
*/
|
||||
|
||||
import { Routes } from '@angular/router';
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, Routes } from '@angular/router';
|
||||
|
||||
function redirectToCanonicalSetup(path: string) {
|
||||
return ({
|
||||
params,
|
||||
queryParams,
|
||||
fragment,
|
||||
}: {
|
||||
params: Record<string, string>;
|
||||
queryParams: Record<string, string>;
|
||||
fragment?: string | null;
|
||||
}) => {
|
||||
const router = inject(Router);
|
||||
let targetPath = path;
|
||||
|
||||
for (const [name, value] of Object.entries(params ?? {})) {
|
||||
targetPath = targetPath.replaceAll(`:${name}`, encodeURIComponent(value));
|
||||
}
|
||||
|
||||
const target = router.parseUrl(targetPath);
|
||||
target.queryParams = { ...queryParams };
|
||||
target.fragment = fragment ?? null;
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
export const SETTINGS_ROUTES: Routes = [
|
||||
{
|
||||
@@ -51,16 +76,45 @@ export const SETTINGS_ROUTES: Routes = [
|
||||
{
|
||||
path: 'trust',
|
||||
title: 'Trust & Signing',
|
||||
loadComponent: () =>
|
||||
import('./trust/trust-settings-page.component').then(m => m.TrustSettingsPageComponent),
|
||||
data: { breadcrumb: 'Trust & Signing' },
|
||||
redirectTo: redirectToCanonicalSetup('/setup/trust-signing'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust/issuers',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/issuers'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust/:page/:child',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/:page/:child'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust/:page',
|
||||
title: 'Trust & Signing',
|
||||
loadComponent: () =>
|
||||
import('./trust/trust-settings-page.component').then(m => m.TrustSettingsPageComponent),
|
||||
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/:page'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust-signing',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonicalSetup('/setup/trust-signing'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust-signing/:page',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/:page'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust-signing/:page/:child',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/:page/:child'),
|
||||
data: { breadcrumb: 'Trust & Signing' },
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'security-data',
|
||||
|
||||
@@ -52,9 +52,13 @@ export class TopologyShellComponent {
|
||||
readonly tabs: TabItem[] = [
|
||||
{ id: 'overview', label: 'Overview', route: 'overview' },
|
||||
{ id: 'map', label: 'Map', route: 'map' },
|
||||
{ id: 'regions', label: 'Regions & Environments', route: 'regions' },
|
||||
{ id: 'targets', label: 'Targets', route: 'targets' },
|
||||
{ id: 'hosts', label: 'Hosts', route: 'hosts' },
|
||||
{ id: 'agents', label: 'Agents', route: 'agents' },
|
||||
{ id: 'promotion', label: 'Promotion Graph', route: 'promotion-graph' },
|
||||
{ id: 'workflows', label: 'Workflows', route: 'workflows' },
|
||||
{ id: 'gate-profiles', label: 'Gate Profiles', route: 'gate-profiles' },
|
||||
{ id: 'connectivity', label: 'Connectivity', route: 'connectivity' },
|
||||
{ id: 'drift', label: 'Runtime Drift', route: 'runtime-drift' },
|
||||
];
|
||||
|
||||
@@ -68,6 +68,30 @@ function redirectToEvidence(path: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function redirectToSetup(path: string) {
|
||||
return ({
|
||||
params,
|
||||
queryParams,
|
||||
fragment,
|
||||
}: {
|
||||
params: Record<string, string>;
|
||||
queryParams: Record<string, string>;
|
||||
fragment?: string | null;
|
||||
}) => {
|
||||
const router = inject(Router);
|
||||
let targetPath = path;
|
||||
|
||||
for (const [name, value] of Object.entries(params ?? {})) {
|
||||
targetPath = targetPath.replaceAll(`:${name}`, encodeURIComponent(value));
|
||||
}
|
||||
|
||||
const target = router.parseUrl(targetPath);
|
||||
target.queryParams = { ...queryParams };
|
||||
target.fragment = fragment ?? null;
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
export const ADMINISTRATION_ROUTES: Routes = [
|
||||
// A0 — Administration overview
|
||||
{
|
||||
@@ -294,38 +318,37 @@ export const ADMINISTRATION_ROUTES: Routes = [
|
||||
path: 'trust-signing',
|
||||
title: 'Trust & Signing',
|
||||
data: { breadcrumb: 'Trust & Signing' },
|
||||
loadChildren: () =>
|
||||
import('../features/trust-admin/trust-admin.routes').then(
|
||||
(m) => m.trustAdminRoutes
|
||||
),
|
||||
redirectTo: redirectToSetup('/setup/trust-signing'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
// Legacy trust sub-paths (formerly /admin/trust/*)
|
||||
{
|
||||
path: 'trust',
|
||||
title: 'Trust & Signing',
|
||||
data: { breadcrumb: 'Trust & Signing' },
|
||||
loadComponent: () =>
|
||||
import('../features/settings/trust/trust-settings-page.component').then(
|
||||
(m) => m.TrustSettingsPageComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'trust/:page',
|
||||
title: 'Trust & Signing',
|
||||
data: { breadcrumb: 'Trust & Signing' },
|
||||
loadComponent: () =>
|
||||
import('../features/settings/trust/trust-settings-page.component').then(
|
||||
(m) => m.TrustSettingsPageComponent
|
||||
),
|
||||
redirectTo: redirectToSetup('/setup/trust-signing'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'trust/issuers',
|
||||
title: 'Issuers',
|
||||
data: { breadcrumb: 'Issuers' },
|
||||
loadChildren: () =>
|
||||
import('../features/issuer-trust/issuer-trust.routes').then(
|
||||
(m) => m.issuerTrustRoutes
|
||||
),
|
||||
redirectTo: redirectToSetup('/setup/trust-signing/issuers'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'trust/:page/:child',
|
||||
title: 'Trust & Signing',
|
||||
data: { breadcrumb: 'Trust & Signing' },
|
||||
redirectTo: redirectToSetup('/setup/trust-signing/:page/:child'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'trust/:page',
|
||||
title: 'Trust & Signing',
|
||||
data: { breadcrumb: 'Trust & Signing' },
|
||||
redirectTo: redirectToSetup('/setup/trust-signing/:page'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
|
||||
// Legacy alias: /administration/identity-providers → /settings/identity-providers
|
||||
|
||||
@@ -47,6 +47,11 @@ export const TOPOLOGY_ROUTES: Routes = [
|
||||
(m) => m.TopologyRegionsEnvironmentsPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'regions-environments',
|
||||
redirectTo: 'regions',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'environments',
|
||||
title: 'Environments',
|
||||
@@ -180,6 +185,11 @@ export const TOPOLOGY_ROUTES: Routes = [
|
||||
(m) => m.TopologyPromotionPathsPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'promotion-paths',
|
||||
redirectTo: 'promotion-graph',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'workflows',
|
||||
title: 'Workflows',
|
||||
@@ -194,6 +204,11 @@ export const TOPOLOGY_ROUTES: Routes = [
|
||||
(m) => m.TopologyInventoryPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'workflows-gates',
|
||||
redirectTo: 'workflows',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'gate-profiles',
|
||||
title: 'Gate Profiles',
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
import { PLATFORM_SETUP_ROUTES } from '../../app/features/platform/setup/platform-setup.routes';
|
||||
|
||||
describe('PLATFORM_SETUP_ROUTES (pre-alpha)', () => {
|
||||
it('uses policy-bindings as canonical policy setup page', () => {
|
||||
it('keeps policy-bindings as canonical policy setup page', () => {
|
||||
const route = PLATFORM_SETUP_ROUTES.find((item) => item.path === 'policy-bindings');
|
||||
expect(route).toBeDefined();
|
||||
expect(route?.loadComponent).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes gate profiles and defaults guardrails pages', () => {
|
||||
const gateProfiles = PLATFORM_SETUP_ROUTES.find((item) => item.path === 'gate-profiles');
|
||||
it('keeps release templates and defaults guardrails as mounted pages', () => {
|
||||
const releaseTemplates = PLATFORM_SETUP_ROUTES.find((item) => item.path === 'release-templates');
|
||||
const defaults = PLATFORM_SETUP_ROUTES.find((item) => item.path === 'defaults-guardrails');
|
||||
|
||||
expect(gateProfiles?.loadComponent).toBeDefined();
|
||||
expect(releaseTemplates?.loadComponent).toBeDefined();
|
||||
expect(defaults?.loadComponent).toBeDefined();
|
||||
});
|
||||
|
||||
it('contains no redirect aliases', () => {
|
||||
for (const route of PLATFORM_SETUP_ROUTES) {
|
||||
expect(route.redirectTo).toBeUndefined();
|
||||
it('redirects absorbed topology and trust pages into canonical setup owners', () => {
|
||||
const regions = PLATFORM_SETUP_ROUTES.find((item) => item.path === 'regions-environments');
|
||||
const promotion = PLATFORM_SETUP_ROUTES.find((item) => item.path === 'promotion-paths');
|
||||
const workflows = PLATFORM_SETUP_ROUTES.find((item) => item.path === 'workflows-gates');
|
||||
const gateProfiles = PLATFORM_SETUP_ROUTES.find((item) => item.path === 'gate-profiles');
|
||||
const trustSigning = PLATFORM_SETUP_ROUTES.find((item) => item.path === 'trust-signing');
|
||||
|
||||
expect(regions?.redirectTo).toBe('/setup/topology/regions');
|
||||
expect(promotion?.redirectTo).toBe('/setup/topology/promotion-graph');
|
||||
expect(workflows?.redirectTo).toBe('/setup/topology/workflows');
|
||||
expect(gateProfiles?.redirectTo).toBe('/setup/topology/gate-profiles');
|
||||
expect(trustSigning?.redirectTo).toBe('/setup/trust-signing');
|
||||
});
|
||||
|
||||
it('retains mounted routes for the remaining platform setup surfaces', () => {
|
||||
const mountedPaths = ['policy-bindings', 'release-templates', 'defaults-guardrails'];
|
||||
|
||||
for (const path of mountedPaths) {
|
||||
const route = PLATFORM_SETUP_ROUTES.find((item) => item.path === path);
|
||||
expect(route?.loadComponent).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Route, Router } from '@angular/router';
|
||||
|
||||
import { NAVIGATION_GROUPS } from '../../app/core/navigation/navigation.config';
|
||||
import { SETTINGS_ROUTES } from '../../app/features/settings/settings.routes';
|
||||
import { routes } from '../../app/app.routes';
|
||||
import { ADMINISTRATION_ROUTES } from '../../app/routes/administration.routes';
|
||||
|
||||
function resolveRedirect(route: Route | undefined, params: Record<string, string> = {}): string | undefined {
|
||||
const redirect = route?.redirectTo;
|
||||
if (typeof redirect === 'string') {
|
||||
return redirect;
|
||||
}
|
||||
|
||||
if (typeof redirect !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return TestBed.runInInjectionContext(() => {
|
||||
const router = TestBed.inject(Router);
|
||||
const target = redirect({
|
||||
params,
|
||||
queryParams: {},
|
||||
fragment: null,
|
||||
} as never) as unknown;
|
||||
|
||||
return typeof target === 'string' ? target : router.serializeUrl(target as never);
|
||||
});
|
||||
}
|
||||
|
||||
describe('setup topology trust cutover contract', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter([])],
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects legacy settings trust routes to canonical setup trust-signing pages', () => {
|
||||
const root = SETTINGS_ROUTES[0];
|
||||
const children = root.children ?? [];
|
||||
|
||||
expect(resolveRedirect(children.find((route) => route.path === 'trust'))).toBe('/setup/trust-signing');
|
||||
expect(resolveRedirect(children.find((route) => route.path === 'trust/issuers'))).toBe(
|
||||
'/setup/trust-signing/issuers',
|
||||
);
|
||||
expect(resolveRedirect(children.find((route) => route.path === 'trust/:page'), { page: 'analytics' })).toBe(
|
||||
'/setup/trust-signing/analytics',
|
||||
);
|
||||
expect(
|
||||
resolveRedirect(children.find((route) => route.path === 'trust/:page/:child'), {
|
||||
page: 'watchlist',
|
||||
child: 'alerts',
|
||||
}),
|
||||
).toBe('/setup/trust-signing/watchlist/alerts');
|
||||
expect(resolveRedirect(children.find((route) => route.path === 'trust-signing'))).toBe('/setup/trust-signing');
|
||||
});
|
||||
|
||||
it('redirects administration trust routes into canonical setup trust-signing pages', () => {
|
||||
expect(resolveRedirect(ADMINISTRATION_ROUTES.find((route) => route.path === 'trust-signing'))).toBe(
|
||||
'/setup/trust-signing',
|
||||
);
|
||||
expect(resolveRedirect(ADMINISTRATION_ROUTES.find((route) => route.path === 'trust'))).toBe('/setup/trust-signing');
|
||||
expect(resolveRedirect(ADMINISTRATION_ROUTES.find((route) => route.path === 'trust/issuers'))).toBe(
|
||||
'/setup/trust-signing/issuers',
|
||||
);
|
||||
expect(
|
||||
resolveRedirect(ADMINISTRATION_ROUTES.find((route) => route.path === 'trust/:page'), { page: 'analytics' }),
|
||||
).toBe('/setup/trust-signing/analytics');
|
||||
expect(
|
||||
resolveRedirect(ADMINISTRATION_ROUTES.find((route) => route.path === 'trust/:page/:child'), {
|
||||
page: 'watchlist',
|
||||
child: 'tuning',
|
||||
}),
|
||||
).toBe('/setup/trust-signing/watchlist/tuning');
|
||||
});
|
||||
|
||||
it('provides top-level admin aliases for notification and trust bookmarks', () => {
|
||||
const admin = routes.find((route) => route.path === 'admin');
|
||||
expect(admin).toBeDefined();
|
||||
|
||||
const childPaths = (admin?.children ?? []).map((route) => route.path);
|
||||
expect(childPaths).toEqual([
|
||||
'',
|
||||
'notifications',
|
||||
'notifications/:page',
|
||||
'trust',
|
||||
'trust/:page',
|
||||
'trust/:page/:child',
|
||||
'issuers',
|
||||
'issuers/:page',
|
||||
'registries',
|
||||
'**',
|
||||
]);
|
||||
});
|
||||
|
||||
it('retargets active admin navigation links to mounted setup destinations', () => {
|
||||
const adminGroup = NAVIGATION_GROUPS.find((group) => group.id === 'admin');
|
||||
expect(adminGroup).toBeDefined();
|
||||
|
||||
const itemById = new Map((adminGroup?.items ?? []).map((item) => [item.id, item.route]));
|
||||
|
||||
expect(itemById.get('admin-notifications')).toBe('/setup/notifications');
|
||||
expect(itemById.get('admin-trust')).toBe('/setup/trust-signing');
|
||||
expect(itemById.get('issuer-trust')).toBe('/setup/trust-signing/issuers');
|
||||
});
|
||||
|
||||
it('preserves top-level platform setup aliases for topology and trust destinations', () => {
|
||||
const platform = routes.find((route) => route.path === 'platform');
|
||||
expect(platform).toBeDefined();
|
||||
|
||||
const children = platform?.children ?? [];
|
||||
|
||||
expect(resolveRedirect(children.find((route) => route.path === 'setup/regions-environments'))).toBe(
|
||||
'/setup/topology/regions',
|
||||
);
|
||||
expect(resolveRedirect(children.find((route) => route.path === 'setup/promotion-paths'))).toBe(
|
||||
'/setup/topology/promotion-graph',
|
||||
);
|
||||
expect(resolveRedirect(children.find((route) => route.path === 'setup/workflows-gates'))).toBe(
|
||||
'/setup/topology/workflows',
|
||||
);
|
||||
expect(resolveRedirect(children.find((route) => route.path === 'setup/gate-profiles'))).toBe(
|
||||
'/setup/topology/gate-profiles',
|
||||
);
|
||||
expect(resolveRedirect(children.find((route) => route.path === 'setup/trust-signing'))).toBe('/setup/trust-signing');
|
||||
expect(
|
||||
resolveRedirect(children.find((route) => route.path === 'setup/trust-signing/:page'), { page: 'issuers' }),
|
||||
).toBe(
|
||||
'/setup/trust-signing/issuers',
|
||||
);
|
||||
expect(
|
||||
resolveRedirect(children.find((route) => route.path === 'setup/trust-signing/:page/:child'), {
|
||||
page: 'watchlist',
|
||||
child: 'alerts',
|
||||
}),
|
||||
).toBe(
|
||||
'/setup/trust-signing/watchlist/alerts',
|
||||
);
|
||||
expect(resolveRedirect(children.find((route) => route.path === 'setup/:rest'), { rest: 'policy-bindings' })).toBe(
|
||||
'/ops/platform-setup/policy-bindings',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { TOPOLOGY_ROUTES } from '../../app/routes/topology.routes';
|
||||
|
||||
describe('TOPOLOGY_ROUTES dedicated pages', () => {
|
||||
const topologyRoot = TOPOLOGY_ROUTES.find((item) => item.path === '');
|
||||
const childRoutes = topologyRoot?.children ?? [];
|
||||
|
||||
async function loadComponentName(path: string): Promise<string | null> {
|
||||
const route = TOPOLOGY_ROUTES.find((item) => item.path === path);
|
||||
const route = childRoutes.find((item) => item.path === path);
|
||||
expect(route).toBeDefined();
|
||||
expect(route?.loadComponent).toBeDefined();
|
||||
const component = await route!.loadComponent!();
|
||||
@@ -32,7 +35,12 @@ describe('TOPOLOGY_ROUTES dedicated pages', () => {
|
||||
});
|
||||
|
||||
it('keeps promotion-paths as an alias redirect', () => {
|
||||
const alias = TOPOLOGY_ROUTES.find((item) => item.path === 'promotion-paths');
|
||||
const alias = childRoutes.find((item) => item.path === 'promotion-paths');
|
||||
expect(alias?.redirectTo).toBe('promotion-graph');
|
||||
});
|
||||
|
||||
it('keeps regions-environments and workflows-gates as canonical aliases', () => {
|
||||
expect(childRoutes.find((item) => item.path === 'regions-environments')?.redirectTo).toBe('regions');
|
||||
expect(childRoutes.find((item) => item.path === 'workflows-gates')?.redirectTo).toBe('workflows');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { TopologyShellComponent } from '../../app/features/topology/topology-shell.component';
|
||||
|
||||
describe('TopologyShellComponent', () => {
|
||||
it('exposes canonical tabs for the preserved topology pages', () => {
|
||||
const component = new TopologyShellComponent();
|
||||
|
||||
expect(component.tabs).toEqual([
|
||||
{ id: 'overview', label: 'Overview', route: 'overview' },
|
||||
{ id: 'map', label: 'Map', route: 'map' },
|
||||
{ id: 'regions', label: 'Regions & Environments', route: 'regions' },
|
||||
{ id: 'targets', label: 'Targets', route: 'targets' },
|
||||
{ id: 'hosts', label: 'Hosts', route: 'hosts' },
|
||||
{ id: 'agents', label: 'Agents', route: 'agents' },
|
||||
{ id: 'promotion', label: 'Promotion Graph', route: 'promotion-graph' },
|
||||
{ id: 'workflows', label: 'Workflows', route: 'workflows' },
|
||||
{ id: 'gate-profiles', label: 'Gate Profiles', route: 'gate-profiles' },
|
||||
{ id: 'connectivity', label: 'Connectivity', route: 'connectivity' },
|
||||
{ id: 'drift', label: 'Runtime Drift', route: 'runtime-drift' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
|
||||
|
||||
const operatorSession: StubAuthSession = {
|
||||
subjectId: 'setup-e2e-user',
|
||||
tenant: 'tenant-default',
|
||||
scopes: [
|
||||
'admin',
|
||||
'ui.read',
|
||||
'ui.admin',
|
||||
'orch:read',
|
||||
'orch:operate',
|
||||
'release:read',
|
||||
'signer:read',
|
||||
],
|
||||
};
|
||||
|
||||
const mockConfig = {
|
||||
authority: {
|
||||
issuer: '/authority',
|
||||
clientId: 'stella-ops-ui',
|
||||
authorizeEndpoint: '/authority/connect/authorize',
|
||||
tokenEndpoint: '/authority/connect/token',
|
||||
logoutEndpoint: '/authority/connect/logout',
|
||||
redirectUri: 'https://127.0.0.1:4400/auth/callback',
|
||||
postLogoutRedirectUri: 'https://127.0.0.1:4400/',
|
||||
scope: 'openid profile email ui.read',
|
||||
audience: '/gateway',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: '/authority',
|
||||
scanner: '/scanner',
|
||||
policy: '/policy',
|
||||
concelier: '/concelier',
|
||||
attestor: '/attestor',
|
||||
gateway: '/gateway',
|
||||
},
|
||||
quickstartMode: true,
|
||||
setup: 'complete',
|
||||
};
|
||||
|
||||
const trustDashboardSummary = {
|
||||
keys: {
|
||||
total: 4,
|
||||
active: 3,
|
||||
expiringSoon: 1,
|
||||
expired: 0,
|
||||
revoked: 0,
|
||||
pendingRotation: 0,
|
||||
},
|
||||
issuers: {
|
||||
total: 2,
|
||||
fullTrust: 1,
|
||||
partialTrust: 1,
|
||||
minimalTrust: 0,
|
||||
untrusted: 0,
|
||||
blocked: 0,
|
||||
averageTrustScore: 89.5,
|
||||
},
|
||||
certificates: {
|
||||
total: 3,
|
||||
valid: 3,
|
||||
expiringSoon: 0,
|
||||
expired: 0,
|
||||
revoked: 0,
|
||||
invalidChains: 0,
|
||||
},
|
||||
recentEvents: [],
|
||||
expiryAlerts: [],
|
||||
};
|
||||
|
||||
const trustIssuers = {
|
||||
items: [
|
||||
{
|
||||
issuerId: 'issuer-001',
|
||||
tenantId: 'tenant-default',
|
||||
name: 'github-security-advisories',
|
||||
displayName: 'GitHub Security Advisories',
|
||||
description: 'Official GitHub advisory issuer',
|
||||
issuerType: 'csaf_publisher',
|
||||
trustLevel: 'full',
|
||||
trustScore: 95,
|
||||
publicKeyFingerprints: ['SHA256:issuer-001'],
|
||||
documentCount: 1200,
|
||||
verificationCount: 1188,
|
||||
weights: {
|
||||
baseWeight: 80,
|
||||
recencyFactor: 10,
|
||||
verificationBonus: 15,
|
||||
volumePenalty: 2,
|
||||
manualAdjustment: 0,
|
||||
},
|
||||
validFrom: '2025-01-01T00:00:00Z',
|
||||
lastVerifiedAt: '2026-03-08T06:00:00Z',
|
||||
isActive: true,
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
updatedAt: '2026-03-08T06:00:00Z',
|
||||
},
|
||||
],
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
totalCount: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const topologyRegions = [
|
||||
{ regionId: 'eu-west', displayName: 'EU West', environmentCount: 1, targetCount: 2 },
|
||||
];
|
||||
|
||||
const topologyEnvironments = [
|
||||
{
|
||||
environmentId: 'prod',
|
||||
regionId: 'eu-west',
|
||||
environmentType: 'prod',
|
||||
displayName: 'Production',
|
||||
targetCount: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const topologyTargets = [
|
||||
{
|
||||
targetId: 'target-001',
|
||||
environmentId: 'prod',
|
||||
regionId: 'eu-west',
|
||||
name: 'Gateway',
|
||||
targetType: 'vm',
|
||||
healthStatus: 'healthy',
|
||||
agentId: 'agent-001',
|
||||
},
|
||||
];
|
||||
|
||||
const topologyAgents = [
|
||||
{
|
||||
agentId: 'agent-001',
|
||||
agentName: 'Agent One',
|
||||
regionId: 'eu-west',
|
||||
environmentId: 'prod',
|
||||
status: 'active',
|
||||
assignedTargetCount: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const promotionPaths = [
|
||||
{
|
||||
pathId: 'path-001',
|
||||
regionId: 'eu-west',
|
||||
sourceEnvironmentId: 'stage',
|
||||
targetEnvironmentId: 'prod',
|
||||
status: 'running',
|
||||
requiredApprovals: 1,
|
||||
},
|
||||
];
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200): Promise<void> {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function setupHarness(page: Page): Promise<void> {
|
||||
await page.addInitScript((session) => {
|
||||
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
|
||||
}, operatorSession);
|
||||
|
||||
await page.route('**/api/**', (route) => fulfillJson(route, {}));
|
||||
await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig));
|
||||
await page.route('**/platform/i18n/*.json', (route) => fulfillJson(route, {}));
|
||||
await page.route('**/config.json', (route) => fulfillJson(route, mockConfig));
|
||||
await page.route('**/.well-known/openid-configuration', (route) =>
|
||||
fulfillJson(route, {
|
||||
issuer: 'https://127.0.0.1:4400/authority',
|
||||
authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize',
|
||||
token_endpoint: 'https://127.0.0.1:4400/authority/connect/token',
|
||||
jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json',
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['RS256'],
|
||||
}),
|
||||
);
|
||||
await page.route('**/authority/.well-known/jwks.json', (route) => fulfillJson(route, { keys: [] }));
|
||||
await page.route('**/console/branding**', (route) =>
|
||||
fulfillJson(route, {
|
||||
tenantId: operatorSession.tenant,
|
||||
appName: 'Stella Ops',
|
||||
logoUrl: null,
|
||||
cssVariables: {},
|
||||
}),
|
||||
);
|
||||
await page.route('**/console/profile**', (route) =>
|
||||
fulfillJson(route, {
|
||||
subjectId: operatorSession.subjectId,
|
||||
username: 'setup-e2e',
|
||||
displayName: 'Setup E2E',
|
||||
tenant: operatorSession.tenant,
|
||||
roles: ['platform-admin'],
|
||||
scopes: operatorSession.scopes,
|
||||
}),
|
||||
);
|
||||
await page.route('**/console/token/introspect**', (route) =>
|
||||
fulfillJson(route, {
|
||||
active: true,
|
||||
tenant: operatorSession.tenant,
|
||||
subject: operatorSession.subjectId,
|
||||
scopes: operatorSession.scopes,
|
||||
}),
|
||||
);
|
||||
await page.route('**/authority/console/tenants**', (route) =>
|
||||
fulfillJson(route, {
|
||||
tenants: [
|
||||
{
|
||||
tenantId: operatorSession.tenant,
|
||||
displayName: 'Default Tenant',
|
||||
isDefault: true,
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v2/context/regions**', (route) =>
|
||||
fulfillJson(route, [{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 1, enabled: true }]),
|
||||
);
|
||||
await page.route('**/api/v2/context/environments**', (route) =>
|
||||
fulfillJson(route, [
|
||||
{
|
||||
environmentId: 'prod',
|
||||
regionId: 'eu-west',
|
||||
environmentType: 'prod',
|
||||
displayName: 'Production',
|
||||
sortOrder: 1,
|
||||
enabled: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
await page.route('**/api/v2/context/preferences**', (route) =>
|
||||
fulfillJson(route, {
|
||||
tenantId: operatorSession.tenant,
|
||||
actorId: operatorSession.subjectId,
|
||||
regions: ['eu-west'],
|
||||
environments: ['prod'],
|
||||
timeWindow: '24h',
|
||||
stage: 'all',
|
||||
updatedAt: '2026-03-08T07:00:00Z',
|
||||
updatedBy: operatorSession.subjectId,
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route(/\/api\/v1\/trust\/dashboard(?:\?.*)?$/, (route) => fulfillJson(route, trustDashboardSummary));
|
||||
await page.route(/\/api\/v1\/trust\/issuers(?:\?.*)?$/, (route) => fulfillJson(route, trustIssuers));
|
||||
await page.route(/\/api\/v2\/topology\/regions(?:\?.*)?$/, (route) => fulfillJson(route, topologyRegions));
|
||||
await page.route(/\/api\/v2\/topology\/environments(?:\?.*)?$/, (route) =>
|
||||
fulfillJson(route, topologyEnvironments),
|
||||
);
|
||||
await page.route(/\/api\/v2\/topology\/targets(?:\?.*)?$/, (route) => fulfillJson(route, topologyTargets));
|
||||
await page.route(/\/api\/v2\/topology\/agents(?:\?.*)?$/, (route) => fulfillJson(route, topologyAgents));
|
||||
await page.route(/\/api\/v2\/topology\/promotion-paths(?:\?.*)?$/, (route) =>
|
||||
fulfillJson(route, promotionPaths),
|
||||
);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupHarness(page);
|
||||
});
|
||||
|
||||
test('topology and trust cutover keeps setup handoffs and legacy trust entry points usable', async ({ page }) => {
|
||||
await page.goto('/settings/trust', { waitUntil: 'networkidle' });
|
||||
await expect(page).toHaveURL(/\/setup\/trust-signing(?:\?.*)?$/);
|
||||
await expect(page.getByRole('heading', { name: 'Trust Management' })).toBeVisible();
|
||||
await page.getByRole('tab', { name: 'Trusted Issuers' }).click();
|
||||
await expect(page).toHaveURL(/\/setup\/trust-signing\/issuers(?:\?.*)?$/);
|
||||
await expect(page.getByText('GitHub Security Advisories')).toBeVisible();
|
||||
|
||||
await page.goto('/admin/trust', { waitUntil: 'networkidle' });
|
||||
await expect(page).toHaveURL(/\/setup\/trust-signing(?:\?.*)?$/);
|
||||
await expect(page.getByRole('tab', { name: 'Watchlist' })).toBeVisible();
|
||||
|
||||
await page.goto('/ops/platform-setup', { waitUntil: 'networkidle' });
|
||||
const regionsCard = page.locator('.setup-home__card').filter({ hasText: 'Regions & Environments' });
|
||||
await regionsCard.getByRole('link', { name: 'Open' }).click();
|
||||
await expect(page).toHaveURL(/\/setup\/topology\/regions(?:\?.*)?$/);
|
||||
await expect(page.getByRole('heading', { name: 'Topology' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Regions & Environments' })).toBeVisible();
|
||||
});
|
||||
Reference in New Issue
Block a user