diff --git a/frontend/tests/shared/global-setup.mjs b/frontend/tests/shared/global-setup.mjs index f73668700e..e6e6b24698 100644 --- a/frontend/tests/shared/global-setup.mjs +++ b/frontend/tests/shared/global-setup.mjs @@ -794,7 +794,8 @@ async function startBackendServerWithConfig( state, configPath, externalPort, - networkAlias + networkAlias, + extraHosts = [] ) { console.log(`Starting backend server container on port ${externalPort} with alias ${networkAlias}...`); @@ -837,13 +838,18 @@ async function startBackendServerWithConfig( let containerId; try { - const backend = await new GenericContainer(imageTag) + let container = new GenericContainer(imageTag) .withNetwork(network) .withNetworkAliases(networkAlias) .withExposedPorts({ container: 3000, host: externalPort }) .withBindMounts(bindMounts) - .withCommand(['--config.filepath=/etc/console/config.yaml']) - .start(); + .withCommand(['--config.filepath=/etc/console/config.yaml']); + + if (extraHosts.length > 0) { + container = container.withExtraHosts(extraHosts); + } + + const backend = await container.start(); containerId = backend.getId(); state.backendId = containerId; @@ -882,7 +888,112 @@ async function startBackendServerWithConfig( } } +/** + * Start the Console backend as a host process (not in Docker). + * Used for OIDC tests where the backend must reach both localhost services + * (Zitadel) and port-mapped Docker services (Redpanda). + */ +async function startBackendProcess(state, configPath, ports) { + console.log('Starting backend as host process...'); + + // Rewrite the config to use localhost ports instead of Docker hostnames + const fs = await import('node:fs'); + let config = fs.readFileSync(configPath, 'utf-8'); + config = config.replace('redpanda:9092', `localhost:${ports.redpandaKafka}`); + config = config.replace('http://redpanda:9644', `http://localhost:${ports.redpandaAdmin}`); + config = config.replace('http://redpanda:8081', `http://localhost:${ports.redpandaSchemaRegistry}`); + config = config.replace('listenPort: 3000', `listenPort: ${ports.backend}`); + + // Resolve the license file path to the host filesystem + const defaultLicensePath = resolve( + __dirname, + '../../../../console-enterprise/frontend/tests/config/redpanda.license' + ); + const hostLicensePath = process.env.REDPANDA_LICENSE_PATH || defaultLicensePath; + config = config.replace(/licenseFilepath:.*/, `licenseFilepath: ${hostLicensePath}`); + + fs.writeFileSync(configPath, config); + + // Find the enterprise backend binary or build it + const backendDir = process.env.ENTERPRISE_BACKEND_DIR + ? resolve(process.env.ENTERPRISE_BACKEND_DIR) + : resolve(__dirname, '../../../../console-enterprise/backend'); + + const cmdDir = join(backendDir, 'cmd'); + + // Copy frontend assets into the embed directory for the Go binary + const frontendBuildDir = resolve(__dirname, '../../build'); + const embedDir = join(backendDir, 'pkg', 'embed', 'frontend'); + if (fs.existsSync(frontendBuildDir)) { + console.log(' Copying frontend assets for host binary...'); + await execAsync(`cp -r "${frontendBuildDir}"/* "${embedDir}"/`); + } + + // Build the binary + console.log(` Building backend from ${cmdDir}...`); + await execAsync('go build -o /tmp/console-enterprise-e2e .', { cwd: cmdDir, maxBuffer: 50 * 1024 * 1024 }); + console.log(' ✓ Backend binary built'); + + // Clean up the copied frontend assets + await execAsync(`find "${embedDir}" -mindepth 1 ! -name '.gitignore' -delete`).catch(() => {}); + + // If a license is available, inject it as the REDPANDA_LICENSE env var + // so the backend can use it without a filepath. + let licenseEnv = {}; + if (process.env.ENTERPRISE_LICENSE_CONTENT) { + licenseEnv.REDPANDA_LICENSE = process.env.ENTERPRISE_LICENSE_CONTENT; + } else { + const defaultLicensePath = resolve( + __dirname, + '../../../../console-enterprise/frontend/tests/config/redpanda.license' + ); + const licensePath = process.env.REDPANDA_LICENSE_PATH || defaultLicensePath; + if (fs.existsSync(licensePath)) { + licenseEnv.REDPANDA_LICENSE = fs.readFileSync(licensePath, 'utf-8').trim(); + } + } + + // Start the backend process + const { spawn } = await import('node:child_process'); + const args = [`--config.filepath=${configPath}`]; + + const proc = spawn('/tmp/console-enterprise-e2e', args, { + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + env: { ...process.env, ...licenseEnv }, + }); + + state.backendProcess = proc; + state.backendPid = proc.pid; + + // Log output for debugging + proc.stdout.on('data', (data) => { + const line = data.toString().trim(); + if (line) console.log(`[backend] ${line}`); + }); + proc.stderr.on('data', (data) => { + const line = data.toString().trim(); + if (line) console.error(`[backend] ${line}`); + }); + + proc.on('exit', (code) => { + if (code !== null && code !== 0) { + console.error(`Backend process exited with code ${code}`); + } + }); + + // Wait for backend to be ready + console.log(` Waiting for backend on port ${ports.backend}...`); + await waitForPort(ports.backend, 60, 1000); + console.log(` ✓ Backend ready at http://localhost:${ports.backend}`); +} + async function cleanupOnFailure(state) { + if (state.backendProcess) { + console.log('Stopping backend process...'); + try { state.backendProcess.kill('SIGTERM'); } catch { /* ignore */ } + } + if (state.sourceBackendContainer) { console.log('Stopping source backend container using testcontainers API...'); await state.sourceBackendContainer.stop().catch((error) => { @@ -909,6 +1020,19 @@ async function cleanupOnFailure(state) { console.log(`Failed to stop OwlShop container: ${error.message}`); }); } + for (const [key, label] of [ + ['zitadelProxyContainer', 'Zitadel proxy'], + ['zitadelLoginContainer', 'Zitadel Login'], + ['zitadelContainer', 'Zitadel API'], + ['zitadelDbContainer', 'Zitadel DB'], + ]) { + if (state[key]) { + console.log(`Stopping ${label} container using testcontainers API...`); + await state[key].stop().catch((error) => { + console.log(`Failed to stop ${label} container: ${error.message}`); + }); + } + } if (state.destRedpandaContainer) { console.log('Stopping destination Redpanda container using testcontainers API...'); await state.destRedpandaContainer.stop().catch((error) => { @@ -936,19 +1060,21 @@ export default async function globalSetup(config = {}) { const isEnterprise = config?.metadata?.isEnterprise ?? false; const needsShadowlink = config?.metadata?.needsShadowlink ?? false; const needsConnect = config?.metadata?.needsConnect ?? false; + const needsZitadel = config?.metadata?.needsZitadel ?? false; // Load ports from variant's config/variant.json const variantConfig = loadVariantConfig(variantName); const ports = variantConfig.ports; console.log('\n\n========================================'); - console.log(`🚀 GLOBAL SETUP: ${variantName}${needsShadowlink ? ' + SHADOWLINK' : ''}`); + console.log(`🚀 GLOBAL SETUP: ${variantName}${needsShadowlink ? ' + SHADOWLINK' : ''}${needsZitadel ? ' + ZITADEL' : ''}`); console.log('========================================\n'); console.log('DEBUG - Config metadata:', { variantName, configFile, isEnterprise, needsShadowlink, + needsZitadel, ports, }); console.log('Starting testcontainers environment...'); @@ -963,6 +1089,7 @@ export default async function globalSetup(config = {}) { sourceBackendId: '', isEnterprise, needsShadowlink, + needsZitadel, }; try { @@ -1003,6 +1130,22 @@ export default async function globalSetup(config = {}) { console.log(` - Kafka Connect: http://localhost:${ports.kafkaConnect}`); console.log('================================\n'); + // --- Zitadel OIDC provider (must start before backend so config can be rewritten) --- + let zitadelConfig = null; + let effectiveConfigFile = configFile; + if (needsZitadel) { + const { startZitadel, rewriteConsoleConfig } = await import('./zitadel-setup.mjs'); + zitadelConfig = await startZitadel(network, state, ports); + + // Rewrite the Console config template with real Zitadel values + const originalConfigPath = resolve(__dirname, '..', `test-variant-${variantName}`, 'config', configFile); + const rewrittenConfigPath = rewriteConsoleConfig(originalConfigPath, zitadelConfig); + // Store rewritten path for the backend to use + state.zitadelConfig = zitadelConfig; + state.rewrittenConfigPath = rewrittenConfigPath; + console.log(` ✓ Console config rewritten: ${rewrittenConfigPath}`); + } + // --- Group 2: Start services in parallel (all depend on Redpanda being ready) --- const servicePromises = [ startOwlShop(network, state), @@ -1036,6 +1179,13 @@ export default async function globalSetup(config = {}) { 'console-backend-dest' ) ); + } else if (needsZitadel && state.rewrittenConfigPath) { + // For OIDC tests, the Console backend must reach both Zitadel (on localhost) + // and Redpanda (on Docker network). Run the backend as a host process + // with the config rewritten to use localhost-accessible ports for all services. + servicePromises.push( + startBackendProcess(state, state.rewrittenConfigPath, ports) + ); } else { servicePromises.push( startBackendServer(network, isEnterprise, imageTag, state, variantName, configFile, ports) diff --git a/frontend/tests/shared/global-teardown.mjs b/frontend/tests/shared/global-teardown.mjs index 162c844a1f..fc2d587182 100644 --- a/frontend/tests/shared/global-teardown.mjs +++ b/frontend/tests/shared/global-teardown.mjs @@ -24,68 +24,32 @@ export default async function globalTeardown(config = {}) { const state = JSON.parse(fs.readFileSync(CONTAINER_STATE_FILE, 'utf8')); - // Stop backend containers - if (state.sourceBackendId) { - console.log('Stopping source backend container...'); - await execAsync(`docker stop ${state.sourceBackendId}`).catch(() => { - // Ignore errors - container might already be stopped - }); - await execAsync(`docker rm ${state.sourceBackendId}`).catch(() => { - // Ignore errors - container might already be removed - }); - } - - if (state.backendId) { - console.log('Stopping backend container...'); - await execAsync(`docker stop ${state.backendId}`).catch(() => { - // Ignore errors - container might already be stopped - }); - await execAsync(`docker rm ${state.backendId}`).catch(() => { - // Ignore errors - container might already be removed - }); - } - - // Stop Docker containers (testcontainers) - if (state.connectId) { - console.log('Stopping Kafka Connect container...'); - await execAsync(`docker stop ${state.connectId}`).catch(() => { - // Ignore errors - container might already be stopped - }); - await execAsync(`docker rm ${state.connectId}`).catch(() => { - // Ignore errors - container might already be removed - }); + // Stop backend process if running as host process (OIDC variant) + if (state.backendPid) { + console.log(`Stopping backend process (PID ${state.backendPid})...`); + await execAsync(`kill ${state.backendPid}`).catch(() => {}); } - if (state.owlshopId) { - console.log('Stopping OwlShop container...'); - await execAsync(`docker stop ${state.owlshopId}`).catch(() => { - // Ignore errors - container might already be stopped - }); - await execAsync(`docker rm ${state.owlshopId}`).catch(() => { - // Ignore errors - container might already be removed - }); - } - - // Stop destination cluster if it exists (shadowlink tests) - if (state.destRedpandaId) { - console.log('Stopping destination Redpanda container...'); - await execAsync(`docker stop ${state.destRedpandaId}`).catch(() => { - // Ignore errors - container might already be stopped - }); - await execAsync(`docker rm ${state.destRedpandaId}`).catch(() => { - // Ignore errors - container might already be removed - }); - } - - // Stop source cluster (existing/main redpanda) - if (state.redpandaId) { - console.log('Stopping source Redpanda container...'); - await execAsync(`docker stop ${state.redpandaId}`).catch(() => { - // Ignore errors - container might already be stopped - }); - await execAsync(`docker rm ${state.redpandaId}`).catch(() => { - // Ignore errors - container might already be removed - }); + // Stop containers in dependency order (dependents first, then infrastructure). + // Each entry is [stateKey, label]. Errors are ignored since containers may + // already be stopped/removed. + for (const [key, label] of [ + ['sourceBackendId', 'source backend'], + ['backendId', 'backend'], + ['connectId', 'Kafka Connect'], + ['owlshopId', 'OwlShop'], + ['destRedpandaId', 'destination Redpanda'], + ['zitadelProxyId', 'Zitadel proxy'], + ['zitadelLoginId', 'Zitadel Login'], + ['zitadelId', 'Zitadel API'], + ['zitadelDbId', 'Zitadel PostgreSQL'], + ['redpandaId', 'source Redpanda'], + ]) { + if (state[key]) { + console.log(`Stopping ${label} container...`); + await execAsync(`docker stop ${state[key]}`).catch(() => {}); + await execAsync(`docker rm ${state[key]}`).catch(() => {}); + } } if (state.networkId) { diff --git a/frontend/tests/shared/zitadel-setup.mjs b/frontend/tests/shared/zitadel-setup.mjs new file mode 100644 index 0000000000..1ef6f59b90 --- /dev/null +++ b/frontend/tests/shared/zitadel-setup.mjs @@ -0,0 +1,495 @@ +/** + * Zitadel OIDC provider setup for E2E tests. + * + * Starts PostgreSQL + Zitadel containers, provisions an OIDC application, + * project roles, and test users. Returns the configuration needed by + * Console's OIDC authentication. + */ + +import { GenericContainer, Network, Wait } from 'testcontainers'; +import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as crypto from 'node:crypto'; + +const ZITADEL_VERSION = 'v4.13.1'; +const ZITADEL_API_IMAGE = `ghcr.io/zitadel/zitadel:${ZITADEL_VERSION}`; +const ZITADEL_LOGIN_IMAGE = `ghcr.io/zitadel/zitadel-login:${ZITADEL_VERSION}`; +const ZITADEL_DOMAIN = 'localhost'; +const POSTGRES_IMAGE = 'postgres:17-alpine'; +const MASTERKEY = 'MasterkeyNeedsToHave32Characters'; +const TEST_PASSWORD = 'Secr3tP4ssw0rd!'; +const MACHINE_KEY_PATH = '/data/machinekey.json'; + +/** + * Start Zitadel with PostgreSQL and provision test resources. + * + * @param {import('testcontainers').StartedNetwork} network - Docker network + * @param {object} state - Shared state object for container tracking + * @param {object} ports - Port allocations from variant.json + * @returns {Promise<{issuerURL: string, clientID: string, clientSecret: string, users: object[]}>} + */ +export async function startZitadel(network, state, ports) { + console.log('Starting Zitadel OIDC provider...'); + + // Create a temp dir for the machine key file (bind-mounted into Zitadel) + const keyDir = mkdtempSync(join(tmpdir(), 'zitadel-key-')); + + // 1. Start PostgreSQL for Zitadel + console.log(' Starting PostgreSQL for Zitadel...'); + const postgres = await new GenericContainer(POSTGRES_IMAGE) + .withNetwork(network) + .withNetworkAliases('zitadel-db') + .withEnvironment({ + POSTGRES_USER: 'zitadel', + POSTGRES_PASSWORD: 'zitadel', + POSTGRES_DB: 'zitadel', + }) + .withHealthCheck({ + test: ['CMD-SHELL', 'pg_isready -U zitadel'], + interval: 5_000, + timeout: 3_000, + retries: 10, + startPeriod: 5_000, + }) + .withWaitStrategy(Wait.forHealthCheck()) + .withStartupTimeout(60_000) + .start(); + + state.zitadelDbId = postgres.getId(); + state.zitadelDbContainer = postgres; + console.log(` ✓ PostgreSQL started: ${state.zitadelDbId}`); + + // 2. Create a shared volume for the login client PAT (API writes, Login reads) + const bootstrapDir = mkdtempSync(join(tmpdir(), 'zitadel-bootstrap-')); + + // 3. Start Zitadel API server + console.log(' Starting Zitadel API server...'); + const publicURL = `http://${ZITADEL_DOMAIN}:${ports.zitadel}`; + const zitadelApi = await new GenericContainer(ZITADEL_API_IMAGE) + .withNetwork(network) + .withNetworkAliases('zitadel-api') + .withCommand([ + 'start-from-init', + '--masterkey', MASTERKEY, + '--tlsMode', 'disabled', + ]) + .withUser('root') + .withBindMounts([ + { source: keyDir, target: '/data' }, + { source: bootstrapDir, target: '/zitadel/bootstrap' }, + ]) + .withEnvironment({ + ZITADEL_PORT: '8080', + ZITADEL_EXTERNALDOMAIN: ZITADEL_DOMAIN, + ZITADEL_EXTERNALSECURE: 'false', + ZITADEL_EXTERNALPORT: String(ports.zitadel), + ZITADEL_TLS_ENABLED: 'false', + ZITADEL_DATABASE_POSTGRES_HOST: 'zitadel-db', + ZITADEL_DATABASE_POSTGRES_PORT: '5432', + ZITADEL_DATABASE_POSTGRES_DATABASE: 'zitadel', + ZITADEL_DATABASE_POSTGRES_USER_USERNAME: 'zitadel', + ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: 'zitadel', + ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: 'disable', + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: 'zitadel', + ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: 'zitadel', + ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: 'disable', + ZITADEL_FIRSTINSTANCE_MACHINEKEYPATH: MACHINE_KEY_PATH, + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: 'admin-sa', + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: 'Admin', + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINEKEY_TYPE: '1', + // Login client PAT for the login app + ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: '/zitadel/bootstrap/login-client.pat', + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: 'login-client', + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: 'Login Client', + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: '2099-01-01T00:00:00Z', + // v2 login UI configuration + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: 'true', + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: `${publicURL}/ui/v2/login/`, + ZITADEL_OIDC_DEFAULTLOGINURLV2: `${publicURL}/ui/v2/login/login?authRequest=`, + ZITADEL_OIDC_DEFAULTLOGOUTURLV2: `${publicURL}/ui/v2/login/logout?post_logout_redirect=`, + }) + .withHealthCheck({ + test: ['CMD', '/app/zitadel', 'ready'], + interval: 10_000, + timeout: 30_000, + retries: 12, + startPeriod: 20_000, + }) + .withWaitStrategy(Wait.forHealthCheck()) + .withStartupTimeout(120_000) + .start(); + + state.zitadelId = zitadelApi.getId(); + state.zitadelContainer = zitadelApi; + console.log(` ✓ Zitadel API started: ${state.zitadelId}`); + + // 4. Start Zitadel Login app (Next.js) + console.log(' Starting Zitadel Login app...'); + const zitadelLogin = await new GenericContainer(ZITADEL_LOGIN_IMAGE) + .withNetwork(network) + .withNetworkAliases('zitadel-login') + .withUser('root') + .withBindMounts([ + { source: bootstrapDir, target: '/zitadel/bootstrap', mode: 'ro' }, + ]) + .withEnvironment({ + ZITADEL_API_URL: 'http://zitadel-api:8080', + NEXT_PUBLIC_BASE_PATH: '/ui/v2/login', + ZITADEL_SERVICE_USER_TOKEN_FILE: '/zitadel/bootstrap/login-client.pat', + CUSTOM_REQUEST_HEADERS: `Host:${ZITADEL_DOMAIN},X-Forwarded-Proto:http`, + }) + .withHealthCheck({ + test: ['CMD', '/bin/sh', '-c', 'node /app/healthcheck.mjs http://localhost:3000/ui/v2/login/healthy'], + interval: 10_000, + timeout: 30_000, + retries: 12, + startPeriod: 20_000, + }) + .withWaitStrategy(Wait.forHealthCheck()) + .withStartupTimeout(120_000) + .start(); + + state.zitadelLoginId = zitadelLogin.getId(); + state.zitadelLoginContainer = zitadelLogin; + console.log(` ✓ Zitadel Login started: ${state.zitadelLoginId}`); + + // 5. Start nginx reverse proxy to route between API and Login + // /ui/v2/login/* → zitadel-login:3000, everything else → zitadel-api:8080 + console.log(' Starting nginx proxy...'); + const nginxConf = ` +server { + listen 80; + location /ui/v2/login/ { + proxy_pass http://zitadel-login:3000; + proxy_set_header Host ${ZITADEL_DOMAIN}:${ports.zitadel}; + proxy_set_header X-Forwarded-Proto http; + proxy_set_header X-Forwarded-Host ${ZITADEL_DOMAIN}:${ports.zitadel}; + } + location / { + proxy_pass http://zitadel-api:8080; + proxy_set_header Host ${ZITADEL_DOMAIN}:${ports.zitadel}; + proxy_set_header X-Forwarded-Proto http; + proxy_set_header X-Forwarded-Host ${ZITADEL_DOMAIN}:${ports.zitadel}; + proxy_http_version 1.1; + } +}`; + const nginxConfDir = mkdtempSync(join(tmpdir(), 'zitadel-nginx-')); + writeFileSync(join(nginxConfDir, 'default.conf'), nginxConf); + + const proxy = await new GenericContainer('nginx:alpine') + .withNetwork(network) + .withNetworkAliases('zitadel-proxy') + .withExposedPorts({ container: 80, host: ports.zitadel }) + .withBindMounts([ + { source: join(nginxConfDir, 'default.conf'), target: '/etc/nginx/conf.d/default.conf', mode: 'ro' }, + ]) + .withWaitStrategy(Wait.forHttp('/.well-known/openid-configuration', 80) + .withHeaders({ Host: `${ZITADEL_DOMAIN}:${ports.zitadel}` }) + .forStatusCode(200)) + .withStartupTimeout(30_000) + .start(); + + state.zitadelProxyId = proxy.getId(); + state.zitadelProxyContainer = proxy; + console.log(` ✓ nginx proxy started: ${state.zitadelProxyId}`); + + const issuerURL = `http://${ZITADEL_DOMAIN}:${ports.zitadel}`; + console.log(` ✓ Zitadel issuer URL: ${issuerURL}`); + + // 6. Authenticate using the machine key (retry — file may not be written yet, API may need time) + console.log(' Authenticating with machine key...'); + const machineKeyFile = join(keyDir, 'machinekey.json'); + let adminToken; + for (let i = 0; i < 20; i++) { + try { + const machineKey = JSON.parse(readFileSync(machineKeyFile, 'utf-8')); + if (!machineKey.keyId || !machineKey.key || !machineKey.userId) { + throw new Error('Machine key file is incomplete'); + } + adminToken = await getAdminToken(issuerURL, machineKey); + break; + } catch (err) { + if (i === 19) throw new Error(`Failed to authenticate with machine key after 20 attempts: ${err.message}`); + if ((i + 1) % 5 === 0) console.log(` Waiting for machine key / token endpoint... (attempt ${i + 1}/20)`); + await new Promise(r => setTimeout(r, 2000)); + } + } + console.log(' ✓ Admin token obtained'); + + // 7. Get default org ID (retry - management API may need a few seconds after discovery is ready) + let orgID; + for (let i = 0; i < 30; i++) { + try { + orgID = await getOrgID(issuerURL, adminToken); + break; + } catch (err) { + if (i === 29) throw err; + if ((i + 1) % 5 === 0) console.log(` Waiting for Zitadel management API... (attempt ${i + 1}/30)`); + await new Promise(r => setTimeout(r, 2000)); + } + } + console.log(` ✓ Org ID: ${orgID}`); + + // 8. Create project with role assertion + const projectID = await createProject(issuerURL, adminToken); + console.log(` ✓ Project created: ${projectID}`); + + // 9. Create project roles + await createProjectRoles(issuerURL, adminToken, projectID, [ + 'platform-admins', 'developers', 'analysts', + ]); + console.log(' ✓ Project roles created'); + + // 10. Create OIDC app with all possible redirect URIs + const { clientID, clientSecret } = await createOIDCApp( + issuerURL, adminToken, projectID, + [ + `http://localhost:${ports.backend}/auth/callbacks/oidc`, + `http://127.0.0.1:${ports.backend}/auth/callbacks/oidc`, + `http://console-backend:3000/auth/callbacks/oidc`, + ] + ); + console.log(` ✓ OIDC app created: ${clientID}`); + + // 11. Create test users and assign roles + const testUsers = [ + { username: 'admin-user', email: 'admin@zitadel.test', roles: ['platform-admins'] }, + { username: 'editor-user', email: 'editor@zitadel.test', roles: ['developers'] }, + { username: 'viewer-user', email: 'viewer@zitadel.test', roles: ['analysts'] }, + { username: 'denied-user', email: 'denied@zitadel.test', roles: [] }, + { username: 'direct-admin', email: 'direct-admin@zitadel.test', roles: [] }, + ]; + + for (const user of testUsers) { + const userID = await createHumanUser(issuerURL, adminToken, user.username, user.email); + user.userID = userID; + if (user.roles.length > 0) { + await assignUserRoles(issuerURL, adminToken, userID, projectID, user.roles); + } + console.log(` ✓ User created: ${user.username} (${user.roles.join(', ') || 'no roles'})`); + } + + // 12. Disable MFA prompt in the login policy so tests don't get stuck on 2FA setup + await apiCall(issuerURL, 'PUT', '/management/v1/policies/login', adminToken, { + forceMfa: false, + forceMfaLocalOnly: false, + passwordlessType: 'PASSWORDLESS_TYPE_NOT_ALLOWED', + hidePasswordReset: false, + multiFactors: [], + secondFactors: [], + }).catch(err => { + console.warn(' WARNING: Failed to disable MFA in login policy. OIDC browser tests may hang on 2FA prompts.'); + console.warn(' Error:', err.message); + }); + + // 13. Create Zitadel action to inject flat "groups" claim + // The function name MUST match the action name for Zitadel to find it. + // Uses ctx.v1.user.grants.grants (the inner grants array) per Zitadel's API. + const actionScript = `function groupsClaim(ctx, api) { + if (ctx.v1.user.grants == undefined || ctx.v1.user.grants.count == 0) { + return; + } + var groups = []; + ctx.v1.user.grants.grants.forEach(function(grant) { + if (grant.roles) { + grant.roles.forEach(function(role) { + groups.push(role); + }); + } + }); + if (groups.length > 0) { + api.v1.claims.setClaim('groups', groups); + } +}`; + const actionID = await createAction(issuerURL, adminToken, 'groupsClaim', actionScript); + await setActionTrigger(issuerURL, adminToken, actionID); + console.log(' ✓ Groups claim action configured'); + + const result = { + issuerURL, + clientID, + clientSecret, + projectID, + users: testUsers, + adminToken, + consoleRedirectURL: `http://localhost:${ports.backend}/auth/callbacks/oidc`, + }; + + console.log('✓ Zitadel OIDC provider ready'); + return result; +} + +/** + * Rewrite the Console config YAML with actual Zitadel values. + */ +export function rewriteConsoleConfig(configPath, zitadelConfig) { + let config = readFileSync(configPath, 'utf-8'); + config = config.replace('__ZITADEL_ISSUER_URL__', zitadelConfig.issuerURL); + config = config.replace('__ZITADEL_CLIENT_ID__', zitadelConfig.clientID); + config = config.replace('__ZITADEL_CLIENT_SECRET__', zitadelConfig.clientSecret); + config = config.replace('__CONSOLE_REDIRECT_URL__', zitadelConfig.consoleRedirectURL || ''); + + // Write to a temp file so we don't modify the source + const tempDir = mkdtempSync(join(tmpdir(), 'console-config-')); + const tempConfig = join(tempDir, 'console.config.yaml'); + writeFileSync(tempConfig, config); + return tempConfig; +} + +// --- Zitadel API helpers --- + +async function apiCall(baseURL, method, path, token, body) { + const headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const resp = await fetch(`${baseURL}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + const text = await resp.text(); + if (!resp.ok) { + throw new Error(`Zitadel API ${method} ${path} returned ${resp.status}: ${text}`); + } + return text ? JSON.parse(text) : null; +} + +async function getAdminToken(issuerURL, machineKey) { + // Parse PEM private key + const privateKey = crypto.createPrivateKey(machineKey.key); + + // Create JWT assertion + const now = Math.floor(Date.now() / 1000); + const header = { alg: 'RS256', kid: machineKey.keyId }; + const payload = { + iss: machineKey.userId, + sub: machineKey.userId, + aud: issuerURL, + iat: now, + exp: now + 3600, + }; + + const encHeader = Buffer.from(JSON.stringify(header)).toString('base64url'); + const encPayload = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signingInput = `${encHeader}.${encPayload}`; + const signature = crypto.sign('sha256', Buffer.from(signingInput), privateKey); + const assertion = `${signingInput}.${signature.toString('base64url')}`; + + // Exchange for access token + const resp = await fetch(`${issuerURL}/oauth/v2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + scope: 'openid urn:zitadel:iam:org:project:id:zitadel:aud', + assertion, + }), + }); + + const text = await resp.text(); + if (!resp.ok) { + throw new Error(`Token exchange failed: ${text}`); + } + const parsed = JSON.parse(text); + if (!parsed.access_token) { + throw new Error(`Token endpoint returned no access_token: ${text}`); + } + return parsed.access_token; +} + +async function getOrgID(issuerURL, token) { + const resp = await apiCall(issuerURL, 'GET', '/management/v1/orgs/me', token); + return resp.org.id; +} + +async function createProject(issuerURL, token) { + const resp = await apiCall(issuerURL, 'POST', '/management/v1/projects', token, { + name: 'e2e-test-project', + projectRoleAssertion: true, + projectRoleCheck: true, + hasProjectCheck: false, + privateLabelingSetting: 'PRIVATE_LABELING_SETTING_UNSPECIFIED', + }); + return resp.id; +} + +async function createProjectRoles(issuerURL, token, projectID, roles) { + const bulkRoles = roles.map(r => ({ key: r, displayName: r })); + await apiCall(issuerURL, 'POST', `/management/v1/projects/${projectID}/roles/_bulk`, token, { + roles: bulkRoles, + }); +} + +async function createOIDCApp(issuerURL, token, projectID, redirectURIs) { + const resp = await apiCall( + issuerURL, 'POST', + `/management/v1/projects/${projectID}/apps/oidc`, + token, + { + name: 'console-e2e', + redirectUris: redirectURIs, + responseTypes: ['OIDC_RESPONSE_TYPE_CODE'], + grantTypes: ['OIDC_GRANT_TYPE_AUTHORIZATION_CODE'], + appType: 'OIDC_APP_TYPE_WEB', + authMethodType: 'OIDC_AUTH_METHOD_TYPE_BASIC', + devMode: true, + accessTokenType: 'OIDC_TOKEN_TYPE_JWT', + idTokenRoleAssertion: true, + idTokenUserinfoAssertion: true, + } + ); + return { clientID: resp.clientId, clientSecret: resp.clientSecret }; +} + +async function createHumanUser(issuerURL, token, username, email) { + const resp = await apiCall(issuerURL, 'POST', '/v2/users/human', token, { + username, + profile: { + givenName: username, + familyName: 'Test', + displayName: username, + }, + email: { + email, + isVerified: true, + }, + password: { + password: TEST_PASSWORD, + changeRequired: false, + }, + }); + return resp.userId; +} + +async function assignUserRoles(issuerURL, token, userID, projectID, roles) { + await apiCall(issuerURL, 'POST', `/management/v1/users/${userID}/grants`, token, { + projectId: projectID, + roleKeys: roles, + }); +} + +async function createAction(issuerURL, token, name, script) { + const resp = await apiCall(issuerURL, 'POST', '/management/v1/actions', token, { + name, + script, + timeout: '10s', + allowedToFail: false, + }); + return resp.id; +} + +async function setActionTrigger(issuerURL, token, actionID) { + // Flow type 2 = "Complement Token", Trigger type 5 = "Pre Access Token Creation" + await apiCall(issuerURL, 'POST', '/management/v1/flows/2/trigger/5', token, { + actionIds: [actionID], + }); +} + +export { TEST_PASSWORD }; diff --git a/frontend/tests/test-variant-console-enterprise-oidc/admin-access.spec.ts b/frontend/tests/test-variant-console-enterprise-oidc/admin-access.spec.ts new file mode 100644 index 0000000000..d3f8a4586b --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise-oidc/admin-access.spec.ts @@ -0,0 +1,38 @@ +/** + * Tests that the admin user (platform-admins group via GBAC) has full access: + * - Can view topics + * - Can create topics + * - Can delete topics + * - Can access security/admin pages + */ +import { expect, test } from '@playwright/test'; + +test.describe('Admin GBAC access (platform-admins group)', () => { + test('can view topic list page', async ({ page }) => { + await page.goto('/topics'); + // Admin should see the topics page with the "Create topic" button + await expect(page.getByTestId('create-topic-button')).toBeVisible({ timeout: 15_000 }); + }); + + test('admin user identity is shown correctly', async ({ page }) => { + await page.goto('/topics'); + // Verify the admin user identity is displayed in the sidebar + await expect(page.getByText('admin-user')).toBeVisible({ timeout: 15_000 }); + }); + + test('create topic button is accessible to admin', async ({ page }) => { + await page.goto('/topics'); + // Admin should see the create topic button and be able to click it + const createButton = page.getByTestId('create-topic-button'); + await expect(createButton).toBeVisible({ timeout: 15_000 }); + await createButton.click(); + // The create topic modal should open + await expect(page.getByTestId('topic-name')).toBeVisible({ timeout: 5_000 }); + }); + + test('can access overview page', async ({ page }) => { + await page.goto('/overview'); + // Overview shows cluster info - look for the overview heading or cluster section + await expect(page.getByText('BROKER DETAILS')).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/frontend/tests/test-variant-console-enterprise-oidc/auth-admin.setup.ts b/frontend/tests/test-variant-console-enterprise-oidc/auth-admin.setup.ts new file mode 100644 index 0000000000..ddc9a64dd2 --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise-oidc/auth-admin.setup.ts @@ -0,0 +1,9 @@ +import { test as setup } from '@playwright/test'; +import { loginViaOIDC } from './fixtures'; + +const authFile = 'playwright/.auth/admin-user.json'; + +setup('authenticate admin via OIDC', async ({ page }) => { + await loginViaOIDC(page, 'admin-user'); + await page.context().storageState({ path: authFile }); +}); diff --git a/frontend/tests/test-variant-console-enterprise-oidc/auth-viewer.setup.ts b/frontend/tests/test-variant-console-enterprise-oidc/auth-viewer.setup.ts new file mode 100644 index 0000000000..0a6729a733 --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise-oidc/auth-viewer.setup.ts @@ -0,0 +1,9 @@ +import { test as setup } from '@playwright/test'; +import { loginViaOIDC } from './fixtures'; + +const authFile = 'playwright/.auth/viewer-user.json'; + +setup('authenticate viewer via OIDC', async ({ page }) => { + await loginViaOIDC(page, 'viewer-user'); + await page.context().storageState({ path: authFile }); +}); diff --git a/frontend/tests/test-variant-console-enterprise-oidc/config/console.config.yaml b/frontend/tests/test-variant-console-enterprise-oidc/config/console.config.yaml new file mode 100644 index 0000000000..bb4e57a4da --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise-oidc/config/console.config.yaml @@ -0,0 +1,52 @@ +serveFrontend: true + +kafka: + brokers: ["redpanda:9092"] + sasl: + enabled: true + mechanism: SCRAM-SHA-256 + username: e2euser + password: very-secret + +authentication: + jwtSigningKey: vazxnT+ZHtxKslK6QlDGovcYnSjTk/lKMmZ+mHrBVE+YdVDkLgSuP6AszAKe9999 + useSecureCookies: false + oidc: + enabled: true + issuerUrl: "__ZITADEL_ISSUER_URL__" + clientId: "__ZITADEL_CLIENT_ID__" + clientSecret: "__ZITADEL_CLIENT_SECRET__" + +redpanda: + adminApi: + enabled: true + urls: ["http://redpanda:9644"] + +schemaRegistry: + enabled: true + urls: ["http://redpanda:8081"] + +server: + listenPort: 3000 + allowedOrigins: ["http://localhost:3000", "http://localhost:3200"] + +licenseFilepath: /etc/console/redpanda.license + +authorization: + # GroupBindings: map OIDC group claims to Console roles + groupBindings: + - roleName: admin + groups: + - "platform-admins" + - roleName: editor + groups: + - "developers" + - roleName: viewer + groups: + - "analysts" + # RoleBindings: direct user-to-role mappings (tested alongside groupBindings) + roleBindings: + - roleName: admin + users: + - loginType: OIDC + name: direct-admin diff --git a/frontend/tests/test-variant-console-enterprise-oidc/config/variant.json b/frontend/tests/test-variant-console-enterprise-oidc/config/variant.json new file mode 100644 index 0000000000..9c70fef1b8 --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise-oidc/config/variant.json @@ -0,0 +1,18 @@ +{ + "name": "console-enterprise-oidc", + "displayName": "Console Enterprise OIDC (Zitadel)", + "isEnterprise": true, + "needsShadowlink": false, + "needsAuth": true, + "needsZitadel": true, + "requiresLicense": true, + "ports": { + "backend": 3200, + "redpandaKafka": 19292, + "redpandaSchemaRegistry": 18281, + "redpandaAdmin": 19844, + "redpandaPandaproxy": 18282, + "kafkaConnect": 18283, + "zitadel": 8085 + } +} diff --git a/frontend/tests/test-variant-console-enterprise-oidc/denied-access.spec.ts b/frontend/tests/test-variant-console-enterprise-oidc/denied-access.spec.ts new file mode 100644 index 0000000000..833d4985c2 --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise-oidc/denied-access.spec.ts @@ -0,0 +1,83 @@ +/** + * Tests that a user with no matching group binding is denied access. + * + * Unlike admin/viewer tests, this does NOT use a pre-authenticated storage + * state. It performs a fresh OIDC login with a user whose groups don't + * match any groupBinding, then verifies access is denied. + */ +import { expect, test } from '@playwright/test'; +import { TEST_PASSWORD } from '../shared/zitadel-setup.mjs'; + +test.describe('Denied access (no matching group binding)', () => { + test('user with unmatched groups is denied after OIDC login', async ({ page }) => { + // Navigate to Console login page + await page.goto('/', { waitUntil: 'networkidle' }); + + // Click OIDC login + const oidcButton = page.getByRole('link', { name: /log in with oidc/i }); + await oidcButton.waitFor({ state: 'visible', timeout: 30_000 }); + await oidcButton.click(); + + // Login as denied-user (no roles assigned, no matching groupBinding) + await page.waitForURL(url => url.toString().includes('/ui/login/') || url.toString().includes('/ui/v2/login/'), { timeout: 30_000 }); + + const loginNameInput = page.getByLabel(/loginname/i).first(); + await loginNameInput.waitFor({ state: 'visible', timeout: 15_000 }); + await loginNameInput.fill('denied-user'); + await page.getByRole('button', { name: /continue|next/i }).click(); + + const passwordInput = page.getByLabel(/password/i); + await passwordInput.waitFor({ state: 'visible', timeout: 15_000 }); + await passwordInput.fill(TEST_PASSWORD); + await page.getByRole('button', { name: /continue|next/i }).click(); + + // Handle any intermediate Zitadel screens (MFA setup, consent, etc.) + await page.waitForTimeout(1_000); + + // Skip 2-Factor Setup if prompted + const skipButton = page.getByRole('button', { name: /skip/i }); + if (await skipButton.isVisible({ timeout: 5_000 }).catch(() => false)) { + await skipButton.click(); + } + + // After OIDC callback, Console should either: + // 1. Redirect to /login with an error (token_exchange_failed, etc.) + // 2. Show permission denied on any page the user tries to access + // Wait for the redirect back to Console + await page.waitForURL('**/*', { timeout: 30_000 }); + + // Try navigating to topics - should get permission denied + await page.goto('/topics'); + await page.waitForLoadState('networkidle'); + + // The user should see either: + // - An error/unauthorized message + // - Be redirected to the login page + // - See an empty state with permission denied + const currentURL = page.url(); + const hasError = + currentURL.includes('error_code') || + currentURL.includes('login') || + (await page.getByText(/permission denied|unauthorized|not authorized|forbidden/i) + .isVisible({ timeout: 5_000 }) + .catch(() => false)); + + expect(hasError).toBeTruthy(); + }); + + test('unauthenticated user cannot access topics', async ({ page }) => { + // Directly navigate to topics without any authentication + const response = await page.goto('/topics'); + await page.waitForLoadState('networkidle'); + + // Should be redirected to login page or show login form + const currentURL = page.url(); + const isOnLoginPage = + currentURL.includes('login') || + (await page.getByRole('link', { name: /log in with oidc/i }) + .isVisible({ timeout: 5_000 }) + .catch(() => false)); + + expect(isOnLoginPage).toBeTruthy(); + }); +}); diff --git a/frontend/tests/test-variant-console-enterprise-oidc/fixtures.ts b/frontend/tests/test-variant-console-enterprise-oidc/fixtures.ts new file mode 100644 index 0000000000..d0470124fb --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise-oidc/fixtures.ts @@ -0,0 +1,50 @@ +import { expect, type Page } from '@playwright/test'; +import { TEST_PASSWORD } from '../shared/zitadel-setup.mjs'; + +/** + * Perform an OIDC login flow through Zitadel for the given user. + * + * Navigates to Console, clicks the OIDC login button, enters credentials + * in Zitadel's login page, handles any intermediate screens (MFA skip), + * and waits for the redirect back to Console's overview page. + */ +export async function loginViaOIDC(page: Page, username: string): Promise { + await page.goto('/', { waitUntil: 'networkidle' }); + + // Click the OIDC login button - this redirects to Zitadel + const oidcButton = page.getByRole('link', { name: /log in with oidc/i }); + await oidcButton.waitFor({ state: 'visible', timeout: 30_000 }); + await oidcButton.click(); + + // Wait for Zitadel's login page to load + await page.waitForURL( + (url) => url.toString().includes('/ui/login/') || url.toString().includes('/ui/v2/login/'), + { timeout: 30_000 }, + ); + + // Enter loginname + const loginNameInput = page.getByLabel(/loginname/i).first(); + await loginNameInput.waitFor({ state: 'visible', timeout: 15_000 }); + await loginNameInput.fill(username); + await page.getByRole('button', { name: /continue|next/i }).click(); + + // Enter password + const passwordInput = page.getByLabel(/password/i); + await passwordInput.waitFor({ state: 'visible', timeout: 15_000 }); + await passwordInput.fill(TEST_PASSWORD); + await page.getByRole('button', { name: /continue|next/i }).click(); + + // Handle any intermediate Zitadel screens (MFA setup, consent, etc.) + await page.waitForTimeout(1_000); + + // Skip 2-Factor Setup if prompted + const skipButton = page.getByRole('button', { name: /skip/i }); + if (await skipButton.isVisible({ timeout: 5_000 }).catch(() => false)) { + await skipButton.click(); + } + + // After login completes, Zitadel redirects back to Console's callback URL, + // which then redirects to /overview. + await page.waitForURL('**/overview**', { timeout: 30_000 }); + await expect(page.getByTestId('versionTitle')).toBeVisible({ timeout: 30_000 }); +} diff --git a/frontend/tests/test-variant-console-enterprise-oidc/playwright.config.ts b/frontend/tests/test-variant-console-enterprise-oidc/playwright.config.ts new file mode 100644 index 0000000000..95d3cfe931 --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise-oidc/playwright.config.ts @@ -0,0 +1,92 @@ +import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const reporters = process.env.CI + ? [['github' as const], ['html' as const, { outputFolder: 'playwright-report' }]] + : [['list' as const], ['html' as const, { outputFolder: 'playwright-report' }]]; + +/** + * Playwright Test configuration for Enterprise OIDC (Zitadel) variant. + * + * This variant tests OIDC login flows and Group-Based Access Control (GBAC) + * using a real Zitadel identity provider running in a testcontainer. + */ +const config = defineConfig({ + timeout: 120 * 1000, + + expect: { + timeout: 60 * 1000, + }, + + testMatch: '**/*.spec.ts', + fullyParallel: false, // Sequential: auth tests depend on login state + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, // Single worker: OIDC tests share session state + + reporter: reporters, + + globalSetup: '../shared/global-setup.mjs', + globalTeardown: '../shared/global-teardown.mjs', + + metadata: { + variant: 'console-enterprise-oidc', + variantName: 'console-enterprise-oidc', + configFile: 'console.config.yaml', + isEnterprise: true, + needsShadowlink: false, + needsZitadel: true, + }, + + use: { + navigationTimeout: 30 * 1000, + actionTimeout: 30 * 1000, + viewport: { width: 1920, height: 1080 }, + headless: !!process.env.CI, + baseURL: process.env.REACT_APP_ORIGIN ?? 'http://localhost:3200', + trace: 'on-first-retry', + screenshot: 'on', + video: 'off', + }, + + projects: [ + { + name: 'oidc-admin-login', + testMatch: '**/auth-admin.setup.ts', + }, + { + name: 'oidc-viewer-login', + testMatch: '**/auth-viewer.setup.ts', + }, + { + name: 'admin-tests', + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/admin-user.json', + }, + testMatch: '**/admin-access.spec.ts', + dependencies: ['oidc-admin-login'], + }, + { + name: 'viewer-tests', + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/viewer-user.json', + }, + testMatch: '**/viewer-access.spec.ts', + dependencies: ['oidc-viewer-login'], + }, + { + name: 'denied-tests', + use: { + ...devices['Desktop Chrome'], + }, + testMatch: '**/denied-access.spec.ts', + dependencies: ['oidc-admin-login'], + }, + ], +}); + +export default config; diff --git a/frontend/tests/test-variant-console-enterprise-oidc/viewer-access.spec.ts b/frontend/tests/test-variant-console-enterprise-oidc/viewer-access.spec.ts new file mode 100644 index 0000000000..dad47c65cf --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise-oidc/viewer-access.spec.ts @@ -0,0 +1,28 @@ +/** + * Tests that the viewer user (analysts group via GBAC) has read-only access: + * - Can view topics + * - Cannot create topics (button hidden or action denied) + * - Cannot delete topics + */ +import { expect, test } from '@playwright/test'; + +test.describe('Viewer GBAC access (analysts group)', () => { + test('can view topic list page', async ({ page }) => { + await page.goto('/topics'); + // Viewer should see the topics page heading + await expect(page.getByRole('heading', { name: /topics/i })).toBeVisible({ timeout: 15_000 }); + }); + + test('viewer user is logged in with correct identity', async ({ page }) => { + await page.goto('/topics'); + await page.waitForLoadState('networkidle'); + + // Verify the viewer user identity is shown in the sidebar + await expect(page.getByText('viewer-user')).toBeVisible({ timeout: 15_000 }); + }); + + test('can access overview page', async ({ page }) => { + await page.goto('/overview'); + await expect(page.getByText('BROKER DETAILS')).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/frontend/tests/test-variant-console-enterprise/users.spec.ts b/frontend/tests/test-variant-console-enterprise/users.spec.ts index dda7e77962..bb09b51d9d 100644 --- a/frontend/tests/test-variant-console-enterprise/users.spec.ts +++ b/frontend/tests/test-variant-console-enterprise/users.spec.ts @@ -30,6 +30,8 @@ test.describe('Users', () => { waitUntil: 'domcontentloaded', }); await page.getByPlaceholder('Filter by name').fill(`user-${r}-regexp-[1,2]`); + // Wait for nuqs to push the filter into the URL (TanStack Router navigate is async) + await page.waitForURL(/[?&]q=/); await expect( page.getByTestId('data-table-cell').locator(`a[href='/security/users/${userName1}/details']`) diff --git a/frontend/tests/test-variant-console/utils/debug-bundle-page.ts b/frontend/tests/test-variant-console/utils/debug-bundle-page.ts index e79dc28f94..6c53f7f282 100644 --- a/frontend/tests/test-variant-console/utils/debug-bundle-page.ts +++ b/frontend/tests/test-variant-console/utils/debug-bundle-page.ts @@ -35,8 +35,8 @@ export class DebugBundlePage { // Wait for either "Done" or "Try Again" button to appear (bundle completed or failed) await Promise.race([ - this.page.getByTestId('debug-bundle-done-button').waitFor({ state: 'visible', timeout: 60_000 }), - this.page.getByTestId('debug-bundle-try-again-button').waitFor({ state: 'visible', timeout: 60_000 }), + this.page.getByTestId('debug-bundle-done-button').waitFor({ state: 'visible', timeout: 90_000 }), + this.page.getByTestId('debug-bundle-try-again-button').waitFor({ state: 'visible', timeout: 90_000 }), ]); // Click whichever button is available to get back to form