diff --git a/frontend/src/components/__tests__/AccessListSelector.test.tsx b/frontend/src/components/__tests__/AccessListSelector.test.tsx index 4ba93d3d..90a69963 100644 --- a/frontend/src/components/__tests__/AccessListSelector.test.tsx +++ b/frontend/src/components/__tests__/AccessListSelector.test.tsx @@ -126,4 +126,41 @@ describe('AccessListSelector', () => { expect(screen.getByText('This is selected')).toBeInTheDocument(); expect(screen.getByText(/Countries: US,CA/)).toBeInTheDocument(); }); + + it('should normalize string numeric ACL ids to numeric selection values', async () => { + const mockLists = [ + { + id: '7', + uuid: 'uuid-7', + name: 'String ID ACL', + description: 'String-based ID shape from API', + type: 'whitelist', + ip_rules: '[]', + country_codes: '', + local_network_only: false, + enabled: true, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + ]; + + vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({ + data: mockLists as unknown as AccessList[], + } as unknown as ReturnType); + + const mockOnChange = vi.fn(); + const Wrapper = createWrapper(); + const user = userEvent.setup(); + + render( + + + + ); + + await user.click(screen.getByRole('combobox', { name: /Access Control List/i })); + await user.click(await screen.findByRole('option', { name: 'String ID ACL (whitelist)' })); + + expect(mockOnChange).toHaveBeenCalledWith(7); + }); }); diff --git a/frontend/src/components/__tests__/ProxyHostForm-dropdown-changes.test.tsx b/frontend/src/components/__tests__/ProxyHostForm-dropdown-changes.test.tsx index 403f9379..c30a7141 100644 --- a/frontend/src/components/__tests__/ProxyHostForm-dropdown-changes.test.tsx +++ b/frontend/src/components/__tests__/ProxyHostForm-dropdown-changes.test.tsx @@ -614,4 +614,53 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => { expect(mockOnSubmit).toHaveBeenCalled() }) }) + + it('submits numeric ACL value when ACL option id is a numeric string', async () => { + const user = userEvent.setup() + const Wrapper = createWrapper() + + const stringIdAccessLists = [ + { + ...mockAccessLists[0], + id: '2', + uuid: 'acl-string-id-2', + name: 'String ID ACL', + }, + ] + + vi.mocked(useAccessLists).mockReturnValue({ + data: stringIdAccessLists as unknown as AccessList[], + isLoading: false, + error: null, + } as unknown as ReturnType) + + render( + + + + ) + + await user.type(screen.getByLabelText(/^Name/), 'String ID ACL Host') + await user.type(screen.getByLabelText(/Domain Names/), 'test.com') + await user.type(screen.getByLabelText(/^Host$/), 'localhost') + await user.clear(screen.getByLabelText(/^Port$/)) + await user.type(screen.getByLabelText(/^Port$/), '8080') + + await user.click(screen.getByRole('combobox', { name: /Access Control List/i })) + await user.click(await screen.findByRole('option', { name: /String ID ACL/i })) + + await user.click(screen.getByRole('combobox', { name: /Security Headers/i })) + await user.click(await screen.findByRole('option', { name: /Basic Security/i })) + + await user.click(screen.getByRole('button', { name: /Save/i })) + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + access_list_id: 2, + security_header_profile_id: 1, + }) + ) + }) + }) }) diff --git a/playwright.config.js b/playwright.config.js index cdfa7a1b..1c6cd9ee 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -30,9 +30,9 @@ const resolvedBaseURL = process.env.PLAYWRIGHT_BASE_URL || (enableCoverage ? 'ht if (!process.env.PLAYWRIGHT_BASE_URL) { process.env.PLAYWRIGHT_BASE_URL = resolvedBaseURL; } -// Skip security-test dependencies by default to avoid running them as a -// prerequisite for non-security test runs. Set PLAYWRIGHT_SKIP_SECURITY_DEPS=0 -// to restore the legacy dependency behavior when needed. +// Skip security-test dependencies by default to avoid running the security +// shard setup/teardown as a prerequisite for non-security test runs. +// Set PLAYWRIGHT_SKIP_SECURITY_DEPS=0 to restore legacy dependency behavior. const skipSecurityDeps = process.env.PLAYWRIGHT_SKIP_SECURITY_DEPS !== '0'; const browserDependencies = skipSecurityDeps ? ['setup'] : ['setup', 'security-tests']; @@ -227,6 +227,13 @@ export default defineConfig({ testMatch: /auth\.setup\.ts/, }, + // Security Shard Setup - runs only when security-tests are executed + { + name: 'security-shard-setup', + testMatch: /security-shard\.setup\.ts/, + dependencies: ['setup'], + }, + // Security Tests - Run WITH security enabled (SEQUENTIAL, Chromium only) { name: 'security-tests', @@ -235,7 +242,7 @@ export default defineConfig({ /security-enforcement\/.*\.spec\.(ts|js)/, /security\/.*\.spec\.(ts|js)/, ], - dependencies: ['setup'], + dependencies: ['setup', 'security-shard-setup'], teardown: 'security-teardown', fullyParallel: false, workers: 1, diff --git a/tests/auth.setup.ts b/tests/auth.setup.ts index 4b151b9e..cd63d6a1 100644 --- a/tests/auth.setup.ts +++ b/tests/auth.setup.ts @@ -101,6 +101,46 @@ async function resetAdminCredentials(baseURL: string | undefined): Promise { + if (!baseURL || !EMERGENCY_TOKEN) { + return false; + } + + const emergencyURL = new URL(baseURL); + emergencyURL.port = process.env.EMERGENCY_SERVER_PORT || '2020'; + + const recoveryContext = await playwrightRequest.newContext({ + baseURL: emergencyURL.toString(), + httpCredentials: { + username: process.env.CHARON_EMERGENCY_USERNAME || 'admin', + password: process.env.CHARON_EMERGENCY_PASSWORD || 'changeme', + }, + }); + + try { + const response = await recoveryContext.post('/emergency/security-reset', { + headers: { + 'X-Emergency-Token': EMERGENCY_TOKEN, + 'Content-Type': 'application/json', + }, + data: { reason: 'Auth setup ACL lockout recovery' }, + }); + + if (!response.ok()) { + console.warn(`⚠️ ACL lockout recovery failed with status ${response.status()}`); + return false; + } + + await new Promise((resolve) => setTimeout(resolve, 500)); + return true; + } catch (err) { + console.warn('⚠️ ACL lockout recovery request failed:', err instanceof Error ? err.message : err); + return false; + } finally { + await recoveryContext.dispose(); + } +} + async function performLoginAndSaveState( request: APIRequestContext, setupRequired: boolean, @@ -196,7 +236,14 @@ async function performLoginAndSaveState( setup('authenticate', async ({ request, baseURL }) => { // Step 1: Check if setup is required - const setupStatusResponse = await request.get('/api/v1/setup'); + let setupStatusResponse = await request.get('/api/v1/setup'); + + if (setupStatusResponse.status() === 403) { + const recovered = await recoverFromAclLockout(baseURL); + if (recovered) { + setupStatusResponse = await request.get('/api/v1/setup'); + } + } // Accept 200 (normal) or 401 (already initialized/auth required) // Provide diagnostic info on unexpected status for actionable failures diff --git a/tests/global-setup.ts b/tests/global-setup.ts index bfe30570..2df4fc03 100644 --- a/tests/global-setup.ts +++ b/tests/global-setup.ts @@ -4,117 +4,11 @@ * 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 + * 3. Performing base connectivity checks for test diagnostics */ -import { request, APIRequestContext } from '@playwright/test'; -import { existsSync } from 'fs'; -import { dirname } from 'path'; +import { request } from '@playwright/test'; import { TestDataManager } from './utils/TestDataManager'; -import { STORAGE_STATE } from './constants'; - -function isSqliteFullFailure(message: string): boolean { - const normalized = message.toLowerCase(); - return ( - normalized.includes('database or disk is full') || - normalized.includes('sqlite_full') || - (normalized.includes('(13)') && normalized.includes('sqlite')) - ); -} - -function buildSqliteFullInfrastructureError(context: string, details: string): Error { - const error = new Error( - `[INFRASTRUCTURE][SQLITE_FULL] ${context}\n` + - `Detected SQLite storage exhaustion during Playwright global setup.\n` + - `Action required:\n` + - `1. Free disk space and verify SQLite volume permissions.\n` + - `2. Rebuild/restart E2E environment before retry.\n` + - `Original error: ${details}` - ); - error.name = 'InfrastructureSQLiteFullError'; - return error; -} - -// 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 @@ -180,42 +74,8 @@ async function waitForContainer(maxRetries = 15, delayMs = 2000): Promise 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}`); @@ -243,29 +103,11 @@ async function globalSetup(): Promise { // 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` + `\n✅ Connectivity Summary: Caddy=${caddyHealthy ? '✓' : '✗'}\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, @@ -327,162 +169,6 @@ async function globalSetup(): Promise { } 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 { - const authDir = dirname(STORAGE_STATE); - console.log(`⏭️ Skipping authenticated security reset (no auth state file at ${STORAGE_STATE})`); - console.log(` └─ Auth dir exists: ${existsSync(authDir) ? 'Yes' : 'No'} (${authDir})`); - } -} - -/** - * 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 = getBaseURL(); - - 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}`); - if (isSqliteFullFailure(body)) { - throw buildSqliteFullInfrastructureError( - 'Emergency security reset returned non-OK status', - 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; diff --git a/tests/proxy-host-dropdown-fix.spec.ts b/tests/proxy-host-dropdown-fix.spec.ts index f882dfb9..65fa857d 100644 --- a/tests/proxy-host-dropdown-fix.spec.ts +++ b/tests/proxy-host-dropdown-fix.spec.ts @@ -1,113 +1,186 @@ import { test, expect } from '@playwright/test' -test.describe('ProxyHostForm Dropdown Click Fix', () => { - test.beforeEach(async ({ page }) => { - await test.step('Navigate to proxy hosts and open the create modal', async () => { +type SelectionPair = { + aclLabel: string + securityHeadersLabel: string +} + +async function dismissDomainDialog(page: import('@playwright/test').Page): Promise { + const noThanksButton = page.getByRole('button', { name: /no, thanks/i }) + if (await noThanksButton.isVisible({ timeout: 1200 }).catch(() => false)) { + await noThanksButton.click() + } +} + +async function openCreateModal(page: import('@playwright/test').Page): Promise { + const addButton = page.getByRole('button', { name: /add.*proxy.*host|create/i }).first() + await expect(addButton).toBeEnabled() + await addButton.click() + await expect(page.getByRole('dialog')).toBeVisible() +} + +async function selectFirstUsableOption( + page: import('@playwright/test').Page, + trigger: import('@playwright/test').Locator, + skipPattern: RegExp +): Promise { + await trigger.click() + const listbox = page.getByRole('listbox') + await expect(listbox).toBeVisible() + + const options = listbox.getByRole('option') + const optionCount = await options.count() + expect(optionCount).toBeGreaterThan(0) + + for (let i = 0; i < optionCount; i++) { + const option = options.nth(i) + const rawLabel = (await option.textContent())?.trim() || '' + const isDisabled = (await option.getAttribute('aria-disabled')) === 'true' + + if (isDisabled || !rawLabel || skipPattern.test(rawLabel)) { + continue + } + + await option.click() + return rawLabel + } + + throw new Error('No selectable non-default option found in dropdown') +} + +async function selectOptionByName( + page: import('@playwright/test').Page, + trigger: import('@playwright/test').Locator, + optionName: RegExp +): Promise { + await trigger.click() + const listbox = page.getByRole('listbox') + await expect(listbox).toBeVisible() + + const option = listbox.getByRole('option', { name: optionName }).first() + await expect(option).toBeVisible() + const label = ((await option.textContent()) || '').trim() + await option.click() + return label +} + +async function saveProxyHost(page: import('@playwright/test').Page): Promise { + await dismissDomainDialog(page) + + const saveButton = page + .getByTestId('proxy-host-save') + .or(page.getByRole('button', { name: /^save$/i })) + .first() + await expect(saveButton).toBeEnabled() + await saveButton.click() + + const confirmSave = page.getByRole('button', { name: /yes.*save/i }).first() + if (await confirmSave.isVisible({ timeout: 1200 }).catch(() => false)) { + await confirmSave.click() + } + + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }) +} + +async function openEditModalForDomain(page: import('@playwright/test').Page, domain: string): Promise { + const row = page.locator('tbody tr').filter({ hasText: domain }).first() + await expect(row).toBeVisible({ timeout: 10000 }) + + const editButton = row.getByRole('button', { name: /edit proxy host|edit/i }).first() + await expect(editButton).toBeVisible() + await editButton.click() + await expect(page.getByRole('dialog')).toBeVisible() +} + +async function selectNonDefaultPair( + page: import('@playwright/test').Page, + dialog: import('@playwright/test').Locator +): Promise { + const aclTrigger = dialog.getByRole('combobox', { name: /access control list/i }) + const securityHeadersTrigger = dialog.getByRole('combobox', { name: /security headers/i }) + + const aclLabel = await selectFirstUsableOption(page, aclTrigger, /no access control|public/i) + await expect(aclTrigger).toContainText(aclLabel) + + const securityHeadersLabel = await selectFirstUsableOption(page, securityHeadersTrigger, /none \(no security headers\)/i) + await expect(securityHeadersTrigger).toContainText(securityHeadersLabel) + + return { aclLabel, securityHeadersLabel } +} + +test.describe.skip('ProxyHostForm ACL/Security Headers Regression (moved to security shard)', () => { + test('should keep ACL and Security Headers behavior equivalent across create/edit flows', async ({ page }) => { + const suffix = Date.now() + const proxyName = `Dropdown Regression ${suffix}` + const proxyDomain = `dropdown-${suffix}.test.local` + + await test.step('Navigate to Proxy Hosts', async () => { await page.goto('/proxy-hosts') await page.waitForLoadState('networkidle') - - const addButton = page.getByRole('button', { name: /add proxy host|create/i }).first() - await expect(addButton).toBeEnabled() - await addButton.click() - - await expect(page.getByRole('dialog')).toBeVisible() + await expect(page.getByRole('heading', { name: /proxy hosts/i })).toBeVisible() }) - }) - test('ACL dropdown should open and items should be clickable', async ({ page }) => { - const dialog = page.getByRole('dialog') + await test.step('Create flow: select ACL + Security Headers and verify immediate form state', async () => { + await openCreateModal(page) + const dialog = page.getByRole('dialog') + + await dialog.locator('#proxy-name').fill(proxyName) + await dialog.locator('#domain-names').click() + await page.keyboard.type(proxyDomain) + await page.keyboard.press('Tab') + await dismissDomainDialog(page) + + await dialog.locator('#forward-host').fill('127.0.0.1') + await dialog.locator('#forward-port').fill('8080') + + const initialSelection = await selectNonDefaultPair(page, dialog) + + await saveProxyHost(page) + + await openEditModalForDomain(page, proxyDomain) + const reopenDialog = page.getByRole('dialog') + await expect(reopenDialog.getByRole('combobox', { name: /access control list/i })).toContainText(initialSelection.aclLabel) + await expect(reopenDialog.getByRole('combobox', { name: /security headers/i })).toContainText(initialSelection.securityHeadersLabel) + await reopenDialog.getByRole('button', { name: /cancel/i }).click() + await expect(reopenDialog).not.toBeVisible({ timeout: 5000 }) + }) + + await test.step('Edit flow: change ACL + Security Headers and verify persisted updates', async () => { + await openEditModalForDomain(page, proxyDomain) + const dialog = page.getByRole('dialog') + + const updatedSelection = await selectNonDefaultPair(page, dialog) + await saveProxyHost(page) + + await openEditModalForDomain(page, proxyDomain) + const reopenDialog = page.getByRole('dialog') + await expect(reopenDialog.getByRole('combobox', { name: /access control list/i })).toContainText(updatedSelection.aclLabel) + await expect(reopenDialog.getByRole('combobox', { name: /security headers/i })).toContainText(updatedSelection.securityHeadersLabel) + await reopenDialog.getByRole('button', { name: /cancel/i }).click() + await expect(reopenDialog).not.toBeVisible({ timeout: 5000 }) + }) + + await test.step('Edit flow: clear both to none/null and verify persisted clearing', async () => { + await openEditModalForDomain(page, proxyDomain) + const dialog = page.getByRole('dialog') - await test.step('Open Access Control List dropdown', async () => { const aclTrigger = dialog.getByRole('combobox', { name: /access control list/i }) - await expect(aclTrigger).toBeEnabled() - await aclTrigger.click() + const securityHeadersTrigger = dialog.getByRole('combobox', { name: /security headers/i }) - const listbox = page.getByRole('listbox') - await expect(listbox).toBeVisible() - await expect(listbox).toMatchAriaSnapshot(` - - listbox: - - option - `) + const aclNoneLabel = await selectOptionByName(page, aclTrigger, /no access control \(public\)/i) + await expect(aclTrigger).toContainText(aclNoneLabel) - const dropdownItems = listbox.getByRole('option') - const itemCount = await dropdownItems.count() - expect(itemCount).toBeGreaterThan(0) + const securityNoneLabel = await selectOptionByName(page, securityHeadersTrigger, /none \(no security headers\)/i) + await expect(securityHeadersTrigger).toContainText(securityNoneLabel) - let selectedText: string | null = null - for (let i = 0; i < itemCount; i++) { - const option = dropdownItems.nth(i) - const isDisabled = (await option.getAttribute('aria-disabled')) === 'true' - if (!isDisabled) { - selectedText = (await option.textContent())?.trim() || null - await option.click() - break - } - } + await saveProxyHost(page) - expect(selectedText).toBeTruthy() - await expect(aclTrigger).toContainText(selectedText || '') + await openEditModalForDomain(page, proxyDomain) + const reopenDialog = page.getByRole('dialog') + await expect(reopenDialog.getByRole('combobox', { name: /access control list/i })).toContainText(/no access control \(public\)/i) + await expect(reopenDialog.getByRole('combobox', { name: /security headers/i })).toContainText(/none \(no security headers\)/i) + await reopenDialog.getByRole('button', { name: /cancel/i }).click() + await expect(reopenDialog).not.toBeVisible({ timeout: 5000 }) }) }) - - test('Security Headers dropdown should open and items should be clickable', async ({ page }) => { - const dialog = page.getByRole('dialog') - - await test.step('Open Security Headers dropdown', async () => { - const securityTrigger = dialog.getByRole('combobox', { name: /security headers/i }) - await expect(securityTrigger).toBeEnabled() - await securityTrigger.click() - - const listbox = page.getByRole('listbox') - await expect(listbox).toBeVisible() - await expect(listbox).toMatchAriaSnapshot(` - - listbox: - - option - `) - - const dropdownItems = listbox.getByRole('option') - const itemCount = await dropdownItems.count() - expect(itemCount).toBeGreaterThan(0) - - let selectedText: string | null = null - for (let i = 0; i < itemCount; i++) { - const option = dropdownItems.nth(i) - const isDisabled = (await option.getAttribute('aria-disabled')) === 'true' - if (!isDisabled) { - selectedText = (await option.textContent())?.trim() || null - await option.click() - break - } - } - - expect(selectedText).toBeTruthy() - await expect(securityTrigger).toContainText(selectedText || '') - }) - }) - - test('All dropdown menus should allow clicking on items without blocking', async ({ page }) => { - const dialog = page.getByRole('dialog') - const selectTriggers = dialog.getByRole('combobox') - const triggerCount = await selectTriggers.count() - - for (let i = 0; i < Math.min(triggerCount, 3); i++) { - await test.step(`Open dropdown ${i + 1}`, async () => { - const trigger = selectTriggers.nth(i) - const isDisabled = await trigger.isDisabled() - if (isDisabled) { - return - } - - await expect(trigger).toBeEnabled() - await trigger.click() - - const menu = page.getByRole('listbox') - await expect(menu).toBeVisible() - - const firstOption = menu.getByRole('option').first() - await expect(firstOption).toBeVisible() - - await page.keyboard.press('Escape') - }) - } - }) }) diff --git a/tests/security-enforcement/acl-creation.spec.ts b/tests/security-enforcement/acl-creation.spec.ts new file mode 100644 index 00000000..3ac0b5b6 --- /dev/null +++ b/tests/security-enforcement/acl-creation.spec.ts @@ -0,0 +1,83 @@ +import { test, expect } from '@playwright/test'; + +const TEST_EMAIL = process.env.E2E_TEST_EMAIL || 'e2e-test@example.com'; +const TEST_PASSWORD = process.env.E2E_TEST_PASSWORD || 'TestPassword123!'; + +async function authenticate(request: import('@playwright/test').APIRequestContext): Promise { + const loginResponse = await request.post('/api/v1/auth/login', { + data: { + email: TEST_EMAIL, + password: TEST_PASSWORD, + }, + }); + + expect(loginResponse.ok()).toBeTruthy(); + const loginBody = await loginResponse.json(); + expect(loginBody.token).toBeTruthy(); + return loginBody.token as string; +} + +test.describe('ACL Creation Baseline', () => { + test('should create ACL and security header profile for dropdown coverage', async ({ request }) => { + const token = await authenticate(request); + const unique = Date.now(); + const aclName = `ACL Baseline ${unique}`; + const profileName = `Headers Baseline ${unique}`; + + await test.step('Create ACL baseline entry', async () => { + const aclResponse = await request.post('/api/v1/access-lists', { + headers: { + Authorization: `Bearer ${token}`, + }, + data: { + name: aclName, + type: 'whitelist', + enabled: true, + ip_rules: JSON.stringify([ + { + cidr: '127.0.0.1/32', + description: 'Local test runner', + }, + ]), + }, + }); + + expect(aclResponse.ok()).toBeTruthy(); + }); + + await test.step('Create security headers profile baseline entry', async () => { + const profileResponse = await request.post('/api/v1/security/headers/profiles', { + headers: { + Authorization: `Bearer ${token}`, + }, + data: { + name: profileName, + }, + }); + + expect(profileResponse.status()).toBe(201); + }); + + await test.step('Verify baseline entries are queryable', async () => { + const aclListResponse = await request.get('/api/v1/access-lists', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + expect(aclListResponse.ok()).toBeTruthy(); + const aclList = await aclListResponse.json(); + expect(Array.isArray(aclList)).toBeTruthy(); + expect(aclList.some((item: { name?: string }) => item.name === aclName)).toBeTruthy(); + + const profileListResponse = await request.get('/api/v1/security/headers/profiles', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + expect(profileListResponse.ok()).toBeTruthy(); + const profilePayload = await profileListResponse.json(); + const profiles = Array.isArray(profilePayload?.profiles) ? profilePayload.profiles : []; + expect(profiles.some((item: { name?: string }) => item.name === profileName)).toBeTruthy(); + }); + }); +}); diff --git a/tests/security-enforcement/acl-dropdown-regression.spec.ts b/tests/security-enforcement/acl-dropdown-regression.spec.ts new file mode 100644 index 00000000..a1358557 --- /dev/null +++ b/tests/security-enforcement/acl-dropdown-regression.spec.ts @@ -0,0 +1,186 @@ +import { test, expect } from '@playwright/test'; + +type SelectionPair = { + aclLabel: string; + securityHeadersLabel: string; +}; + +async function dismissDomainDialog(page: import('@playwright/test').Page): Promise { + const noThanksButton = page.getByRole('button', { name: /no, thanks/i }); + if (await noThanksButton.isVisible({ timeout: 1200 }).catch(() => false)) { + await noThanksButton.click(); + } +} + +async function openCreateModal(page: import('@playwright/test').Page): Promise { + const addButton = page.getByRole('button', { name: /add.*proxy.*host|create/i }).first(); + await expect(addButton).toBeEnabled(); + await addButton.click(); + await expect(page.getByRole('dialog')).toBeVisible(); +} + +async function selectFirstUsableOption( + page: import('@playwright/test').Page, + trigger: import('@playwright/test').Locator, + skipPattern: RegExp +): Promise { + await trigger.click(); + const listbox = page.getByRole('listbox'); + await expect(listbox).toBeVisible(); + + const options = listbox.getByRole('option'); + const optionCount = await options.count(); + expect(optionCount).toBeGreaterThan(0); + + for (let i = 0; i < optionCount; i++) { + const option = options.nth(i); + const rawLabel = (await option.textContent())?.trim() || ''; + const isDisabled = (await option.getAttribute('aria-disabled')) === 'true'; + + if (isDisabled || !rawLabel || skipPattern.test(rawLabel)) { + continue; + } + + await option.click(); + return rawLabel; + } + + throw new Error('No selectable non-default option found in dropdown'); +} + +async function selectOptionByName( + page: import('@playwright/test').Page, + trigger: import('@playwright/test').Locator, + optionName: RegExp +): Promise { + await trigger.click(); + const listbox = page.getByRole('listbox'); + await expect(listbox).toBeVisible(); + + const option = listbox.getByRole('option', { name: optionName }).first(); + await expect(option).toBeVisible(); + const label = ((await option.textContent()) || '').trim(); + await option.click(); + return label; +} + +async function saveProxyHost(page: import('@playwright/test').Page): Promise { + await dismissDomainDialog(page); + + const saveButton = page + .getByTestId('proxy-host-save') + .or(page.getByRole('button', { name: /^save$/i })) + .first(); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + + const confirmSave = page.getByRole('button', { name: /yes.*save/i }).first(); + if (await confirmSave.isVisible({ timeout: 1200 }).catch(() => false)) { + await confirmSave.click(); + } + + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); +} + +async function openEditModalForDomain(page: import('@playwright/test').Page, domain: string): Promise { + const row = page.locator('tbody tr').filter({ hasText: domain }).first(); + await expect(row).toBeVisible({ timeout: 10000 }); + + const editButton = row.getByRole('button', { name: /edit proxy host|edit/i }).first(); + await expect(editButton).toBeVisible(); + await editButton.click(); + await expect(page.getByRole('dialog')).toBeVisible(); +} + +async function selectNonDefaultPair( + page: import('@playwright/test').Page, + dialog: import('@playwright/test').Locator +): Promise { + const aclTrigger = dialog.getByRole('combobox', { name: /access control list/i }); + const securityHeadersTrigger = dialog.getByRole('combobox', { name: /security headers/i }); + + const aclLabel = await selectFirstUsableOption(page, aclTrigger, /no access control|public/i); + await expect(aclTrigger).toContainText(aclLabel); + + const securityHeadersLabel = await selectFirstUsableOption(page, securityHeadersTrigger, /none \(no security headers\)/i); + await expect(securityHeadersTrigger).toContainText(securityHeadersLabel); + + return { aclLabel, securityHeadersLabel }; +} + +test.describe('ProxyHostForm ACL and Security Headers Dropdown Regression', () => { + test('should keep ACL and Security Headers behavior equivalent across create/edit flows', async ({ page }) => { + const suffix = Date.now(); + const proxyName = `Dropdown Regression ${suffix}`; + const proxyDomain = `dropdown-${suffix}.test.local`; + + await test.step('Navigate to Proxy Hosts', async () => { + await page.goto('/proxy-hosts'); + await page.waitForLoadState('networkidle'); + await expect(page.getByRole('heading', { name: /proxy hosts/i }).first()).toBeVisible(); + }); + + await test.step('Create flow: select ACL + Security Headers and verify immediate form state', async () => { + await openCreateModal(page); + const dialog = page.getByRole('dialog'); + + await dialog.locator('#proxy-name').fill(proxyName); + await dialog.locator('#domain-names').click(); + await page.keyboard.type(proxyDomain); + await page.keyboard.press('Tab'); + await dismissDomainDialog(page); + + await dialog.locator('#forward-host').fill('127.0.0.1'); + await dialog.locator('#forward-port').fill('8080'); + + const initialSelection = await selectNonDefaultPair(page, dialog); + + await saveProxyHost(page); + + await openEditModalForDomain(page, proxyDomain); + const reopenDialog = page.getByRole('dialog'); + await expect(reopenDialog.getByRole('combobox', { name: /access control list/i })).toContainText(initialSelection.aclLabel); + await expect(reopenDialog.getByRole('combobox', { name: /security headers/i })).toContainText(initialSelection.securityHeadersLabel); + await reopenDialog.getByRole('button', { name: /cancel/i }).click(); + await expect(reopenDialog).not.toBeVisible({ timeout: 5000 }); + }); + + await test.step('Edit flow: change ACL + Security Headers and verify persisted updates', async () => { + await openEditModalForDomain(page, proxyDomain); + const dialog = page.getByRole('dialog'); + + const updatedSelection = await selectNonDefaultPair(page, dialog); + await saveProxyHost(page); + + await openEditModalForDomain(page, proxyDomain); + const reopenDialog = page.getByRole('dialog'); + await expect(reopenDialog.getByRole('combobox', { name: /access control list/i })).toContainText(updatedSelection.aclLabel); + await expect(reopenDialog.getByRole('combobox', { name: /security headers/i })).toContainText(updatedSelection.securityHeadersLabel); + await reopenDialog.getByRole('button', { name: /cancel/i }).click(); + await expect(reopenDialog).not.toBeVisible({ timeout: 5000 }); + }); + + await test.step('Edit flow: clear both to none/null and verify persisted clearing', async () => { + await openEditModalForDomain(page, proxyDomain); + const dialog = page.getByRole('dialog'); + + const aclTrigger = dialog.getByRole('combobox', { name: /access control list/i }); + const securityHeadersTrigger = dialog.getByRole('combobox', { name: /security headers/i }); + + const aclNoneLabel = await selectOptionByName(page, aclTrigger, /no access control \(public\)/i); + await expect(aclTrigger).toContainText(aclNoneLabel); + + const securityNoneLabel = await selectOptionByName(page, securityHeadersTrigger, /none \(no security headers\)/i); + await expect(securityHeadersTrigger).toContainText(securityNoneLabel); + + await saveProxyHost(page); + + await openEditModalForDomain(page, proxyDomain); + const reopenDialog = page.getByRole('dialog'); + await expect(reopenDialog.getByRole('combobox', { name: /access control list/i })).toContainText(/no access control \(public\)/i); + await expect(reopenDialog.getByRole('combobox', { name: /security headers/i })).toContainText(/none \(no security headers\)/i); + await reopenDialog.getByRole('button', { name: /cancel/i }).click(); + await expect(reopenDialog).not.toBeVisible({ timeout: 5000 }); + }); + }); +}); diff --git a/tests/security-enforcement/emergency-token.spec.ts b/tests/security-enforcement/emergency-token.spec.ts index 7c008ed8..7dc1ee68 100644 --- a/tests/security-enforcement/emergency-token.spec.ts +++ b/tests/security-enforcement/emergency-token.spec.ts @@ -23,7 +23,7 @@ test.describe('Emergency Token Break Glass Protocol', () => { * CRITICAL: Ensure Cerberus AND ACL are enabled before running these tests * * WHY CERBERUS MUST BE ENABLED FIRST: - * - global-setup.ts disables ALL security modules including feature.cerberus.enabled + * - security-shard.setup.ts resets security state to a disabled baseline * - The Cerberus middleware is the master switch that gates ALL security enforcement * - If Cerberus is disabled, the middleware short-circuits and ACL is never checked * - Therefore: Cerberus must be enabled BEFORE ACL for security to actually be enforced diff --git a/tests/security-shard.setup.ts b/tests/security-shard.setup.ts new file mode 100644 index 00000000..ef182243 --- /dev/null +++ b/tests/security-shard.setup.ts @@ -0,0 +1,87 @@ +import { test as setup, expect, request as playwrightRequest } from '@playwright/test'; + +const SECURITY_RESET_PROPAGATION_MS = 750; + +function getBaseURL(baseURL?: string): string { + return baseURL || process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:8080'; +} + +function getEmergencyServerURL(baseURL: string): string { + const parsed = new URL(baseURL); + parsed.port = process.env.EMERGENCY_SERVER_PORT || '2020'; + return parsed.toString().replace(/\/$/, ''); +} + +function validateEmergencyTokenForSecurityShard(): string { + const token = process.env.CHARON_EMERGENCY_TOKEN; + if (!token) { + throw new Error('CHARON_EMERGENCY_TOKEN is required for security shard setup'); + } + + if (token.length < 64) { + throw new Error(`CHARON_EMERGENCY_TOKEN must be at least 64 characters (got ${token.length})`); + } + + if (!/^[a-f0-9]+$/i.test(token)) { + throw new Error('CHARON_EMERGENCY_TOKEN must be hexadecimal'); + } + + return token; +} + +async function emergencySecurityReset(baseURL: string, emergencyToken: string): Promise { + const emergencyBaseURL = getEmergencyServerURL(baseURL); + const emergencyContext = await playwrightRequest.newContext({ + baseURL: emergencyBaseURL, + httpCredentials: { + username: process.env.CHARON_EMERGENCY_USERNAME || 'admin', + password: process.env.CHARON_EMERGENCY_PASSWORD || 'changeme', + }, + }); + + try { + const response = await emergencyContext.post('/emergency/security-reset', { + headers: { + 'X-Emergency-Token': emergencyToken, + 'Content-Type': 'application/json', + }, + data: { reason: 'Security shard setup baseline reset' }, + timeout: 8000, + }); + + const body = await response.text(); + expect(response.ok(), `Security shard emergency reset failed: ${response.status()} ${body}`).toBeTruthy(); + } finally { + await emergencyContext.dispose(); + } +} + +async function verifySecurityDisabled(baseURL: string, emergencyToken: string): Promise { + const statusContext = await playwrightRequest.newContext({ + baseURL, + extraHTTPHeaders: { + 'X-Emergency-Token': emergencyToken, + }, + }); + + try { + const response = await statusContext.get('/api/v1/security/status', { timeout: 5000 }); + expect(response.ok()).toBeTruthy(); + + const status = await response.json(); + expect(status.acl?.enabled).toBeFalsy(); + expect(status.waf?.enabled).toBeFalsy(); + expect(status.rate_limit?.enabled).toBeFalsy(); + } finally { + await statusContext.dispose(); + } +} + +setup('prepare-security-shard-baseline', async ({ baseURL }) => { + const resolvedBaseURL = getBaseURL(baseURL); + const emergencyToken = validateEmergencyTokenForSecurityShard(); + + await emergencySecurityReset(resolvedBaseURL, emergencyToken); + await new Promise((resolve) => setTimeout(resolve, SECURITY_RESET_PROPAGATION_MS)); + await verifySecurityDisabled(resolvedBaseURL, emergencyToken); +}); diff --git a/tests/security/emergency-operations.spec.ts b/tests/security/emergency-operations.spec.ts index a724c079..13976bc6 100644 --- a/tests/security/emergency-operations.spec.ts +++ b/tests/security/emergency-operations.spec.ts @@ -9,9 +9,127 @@ import { test, expect } from '@playwright/test'; */ test.describe('Emergency & Break-Glass Operations', () => { + async function dismissDomainDialog(page: import('@playwright/test').Page): Promise { + const noThanksButton = page.getByRole('button', { name: /no, thanks/i }); + if (await noThanksButton.isVisible({ timeout: 1200 }).catch(() => false)) { + await noThanksButton.click(); + } + } + + async function openCreateProxyModal(page: import('@playwright/test').Page): Promise { + const addButton = page.getByRole('button', { name: /add.*proxy.*host|create/i }).first(); + await expect(addButton).toBeEnabled(); + await addButton.click(); + await expect(page.getByRole('dialog')).toBeVisible(); + } + + async function openEditProxyModalForDomain( + page: import('@playwright/test').Page, + domain: string + ): Promise { + const row = page.locator('tbody tr').filter({ hasText: domain }).first(); + await expect(row).toBeVisible({ timeout: 10000 }); + + const editButton = row.getByRole('button', { name: /edit proxy host|edit/i }).first(); + await expect(editButton).toBeVisible(); + await editButton.click(); + await expect(page.getByRole('dialog')).toBeVisible(); + } + + async function saveProxyHost(page: import('@playwright/test').Page): Promise { + await dismissDomainDialog(page); + + const saveButton = page + .getByTestId('proxy-host-save') + .or(page.getByRole('button', { name: /^save$/i })) + .first(); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + + const confirmSave = page.getByRole('button', { name: /yes.*save/i }).first(); + if (await confirmSave.isVisible({ timeout: 1200 }).catch(() => false)) { + await confirmSave.click(); + } + + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + } + + async function selectOptionByName( + page: import('@playwright/test').Page, + trigger: import('@playwright/test').Locator, + optionName: RegExp + ): Promise { + await trigger.click(); + const listbox = page.getByRole('listbox'); + await expect(listbox).toBeVisible(); + + const option = listbox.getByRole('option', { name: optionName }).first(); + await expect(option).toBeVisible(); + const label = ((await option.textContent()) || '').trim(); + await option.click(); + return label; + } + test.beforeEach(async ({ page }) => { await page.goto('/'); - await page.waitForSelector('[data-testid="dashboard-container"], [role="main"]', { timeout: 5000 }); + await page.waitForSelector('[data-testid="dashboard-container"], main', { timeout: 15000 }); + }); + + test('ACL dropdown parity regression keeps selection stable before emergency token flows', async ({ page }) => { + const suffix = Date.now(); + const aclName = `Emergency-ACL-${suffix}`; + const proxyDomain = `emergency-acl-${suffix}.test.local`; + + await test.step('Create ACL prerequisite through API for deterministic dropdown options', async () => { + const createAclResponse = await page.request.post('/api/v1/access-lists', { + data: { + name: aclName, + type: 'whitelist', + description: 'ACL prerequisite for emergency regression test', + enabled: true, + ip_rules: JSON.stringify([{ cidr: '10.0.0.0/8' }]), + }, + }); + expect(createAclResponse.ok()).toBeTruthy(); + }); + + await test.step('Create proxy host and select created ACL in dropdown', async () => { + await page.goto('/proxy-hosts'); + await page.waitForLoadState('networkidle'); + + await openCreateProxyModal(page); + const dialog = page.getByRole('dialog'); + + await dialog.locator('#proxy-name').fill(`Emergency ACL Regression ${suffix}`); + await dialog.locator('#domain-names').click(); + await page.keyboard.type(proxyDomain); + await page.keyboard.press('Tab'); + await dismissDomainDialog(page); + + await dialog.locator('#forward-host').fill('127.0.0.1'); + await dialog.locator('#forward-port').fill('8080'); + + const aclTrigger = dialog.getByRole('combobox', { name: /access control list/i }); + const selectedAclLabel = await selectOptionByName( + page, + aclTrigger, + new RegExp(aclName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') + ); + await expect(aclTrigger).toContainText(selectedAclLabel); + + await saveProxyHost(page); + }); + + await test.step('Edit proxy host and verify ACL selection persisted', async () => { + await openEditProxyModalForDomain(page, proxyDomain); + + const dialog = page.getByRole('dialog'); + const aclTrigger = dialog.getByRole('combobox', { name: /access control list/i }); + await expect(aclTrigger).toContainText(new RegExp(aclName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')); + + await dialog.getByRole('button', { name: /cancel/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + }); }); // Use emergency token