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:
@@ -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',
|
||||
|
||||
@@ -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 }) });
|
||||
});
|
||||
|
||||
|
||||
244
src/Web/StellaOps.Web/e2e/topology-setup-wizard.e2e.spec.ts
Normal file
244
src/Web/StellaOps.Web/e2e/topology-setup-wizard.e2e.spec.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user