/** * Global Setup - Runs once before all tests * * This setup ensures a clean test environment by: * 1. Cleaning up any orphaned test data from previous runs * 2. Verifying the application is accessible * 3. Performing emergency ACL reset to prevent deadlock from previous failed runs * 4. Health-checking emergency server (tier 2) and admin endpoint */ import { request, APIRequestContext } from '@playwright/test'; import { existsSync } from 'fs'; import { TestDataManager } from './utils/TestDataManager'; import { STORAGE_STATE } from './constants'; // Singleton to prevent duplicate validation across workers let tokenValidated = false; /** * Validate emergency token is properly configured for E2E tests * This is a fail-fast check to prevent cascading test failures */ function validateEmergencyToken(): void { if (tokenValidated) { console.log(' โœ… Emergency token already validated (singleton)'); return; } const token = process.env.CHARON_EMERGENCY_TOKEN; const errors: string[] = []; // Check 1: Token exists if (!token) { errors.push( 'โŒ CHARON_EMERGENCY_TOKEN is not set.\n' + ' Generate with: openssl rand -hex 32\n' + ' Add to .env file or set as environment variable' ); } else { // Mask token for logging (show first 8 chars only) const maskedToken = token.slice(0, 8) + '...' + token.slice(-4); console.log(` ๐Ÿ”‘ Token present: ${maskedToken}`); // Check 2: Token length (must be at least 64 chars) if (token.length < 64) { errors.push( `โŒ CHARON_EMERGENCY_TOKEN is too short (${token.length} chars, minimum 64).\n` + ' Generate a new one with: openssl rand -hex 32' ); } else { console.log(` โœ“ Token length: ${token.length} chars (valid)`); } // Check 3: Token is hex format (a-f0-9) const hexPattern = /^[a-f0-9]+$/i; if (!hexPattern.test(token)) { errors.push( 'โŒ CHARON_EMERGENCY_TOKEN must be hexadecimal (0-9, a-f).\n' + ' Generate with: openssl rand -hex 32' ); } else { console.log(' โœ“ Token format: Valid hexadecimal'); } // Check 4: Token entropy (avoid placeholder values) const commonPlaceholders = [ 'test-emergency-token', 'your_64_character', 'replace_this', '0000000000000000', 'ffffffffffffffff', ]; const isPlaceholder = commonPlaceholders.some(ph => token.toLowerCase().includes(ph)); if (isPlaceholder) { errors.push( 'โŒ CHARON_EMERGENCY_TOKEN appears to be a placeholder value.\n' + ' Generate a unique token with: openssl rand -hex 32' ); } else { console.log(' โœ“ Token appears to be unique (not a placeholder)'); } } // Fail fast if validation errors found if (errors.length > 0) { console.error('\n๐Ÿšจ Emergency Token Configuration Errors:\n'); errors.forEach(error => console.error(error + '\n')); console.error('๐Ÿ“– See .env.example and docs/getting-started.md for setup instructions.\n'); process.exit(1); } console.log('โœ… Emergency token validation passed\n'); tokenValidated = true; } /** * Get the base URL for the application */ function getBaseURL(): string { return process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080'; } /** * Check if Caddy admin API is enabled and healthy (port 2019 - read-only config inspection) */ async function checkCaddyAdminHealth(): Promise { const caddyAdminHost = process.env.CADDY_ADMIN_HOST || 'http://localhost:2019'; const startTime = Date.now(); console.log(`๐Ÿ” Checking Caddy admin API health at ${caddyAdminHost}...`); const caddyContext = await request.newContext({ baseURL: caddyAdminHost }); try { const response = await caddyContext.get('/config', { timeout: 3000 }); const elapsed = Date.now() - startTime; if (response.ok()) { console.log(` โœ… Caddy admin API (port 2019) is healthy [${elapsed}ms]`); return true; } else { console.log(` โš ๏ธ Caddy admin API returned: ${response.status()} [${elapsed}ms]`); return false; } } catch (e) { const elapsed = Date.now() - startTime; console.log(` โญ๏ธ Caddy admin API unavailable (non-blocking) [${elapsed}ms]`); return false; } finally { await caddyContext.dispose(); } } /** * Wait for container to be ready before running global setup. * This prevents 401 errors when global-setup runs before containers finish starting. */ async function waitForContainer(maxRetries = 15, delayMs = 2000): Promise { const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080'; console.log(`โณ Waiting for container to be ready at ${baseURL}...`); for (let i = 0; i < maxRetries; i++) { try { const context = await request.newContext({ baseURL }); const response = await context.get('/api/v1/health', { timeout: 3000 }); await context.dispose(); if (response.ok()) { console.log(` โœ… Container ready after ${i + 1} attempt(s) [${(i + 1) * delayMs}ms]`); return; } } catch (error) { console.log(` โณ Waiting for container... (${i + 1}/${maxRetries})`); if (i < maxRetries - 1) { await new Promise(resolve => setTimeout(resolve, delayMs)); } } } throw new Error(`Container failed to start after ${maxRetries * delayMs}ms`); } /** * Check if emergency tier-2 server is enabled and healthy (port 2020 - break-glass with auth) */ async function checkEmergencyServerHealth(): Promise { const emergencyHost = process.env.EMERGENCY_SERVER_HOST || 'http://localhost:2020'; const startTime = Date.now(); console.log(`๐Ÿ” Checking emergency tier-2 server health at ${emergencyHost}...`); const emergencyContext = await request.newContext({ baseURL: emergencyHost }); try { const response = await emergencyContext.get('/health', { timeout: 3000 }); const elapsed = Date.now() - startTime; if (response.ok()) { console.log(` โœ… Emergency tier-2 server (port 2020) is healthy [${elapsed}ms]`); return true; } else { console.log(` โš ๏ธ Emergency tier-2 server returned: ${response.status()} [${elapsed}ms]`); return false; } } catch (e) { const elapsed = Date.now() - startTime; console.log(` โญ๏ธ Emergency tier-2 server unavailable (tests will skip tier-2 features) [${elapsed}ms]`); return false; } finally { await emergencyContext.dispose(); } } async function globalSetup(): Promise { console.log('\n๐Ÿงน Running global test setup...\n'); const setupStartTime = Date.now(); // CRITICAL: Validate emergency token before proceeding console.log('๐Ÿ” Validating emergency token configuration...'); validateEmergencyToken(); const baseURL = getBaseURL(); console.log(`๐Ÿ“ Base URL: ${baseURL}`); // CRITICAL: Wait for container to be ready before proceeding // This prevents 401 errors when containers are still starting up await waitForContainer(); // Log URL analysis for IPv4 vs IPv6 debugging try { const parsedURL = new URL(baseURL); const isIPv6 = parsedURL.hostname.includes(':') || parsedURL.hostname.startsWith('['); const isLocalhost = parsedURL.hostname === 'localhost'; const port = parsedURL.port || (parsedURL.protocol === 'https:' ? '443' : '80'); console.log(` โ””โ”€ Hostname: ${parsedURL.hostname}`); console.log(` โ”œโ”€ Port: ${port}`); console.log(` โ”œโ”€ Protocol: ${parsedURL.protocol}`); console.log(` โ”œโ”€ IPv6: ${isIPv6 ? 'Yes' : 'No'}`); console.log(` โ””โ”€ Localhost: ${isLocalhost ? 'Yes' : 'No'}\n`); } catch (e) { console.log(' โš ๏ธ Could not parse base URL\n'); } // Health-check Caddy admin and emergency tier-2 servers (non-blocking) console.log('๐Ÿ“Š Port Connectivity Checks:'); const caddyHealthy = await checkCaddyAdminHealth(); const emergencyHealthy = await checkEmergencyServerHealth(); console.log( `\nโœ… Connectivity Summary: Caddy=${caddyHealthy ? 'โœ“' : 'โœ—'} Emergency=${emergencyHealthy ? 'โœ“' : 'โœ—'}\n` ); // Pre-auth security reset attempt (crash protection failsafe) // This attempts to disable security modules BEFORE auth, in case a previous run crashed // with security enabled blocking the auth endpoint. // SKIPPED in CI when CHARON_EMERGENCY_TOKEN is not set - fresh containers don't need reset if (process.env.CHARON_EMERGENCY_TOKEN && process.env.CHARON_EMERGENCY_TOKEN !== 'test-emergency-token-for-e2e-32chars') { const preAuthContext = await request.newContext({ baseURL }); try { await emergencySecurityReset(preAuthContext); } catch (e) { console.log('โญ๏ธ Pre-auth security reset skipped (may require auth)'); } await preAuthContext.dispose(); } else { console.log('โญ๏ธ Pre-auth security reset skipped (fresh container, no custom token)'); } // Create a request context const requestContext = await request.newContext({ baseURL, extraHTTPHeaders: { Accept: 'application/json', 'Content-Type': 'application/json', }, }); try { // Verify the application is accessible console.log('๐Ÿ” Checking application health...'); const healthResponse = await requestContext.get('/api/v1/health', { timeout: 10000, }).catch(() => null); if (!healthResponse || !healthResponse.ok()) { console.warn('โš ๏ธ Health check failed - application may not be ready'); // Try the base URL as fallback const baseResponse = await requestContext.get('/').catch(() => null); if (!baseResponse || !baseResponse.ok()) { console.error('โŒ Application is not accessible at', baseURL); throw new Error(`Application not accessible at ${baseURL}`); } } console.log('โœ… Application is accessible'); // Clean up orphaned test data from previous runs console.log('๐Ÿ—‘๏ธ Cleaning up orphaned test data...'); const cleanupResults = await TestDataManager.forceCleanupAll(requestContext); if ( cleanupResults.proxyHosts > 0 || cleanupResults.accessLists > 0 || cleanupResults.dnsProviders > 0 || cleanupResults.certificates > 0 ) { console.log(' Cleaned up:'); if (cleanupResults.proxyHosts > 0) { console.log(` - ${cleanupResults.proxyHosts} proxy hosts`); } if (cleanupResults.accessLists > 0) { console.log(` - ${cleanupResults.accessLists} access lists`); } if (cleanupResults.dnsProviders > 0) { console.log(` - ${cleanupResults.dnsProviders} DNS providers`); } if (cleanupResults.certificates > 0) { console.log(` - ${cleanupResults.certificates} certificates`); } } else { console.log(' No orphaned test data found'); } console.log('โœ… Global setup complete\n'); } catch (error) { console.error('โŒ Global setup failed:', error); throw error; } finally { await requestContext.dispose(); } // Emergency security reset with auth (more complete) if (existsSync(STORAGE_STATE)) { const authenticatedContext = await request.newContext({ baseURL, storageState: STORAGE_STATE, }); try { await emergencySecurityReset(authenticatedContext); console.log('โœ“ Authenticated security reset complete'); // Deterministic ACL disable verification await verifySecurityDisabled(authenticatedContext); } catch (error) { console.warn('โš ๏ธ Authenticated security reset failed:', error); } await authenticatedContext.dispose(); } else { console.log('โญ๏ธ Skipping authenticated security reset (no auth state file)'); } } /** * Verify that security modules (ACL, rate limiting) are disabled. * Retries once if still enabled, then fails fast with actionable error. */ async function verifySecurityDisabled(requestContext: APIRequestContext): Promise { console.log('๐Ÿ”’ Verifying security modules are disabled...'); for (let attempt = 1; attempt <= 2; attempt++) { try { const configResponse = await requestContext.get('/api/v1/security/config', { timeout: 3000 }); if (!configResponse.ok()) { console.warn(` โš ๏ธ Could not fetch security config (${configResponse.status()})`); return; // Endpoint might not exist, continue } const config = await configResponse.json(); const aclEnabled = config.acl?.enabled === true; const rateLimitEnabled = config.rateLimit?.enabled === true; if (!aclEnabled && !rateLimitEnabled) { console.log(' โœ… Security modules confirmed disabled'); return; } console.warn(` โš ๏ธ Attempt ${attempt}: ACL=${aclEnabled} RateLimit=${rateLimitEnabled}`); if (attempt === 1) { // Retry emergency reset console.log(' ๐Ÿ”„ Retrying emergency security reset...'); await emergencySecurityReset(requestContext); await new Promise(resolve => setTimeout(resolve, 1000)); } else { // Fail fast with actionable error throw new Error( `\nโŒ SECURITY MODULES STILL ENABLED AFTER RESET\n` + ` ACL: ${aclEnabled}, Rate Limiting: ${rateLimitEnabled}\n` + ` This will cause test failures. Check:\n` + ` 1. Emergency token is correct (CHARON_EMERGENCY_TOKEN)\n` + ` 2. Emergency endpoint is working (/api/v1/emergency/security-reset)\n` + ` 3. Settings service is applying changes correctly\n` ); } } catch (error) { if (attempt === 2) { throw error; } } } } /** * Perform emergency security reset to disable ALL security modules. * This prevents deadlock if a previous test run left any security module enabled. * * USES THE CORRECT ENDPOINT: /emergency/security-reset (on port 2020) * This endpoint bypasses all security checks when a valid emergency token is provided. */ async function emergencySecurityReset(requestContext: APIRequestContext): Promise { const startTime = Date.now(); console.log('๐Ÿ”“ Performing emergency security reset...'); const emergencyToken = process.env.CHARON_EMERGENCY_TOKEN; const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080'; if (!emergencyToken) { console.warn(' โš ๏ธ CHARON_EMERGENCY_TOKEN not set, skipping emergency reset'); return; } // Debug logging to troubleshoot 401 errors const maskedToken = emergencyToken.slice(0, 8) + '...' + emergencyToken.slice(-4); console.log(` ๐Ÿ”‘ Token configured: ${maskedToken} (${emergencyToken.length} chars)`); try { // Create new context for emergency server on port 2020 with basic auth const emergencyURL = baseURL.replace(':8080', ':2020'); console.log(` ๐Ÿ“ Emergency URL: ${emergencyURL}/emergency/security-reset`); const emergencyContext = await request.newContext({ baseURL: emergencyURL, httpCredentials: { username: process.env.CHARON_EMERGENCY_USERNAME || 'admin', password: process.env.CHARON_EMERGENCY_PASSWORD || 'changeme', }, }); // Use the CORRECT endpoint: /emergency/security-reset // This endpoint bypasses ACL, WAF, and all security checks const response = await emergencyContext.post('/emergency/security-reset', { headers: { 'X-Emergency-Token': emergencyToken, 'Content-Type': 'application/json', }, data: { reason: 'Global setup - reset all modules for clean test state' }, timeout: 5000, // 5s timeout to prevent hanging }); const elapsed = Date.now() - startTime; console.log(` ๐Ÿ“Š Emergency reset status: ${response.status()} [${elapsed}ms]`); if (!response.ok()) { const body = await response.text(); console.error(` โŒ Emergency reset failed: ${response.status()}`); console.error(` ๐Ÿ“„ Response body: ${body}`); throw new Error(`Emergency reset returned ${response.status()}: ${body}`); } const result = await response.json(); console.log(` โœ… Emergency reset successful [${elapsed}ms]`); if (result.disabled_modules && Array.isArray(result.disabled_modules)) { console.log(` โœ“ Disabled modules: ${result.disabled_modules.join(', ')}`); } await emergencyContext.dispose(); // Reduced wait time - fresh containers don't need long propagation console.log(' โณ Waiting for security reset to propagate...'); await new Promise(resolve => setTimeout(resolve, 500)); } catch (e) { const elapsed = Date.now() - startTime; console.error(` โŒ Emergency reset error: ${e instanceof Error ? e.message : String(e)} [${elapsed}ms]`); throw e; } const totalTime = Date.now() - startTime; console.log(` โœ… Security reset complete [${totalTime}ms]`); } export default globalSetup;