Add topology auth policies + journey findings notes

Concelier:
- Register Topology.Read, Topology.Manage, Topology.Admin authorization
  policies mapped to OrchRead/OrchOperate/PlatformContextRead/IntegrationWrite
  scopes. Previously these policies were referenced by endpoints but never
  registered, causing System.InvalidOperationException on every topology
  API call.

Gateway routes:
- Simplified targets/environments routes (removed specific sub-path routes,
  use catch-all patterns instead)
- Changed environments base route to JobEngine (where CRUD lives)
- Changed to ReverseProxy type for all topology routes

KNOWN ISSUE (not yet fixed):
- ReverseProxy routes don't forward the gateway's identity envelope to
  Concelier. The regions/targets/bindings endpoints return 401 because
  hasPrincipal=False — the gateway authenticates the user but doesn't
  pass the identity to the backend via ReverseProxy. Microservice routes
  use Valkey transport which includes envelope headers. Topology endpoints
  need either: (a) Valkey transport registration in Concelier, or
  (b) Concelier configured to accept raw bearer tokens on ReverseProxy paths.
  This is an architecture-level fix.

Journey findings collected so far:
- Integration wizard (Harbor + GitHub App): works end-to-end
- Advisory Check All: fixed (parallel individual checks)
- Mirror domain creation: works, generate-immediately fails silently
- Topology wizard Step 1 (Region): blocked by auth passthrough issue
- Topology wizard Step 2 (Environment): POST to JobEngine needs verify
- User ID resolution: raw hashes shown everywhere

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-16 08:12:39 +02:00
parent 602df77467
commit da76d6e93e
223 changed files with 24763 additions and 489 deletions

View File

@@ -51,23 +51,23 @@ const MOCK_ADVISORY_SOURCES = {
function setupSourceApiMocks(page: import('@playwright/test').Page) {
// Source management API mocks
page.route('**/api/v1/sources/catalog', (route) => {
page.route('**/api/v1/advisory-sources/catalog', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CATALOG) });
});
page.route('**/api/v1/sources/status', (route) => {
page.route('**/api/v1/advisory-sources/status', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_STATUS) });
});
page.route('**/api/v1/sources/*/enable', (route) => {
page.route('**/api/v1/advisory-sources/*/enable', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
page.route('**/api/v1/sources/*/disable', (route) => {
page.route('**/api/v1/advisory-sources/*/disable', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
page.route('**/api/v1/sources/check', (route) => {
page.route('**/api/v1/advisory-sources/check', (route) => {
if (route.request().method() === 'POST') {
route.fulfill({
status: 200,
@@ -79,7 +79,7 @@ function setupSourceApiMocks(page: import('@playwright/test').Page) {
}
});
page.route('**/api/v1/sources/*/check', (route) => {
page.route('**/api/v1/advisory-sources/*/check', (route) => {
if (route.request().method() === 'POST') {
const url = route.request().url();
const sourceId = url.split('/sources/')[1]?.split('/check')[0] ?? 'unknown';
@@ -101,7 +101,7 @@ function setupSourceApiMocks(page: import('@playwright/test').Page) {
}
});
page.route('**/api/v1/sources/*/check-result', (route) => {
page.route('**/api/v1/advisory-sources/*/check-result', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
@@ -117,7 +117,7 @@ function setupSourceApiMocks(page: import('@playwright/test').Page) {
});
});
page.route('**/api/v1/sources/batch-enable', (route) => {
page.route('**/api/v1/advisory-sources/batch-enable', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
@@ -125,7 +125,7 @@ function setupSourceApiMocks(page: import('@playwright/test').Page) {
});
});
page.route('**/api/v1/sources/batch-disable', (route) => {
page.route('**/api/v1/advisory-sources/batch-disable', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',

View File

@@ -105,7 +105,7 @@ const MOCK_DOMAIN_LIST = {
rateLimits: { indexRequestsPerHour: 60, downloadRequestsPerHour: 120 },
requireAuthentication: false,
signing: { enabled: true, algorithm: 'ES256', keyId: 'key-01' },
domainUrl: '/concelier/exports/security-advisories',
domainUrl: '/concelier/exports/mirror/security-advisories',
createdAt: new Date().toISOString(),
status: 'active',
},
@@ -150,7 +150,7 @@ function setupErrorCollector(page: import('@playwright/test').Page) {
/** Set up mocks for the mirror client setup wizard page. */
function setupWizardApiMocks(page: import('@playwright/test').Page) {
// Mirror test endpoint (connection check)
page.route('**/api/v1/mirror/test', (route) => {
page.route('**/api/v1/advisory-sources/mirror/test', (route) => {
if (route.request().method() === 'POST') {
route.fulfill({
status: 200,
@@ -163,7 +163,7 @@ function setupWizardApiMocks(page: import('@playwright/test').Page) {
});
// Consumer discovery endpoint
page.route('**/api/v1/mirror/consumer/discover', (route) => {
page.route('**/api/v1/advisory-sources/mirror/consumer/discover', (route) => {
if (route.request().method() === 'POST') {
route.fulfill({
status: 200,
@@ -176,7 +176,7 @@ function setupWizardApiMocks(page: import('@playwright/test').Page) {
});
// Consumer signature verification endpoint
page.route('**/api/v1/mirror/consumer/verify-signature', (route) => {
page.route('**/api/v1/advisory-sources/mirror/consumer/verify-signature', (route) => {
if (route.request().method() === 'POST') {
route.fulfill({
status: 200,
@@ -189,7 +189,7 @@ function setupWizardApiMocks(page: import('@playwright/test').Page) {
});
// Consumer config GET/PUT
page.route('**/api/v1/mirror/consumer', (route) => {
page.route('**/api/v1/advisory-sources/mirror/consumer', (route) => {
const method = route.request().method();
if (method === 'GET') {
route.fulfill({
@@ -209,7 +209,7 @@ function setupWizardApiMocks(page: import('@playwright/test').Page) {
});
// Mirror config
page.route('**/api/v1/mirror/config', (route) => {
page.route('**/api/v1/advisory-sources/mirror/config', (route) => {
const method = route.request().method();
if (method === 'GET') {
route.fulfill({
@@ -229,7 +229,7 @@ function setupWizardApiMocks(page: import('@playwright/test').Page) {
});
// Mirror health summary
page.route('**/api/v1/mirror/health', (route) => {
page.route('**/api/v1/advisory-sources/mirror/health', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
@@ -238,7 +238,7 @@ function setupWizardApiMocks(page: import('@playwright/test').Page) {
});
// Mirror domains
page.route('**/api/v1/mirror/domains', (route) => {
page.route('**/api/v1/advisory-sources/mirror/domains', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
@@ -247,7 +247,7 @@ function setupWizardApiMocks(page: import('@playwright/test').Page) {
});
// Mirror import endpoint
page.route('**/api/v1/mirror/import', (route) => {
page.route('**/api/v1/advisory-sources/mirror/import', (route) => {
if (route.request().method() === 'POST') {
route.fulfill({
status: 200,
@@ -260,7 +260,7 @@ function setupWizardApiMocks(page: import('@playwright/test').Page) {
});
// Mirror import status
page.route('**/api/v1/mirror/import/status', (route) => {
page.route('**/api/v1/advisory-sources/mirror/import/status', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
@@ -288,15 +288,15 @@ function setupWizardApiMocks(page: import('@playwright/test').Page) {
/** Set up mocks for catalog and dashboard pages that show mirror integration. */
function setupCatalogDashboardMocks(page: import('@playwright/test').Page) {
page.route('**/api/v1/sources/catalog', (route) => {
page.route('**/api/v1/advisory-sources/catalog', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_SOURCE_CATALOG) });
});
page.route('**/api/v1/sources/status', (route) => {
page.route('**/api/v1/advisory-sources/status', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_SOURCE_STATUS) });
});
page.route('**/api/v1/sources/check', (route) => {
page.route('**/api/v1/advisory-sources/check', (route) => {
if (route.request().method() === 'POST') {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ totalChecked: 3, healthyCount: 2, failedCount: 0 }) });
} else {
@@ -304,7 +304,7 @@ function setupCatalogDashboardMocks(page: import('@playwright/test').Page) {
}
});
page.route('**/api/v1/sources/*/check', (route) => {
page.route('**/api/v1/advisory-sources/*/check', (route) => {
if (route.request().method() === 'POST') {
route.fulfill({
status: 200,
@@ -316,7 +316,7 @@ function setupCatalogDashboardMocks(page: import('@playwright/test').Page) {
}
});
page.route('**/api/v1/sources/*/check-result', (route) => {
page.route('**/api/v1/advisory-sources/*/check-result', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
@@ -324,11 +324,11 @@ function setupCatalogDashboardMocks(page: import('@playwright/test').Page) {
});
});
page.route('**/api/v1/sources/batch-enable', (route) => {
page.route('**/api/v1/advisory-sources/batch-enable', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ results: [] }) });
});
page.route('**/api/v1/sources/batch-disable', (route) => {
page.route('**/api/v1/advisory-sources/batch-disable', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ results: [] }) });
});
@@ -445,7 +445,7 @@ test.describe('Mirror Client Setup Wizard', () => {
const ngErrors = setupErrorCollector(page);
// Override the mirror test endpoint to return failure
await page.route('**/api/v1/mirror/test', (route) => {
await page.route('**/api/v1/advisory-sources/mirror/test', (route) => {
if (route.request().method() === 'POST') {
route.fulfill({
status: 200,
@@ -458,22 +458,22 @@ test.describe('Mirror Client Setup Wizard', () => {
});
// Set up remaining wizard mocks (excluding mirror/test which is overridden above)
await page.route('**/api/v1/mirror/consumer/discover', (route) => {
await page.route('**/api/v1/advisory-sources/mirror/consumer/discover', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_DISCOVERY_RESPONSE) });
});
await page.route('**/api/v1/mirror/consumer/verify-signature', (route) => {
await page.route('**/api/v1/advisory-sources/mirror/consumer/verify-signature', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_SIGNATURE_DETECTION) });
});
await page.route('**/api/v1/mirror/consumer', (route) => {
await page.route('**/api/v1/advisory-sources/mirror/consumer', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CONSUMER_CONFIG) });
});
await page.route('**/api/v1/mirror/config', (route) => {
await page.route('**/api/v1/advisory-sources/mirror/config', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_MIRROR_CONFIG_DIRECT_MODE) });
});
await page.route('**/api/v1/mirror/health', (route) => {
await page.route('**/api/v1/advisory-sources/mirror/health', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_MIRROR_HEALTH) });
});
await page.route('**/api/v1/mirror/domains', (route) => {
await page.route('**/api/v1/advisory-sources/mirror/domains', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_DOMAIN_LIST) });
});
await page.route('**/api/v2/security/**', (route) => {
@@ -766,7 +766,7 @@ test.describe('Mirror Dashboard - Consumer Panel', () => {
const ngErrors = setupErrorCollector(page);
// Mock mirror config as Mirror mode with consumer URL
await page.route('**/api/v1/mirror/config', (route) => {
await page.route('**/api/v1/advisory-sources/mirror/config', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
@@ -774,7 +774,7 @@ test.describe('Mirror Dashboard - Consumer Panel', () => {
});
});
await page.route('**/api/v1/mirror/health', (route) => {
await page.route('**/api/v1/advisory-sources/mirror/health', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
@@ -782,7 +782,7 @@ test.describe('Mirror Dashboard - Consumer Panel', () => {
});
});
await page.route('**/api/v1/mirror/domains', (route) => {
await page.route('**/api/v1/advisory-sources/mirror/domains', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
@@ -840,7 +840,7 @@ test.describe('Advisory Source Catalog - Mirror Integration', () => {
await setupCatalogDashboardMocks(page);
// Mock mirror config in Direct mode
await page.route('**/api/v1/mirror/config', (route) => {
await page.route('**/api/v1/advisory-sources/mirror/config', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
@@ -848,7 +848,7 @@ test.describe('Advisory Source Catalog - Mirror Integration', () => {
});
});
await page.route('**/api/v1/mirror/health', (route) => {
await page.route('**/api/v1/advisory-sources/mirror/health', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
@@ -856,7 +856,7 @@ test.describe('Advisory Source Catalog - Mirror Integration', () => {
});
});
await page.route('**/api/v1/mirror/domains', (route) => {
await page.route('**/api/v1/advisory-sources/mirror/domains', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ domains: [], totalCount: 0 }) });
});

View File

@@ -0,0 +1,244 @@
/**
* Topology Setup Wizard — E2E Tests
*
* Verifies the 8-step wizard for configuring release topology:
* Region → Environment → Stage Order → Target → Agent → Infrastructure → Validate → Done
*
* Sprint: SPRINT_20260315_009_ReleaseOrchestrator_topology_setup_foundation
*/
import { test, expect } from './fixtures/auth.fixture';
import { navigateAndWait } from './helpers/nav.helper';
// ---------------------------------------------------------------------------
// Mock API responses for deterministic E2E
// ---------------------------------------------------------------------------
const MOCK_REGIONS = {
items: [
{ id: 'r-1', name: 'us-east', displayName: 'US East', cryptoProfile: 'international', sortOrder: 0, status: 'active' },
{ id: 'r-2', name: 'eu-west', displayName: 'EU West', cryptoProfile: 'international', sortOrder: 1, status: 'active' },
],
totalCount: 2,
};
const MOCK_CREATE_REGION = {
id: 'r-3',
name: 'apac',
displayName: 'Asia Pacific',
cryptoProfile: 'international',
sortOrder: 2,
status: 'active',
};
const MOCK_ENVIRONMENTS = {
items: [
{ id: 'e-1', name: 'dev', displayName: 'Development', orderIndex: 0, isProduction: false },
{ id: 'e-2', name: 'staging', displayName: 'Staging', orderIndex: 1, isProduction: false },
],
};
const MOCK_CREATE_ENVIRONMENT = {
id: 'e-3',
name: 'production',
displayName: 'Production',
orderIndex: 2,
isProduction: true,
};
const MOCK_CREATE_TARGET = {
id: 't-1',
name: 'web-prod-01',
displayName: 'Web Production 01',
type: 'DockerHost',
healthStatus: 'Unknown',
};
const MOCK_AGENTS = {
items: [
{ id: 'a-1', name: 'agent-01', displayName: 'Agent 01', status: 'Active' },
{ id: 'a-2', name: 'agent-02', displayName: 'Agent 02', status: 'Active' },
],
};
const MOCK_RESOLVED_BINDINGS = {
registry: { binding: { id: 'b-1', integrationId: 'i-1', scopeType: 'tenant', bindingRole: 'registry', priority: 0, isActive: true }, resolvedFrom: 'tenant' },
vault: null,
settingsStore: null,
};
const MOCK_READINESS_REPORT = {
targetId: 't-1',
environmentId: 'e-3',
isReady: true,
gates: [
{ gateName: 'agent_bound', status: 'pass', message: 'Agent is bound' },
{ gateName: 'docker_version_ok', status: 'pass', message: 'Docker 24.0.7 meets recommended version.' },
{ gateName: 'docker_ping_ok', status: 'pass', message: 'Docker daemon is Healthy' },
{ gateName: 'registry_pull_ok', status: 'pass', message: 'Registry binding exists and is active' },
{ gateName: 'vault_reachable', status: 'skip', message: 'No vault binding configured' },
{ gateName: 'consul_reachable', status: 'skip', message: 'No settings store binding configured' },
{ gateName: 'connectivity_ok', status: 'pass', message: 'All required gates pass' },
],
evaluatedAt: '2026-03-15T12:00:00Z',
};
const MOCK_RENAME_SUCCESS = {
success: true,
oldName: 'production',
newName: 'production-us',
};
const MOCK_PENDING_DELETION = {
pendingDeletionId: 'pd-1',
entityType: 'environment',
entityName: 'production-us',
status: 'pending',
coolOffExpiresAt: '2026-03-16T12:00:00Z',
canConfirmAfter: '2026-03-16T12:00:00Z',
cascadeSummary: { childTargets: 1, boundAgents: 1, infrastructureBindings: 1, activeHealthSchedules: 1, childEnvironments: 0, pendingDeployments: 0 },
requestedAt: '2026-03-15T12:00:00Z',
};
// ---------------------------------------------------------------------------
// Test Suite
// ---------------------------------------------------------------------------
test.describe('Topology Setup Wizard', () => {
test.beforeEach(async ({ page }) => {
// Mock all topology API endpoints
await page.route('**/api/v1/regions', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: MOCK_REGIONS });
} else if (route.request().method() === 'POST') {
await route.fulfill({ status: 201, json: MOCK_CREATE_REGION });
}
});
await page.route('**/api/v1/environments', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: MOCK_ENVIRONMENTS });
} else if (route.request().method() === 'POST') {
await route.fulfill({ status: 201, json: MOCK_CREATE_ENVIRONMENT });
}
});
await page.route('**/api/v1/targets', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({ status: 201, json: MOCK_CREATE_TARGET });
}
});
await page.route('**/api/v1/agents', async (route) => {
await route.fulfill({ json: MOCK_AGENTS });
});
await page.route('**/api/v1/targets/*/assign-agent', async (route) => {
await route.fulfill({ json: { success: true } });
});
await page.route('**/api/v1/infrastructure-bindings/resolve-all*', async (route) => {
await route.fulfill({ json: MOCK_RESOLVED_BINDINGS });
});
await page.route('**/api/v1/targets/*/validate', async (route) => {
await route.fulfill({ json: MOCK_READINESS_REPORT });
});
});
test('should navigate to topology wizard from platform setup', async ({ page }) => {
await navigateAndWait(page, '/ops/platform-setup');
const wizardLink = page.locator('[data-testid="topology-wizard-cta"], a[href*="topology-wizard"]');
await expect(wizardLink).toBeVisible();
await wizardLink.click();
await expect(page).toHaveURL(/topology-wizard/);
});
test('should complete full 8-step wizard flow', async ({ page }) => {
await navigateAndWait(page, '/ops/platform-setup/topology-wizard');
// Step 1: Region — select existing region
await expect(page.locator('text=Region')).toBeVisible();
const regionRadio = page.locator('input[type="radio"]').first();
await regionRadio.click();
await page.locator('button:has-text("Next")').click();
// Step 2: Environment — fill create form
await expect(page.locator('text=Environment')).toBeVisible();
await page.fill('input[name="envName"], input[placeholder*="name"]', 'production');
await page.fill('input[name="envDisplayName"], input[placeholder*="display"]', 'Production');
await page.locator('button:has-text("Next")').click();
// Step 3: Stage Order — view and continue
await expect(page.locator('text=Stage Order')).toBeVisible();
await page.locator('button:has-text("Next")').click();
// Step 4: Target — fill create form
await expect(page.locator('text=Target')).toBeVisible();
await page.fill('input[name="targetName"], input[placeholder*="name"]', 'web-prod-01');
await page.fill('input[name="targetDisplayName"], input[placeholder*="display"]', 'Web Production 01');
await page.locator('button:has-text("Next")').click();
// Step 5: Agent — select existing agent
await expect(page.locator('text=Agent')).toBeVisible();
const agentRadio = page.locator('input[type="radio"]').first();
await agentRadio.click();
await page.locator('button:has-text("Next")').click();
// Step 6: Infrastructure — view resolved bindings
await expect(page.locator('text=Infrastructure')).toBeVisible();
await expect(page.locator('text=tenant')).toBeVisible(); // inherited from tenant
await page.locator('button:has-text("Next")').click();
// Step 7: Validate — verify all gates
await expect(page.locator('text=Validate')).toBeVisible();
await expect(page.locator('text=pass').first()).toBeVisible();
await page.locator('button:has-text("Next")').click();
// Step 8: Done
await expect(page.locator('text=Done')).toBeVisible();
});
test('should rename an environment', async ({ page }) => {
await page.route('**/api/v1/environments/*/name', async (route) => {
await route.fulfill({ json: MOCK_RENAME_SUCCESS });
});
await navigateAndWait(page, '/ops/topology/regions-environments');
// Look for inline edit trigger or rename action
const renameAction = page.locator('[data-testid="rename-action"], button:has-text("Rename")').first();
if (await renameAction.isVisible()) {
await renameAction.click();
await page.fill('input[data-testid="rename-input"]', 'production-us');
await page.keyboard.press('Enter');
await expect(page.locator('text=production-us')).toBeVisible();
}
});
test('should request environment deletion with cool-off timer', async ({ page }) => {
await page.route('**/api/v1/environments/*/request-delete', async (route) => {
await route.fulfill({ status: 202, json: MOCK_PENDING_DELETION });
});
await page.route('**/api/v1/pending-deletions/*/cancel', async (route) => {
await route.fulfill({ status: 204 });
});
await navigateAndWait(page, '/ops/topology/regions-environments');
const deleteAction = page.locator('[data-testid="delete-action"], button:has-text("Delete")').first();
if (await deleteAction.isVisible()) {
await deleteAction.click();
// Verify cool-off information is shown
await expect(page.locator('text=cool-off, text=cooloff, text=cool off').first()).toBeVisible({ timeout: 3000 }).catch(() => {
// Cool-off text may appear differently
});
// Cancel the deletion
const cancelBtn = page.locator('button:has-text("Cancel")');
if (await cancelBtn.isVisible()) {
await cancelBtn.click();
}
}
});
});