import { test, expect, request as playwrightRequest } from '@playwright/test'; import type { APIRequestContext } from '@playwright/test'; import { withSecurityEnabled, captureSecurityState, setSecurityModuleEnabled, } from './utils/security-helpers'; import { getStorageStateAuthHeaders } from './utils/api-helpers'; import { STORAGE_STATE } from './constants'; /** * CrowdSec IP Whitelist Management E2E Tests * * Tests the whitelist tab on the CrowdSec configuration page (/security/crowdsec). * The tab is conditionally rendered: it only appears when CrowdSec mode is not 'disabled'. * * Uses IPs in the 10.99.x.x range to avoid conflicts with real network addresses. * * NOTE: Uses request.newContext({ storageState }) instead of the `request` fixture because * the auth cookie has `secure: true` which the fixture won't send over HTTP, but * Playwright's APIRequestContext does send it. */ const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:8080'; const TEST_IP_PREFIX = '10.99'; function createRequestContext(): Promise { return playwrightRequest.newContext({ baseURL: BASE_URL, storageState: STORAGE_STATE, extraHTTPHeaders: getStorageStateAuthHeaders(), }); } test.describe('CrowdSec IP Whitelist Management', () => { // Serial mode prevents the tab-visibility test (which disables CrowdSec) from // racing with the local-mode tests (which require CrowdSec enabled). test.describe.configure({ mode: 'serial' }); test.describe('tab visibility', () => { test('whitelist tab is hidden when CrowdSec is disabled', async ({ page }) => { const rc = await createRequestContext(); const originalState = await captureSecurityState(rc); if (originalState.crowdsec) { await setSecurityModuleEnabled(rc, 'crowdsec', false); } try { await test.step('Navigate to CrowdSec config page', async () => { await page.goto('/security/crowdsec'); await page.waitForLoadState('networkidle'); }); await test.step('Verify whitelist tab is not present', async () => { await expect(page.getByRole('tab', { name: 'Whitelist' })).not.toBeVisible(); }); } finally { if (originalState.crowdsec) { await setSecurityModuleEnabled(rc, 'crowdsec', true); } await rc.dispose(); } }); }); test.describe('with CrowdSec in local mode', () => { let rc: APIRequestContext; let cleanupSecurity: () => Promise; test.beforeAll(async () => { rc = await createRequestContext(); cleanupSecurity = await withSecurityEnabled(rc, { crowdsec: true, cerberus: true }); // Wait for CrowdSec to enter local mode (may take a few seconds after enabling) for (let attempt = 0; attempt < 15; attempt++) { const statusResp = await rc.get('/api/v1/security/status'); if (statusResp.ok()) { const status = await statusResp.json(); if (status.crowdsec?.mode !== 'disabled') break; } await new Promise((resolve) => setTimeout(resolve, 2000)); } }); test.afterAll(async () => { // Remove any leftover test entries before restoring security state const resp = await rc.get('/api/v1/admin/crowdsec/whitelist'); if (resp.ok()) { const data = await resp.json(); for (const entry of (data.whitelist ?? []) as Array<{ uuid: string; ip_or_cidr: string }>) { if (entry.ip_or_cidr.startsWith(TEST_IP_PREFIX)) { await rc.delete(`/api/v1/admin/crowdsec/whitelist/${entry.uuid}`); } } } await cleanupSecurity?.(); await rc.dispose(); }); test.beforeEach(async ({ page }) => { await test.step('Open CrowdSec Whitelist tab', async () => { // CrowdSec may take time to enter local mode after being enabled. // Retry navigation until the Whitelist tab is visible. const maxAttempts = 15; let tabFound = false; for (let attempt = 0; attempt < maxAttempts; attempt++) { await page.goto('/security/crowdsec'); // Wait for network to settle so React Query status fetch completes await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); const whitelistTab = page.getByRole('tab', { name: 'Whitelist' }); const visible = await whitelistTab.isVisible().catch(() => false); if (visible) { await whitelistTab.click(); await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); tabFound = true; break; } if (attempt < maxAttempts - 1) { await new Promise((resolve) => setTimeout(resolve, 2000)); } } if (!tabFound) { // Fail with a clear error message if tab never appeared await expect(page.getByRole('tab', { name: 'Whitelist' })).toBeVisible({ timeout: 1000, }); } }); }); test('displays empty state when no whitelist entries exist', async ({ page }) => { await test.step('Verify empty state message and snapshot', async () => { const emptyEl = page.getByTestId('whitelist-empty'); await expect(emptyEl).toBeVisible(); await expect(emptyEl).toHaveText('No whitelist entries'); await expect(emptyEl).toMatchAriaSnapshot(` - paragraph: No whitelist entries `); }); }); test('adds a valid IPv4 address to the whitelist', async ({ page }) => { const testIP = `${TEST_IP_PREFIX}.1.10`; let addedUUID: string | null = null; try { await test.step('Fill IP address and reason fields', async () => { await page.getByTestId('whitelist-ip-input').fill(testIP); await page.getByTestId('whitelist-reason-input').fill('IPv4 E2E test entry'); }); await test.step('Submit the form and capture response', async () => { const responsePromise = page.waitForResponse( (resp) => resp.url().includes('/api/v1/admin/crowdsec/whitelist') && resp.request().method() === 'POST' ); await page.getByTestId('whitelist-add-btn').click(); const response = await responsePromise; expect(response.status()).toBe(201); const body = await response.json(); addedUUID = body.uuid as string; }); await test.step('Verify the entry appears in the table', async () => { await expect(page.getByRole('cell', { name: testIP, exact: true })).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole('cell', { name: 'IPv4 E2E test entry' })).toBeVisible({ timeout: 10_000 }); }); } finally { if (addedUUID) { await rc.delete(`/api/v1/admin/crowdsec/whitelist/${addedUUID}`); } } }); test('adds a valid CIDR range to the whitelist', async ({ page }) => { const testCIDR = `${TEST_IP_PREFIX}.2.0/24`; let addedUUID: string | null = null; try { await test.step('Fill CIDR notation and reason', async () => { await page.getByTestId('whitelist-ip-input').fill(testCIDR); await page.getByTestId('whitelist-reason-input').fill('CIDR E2E test range'); }); await test.step('Submit the form and capture response', async () => { const responsePromise = page.waitForResponse( (resp) => resp.url().includes('/api/v1/admin/crowdsec/whitelist') && resp.request().method() === 'POST' ); await page.getByTestId('whitelist-add-btn').click(); const response = await responsePromise; expect(response.status()).toBe(201); const body = await response.json(); addedUUID = body.uuid as string; }); await test.step('Verify CIDR entry appears in the table', async () => { await expect(page.getByRole('cell', { name: testCIDR, exact: true })).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole('cell', { name: 'CIDR E2E test range' })).toBeVisible({ timeout: 10_000 }); }); } finally { if (addedUUID) { await rc.delete(`/api/v1/admin/crowdsec/whitelist/${addedUUID}`); } } }); test('"Add My IP" button pre-fills the detected client IP', async ({ page }) => { const ipResp = await rc.get('/api/v1/system/my-ip'); expect(ipResp.ok()).toBeTruthy(); const { ip: detectedIP } = await ipResp.json() as { ip: string }; await test.step('Click the "Add My IP" button', async () => { await page.getByTestId('whitelist-add-my-ip-btn').click(); }); await test.step('Verify the IP input is pre-filled with the detected IP', async () => { await expect(page.getByTestId('whitelist-ip-input')).toHaveValue(detectedIP); }); }); test('shows an inline validation error for an invalid IP address', async ({ page }) => { await test.step('Fill the IP field with an invalid value', async () => { await page.getByTestId('whitelist-ip-input').fill('not-an-ip'); }); await test.step('Submit the form', async () => { await page.getByTestId('whitelist-add-btn').click(); }); await test.step('Verify the inline error element is visible with an error message', async () => { const errorEl = page.getByTestId('whitelist-ip-error'); await expect(errorEl).toBeVisible(); await expect(errorEl).toContainText(/invalid/i); }); }); test('shows a conflict error when adding a duplicate whitelist entry', async ({ page }) => { const testIP = `${TEST_IP_PREFIX}.3.10`; let addedUUID: string | null = null; try { await test.step('Pre-seed the whitelist entry via API', async () => { const addResp = await rc.post('/api/v1/admin/crowdsec/whitelist', { data: { ip_or_cidr: testIP, reason: 'duplicate seed' }, }); expect(addResp.status()).toBe(201); const body = await addResp.json(); addedUUID = body.uuid as string; }); await test.step('Reload the whitelist tab to see the seeded entry', async () => { await page.goto('/security/crowdsec'); const whitelistTab = page.getByRole('tab', { name: 'Whitelist' }); await expect(whitelistTab).toBeVisible({ timeout: 15_000 }); await whitelistTab.click(); await expect(page.getByRole('cell', { name: testIP, exact: true })).toBeVisible({ timeout: 10_000 }); }); await test.step('Attempt to add the same IP again', async () => { await page.getByTestId('whitelist-ip-input').fill(testIP); await page.getByTestId('whitelist-add-btn').click(); }); await test.step('Verify the conflict error is shown inline', async () => { const errorEl = page.getByTestId('whitelist-ip-error'); await expect(errorEl).toBeVisible(); await expect(errorEl).toContainText(/already exists/i); }); } finally { if (addedUUID) { await rc.delete(`/api/v1/admin/crowdsec/whitelist/${addedUUID}`); } } }); test('removes a whitelist entry via the delete confirmation modal', async ({ page }) => { const testIP = `${TEST_IP_PREFIX}.4.10`; let addedUUID: string | null = null; try { await test.step('Pre-seed a whitelist entry via API', async () => { const addResp = await rc.post('/api/v1/admin/crowdsec/whitelist', { data: { ip_or_cidr: testIP, reason: 'delete modal test' }, }); expect(addResp.status()).toBe(201); const body = await addResp.json(); addedUUID = body.uuid as string; }); await test.step('Reload the whitelist tab to see the seeded entry', async () => { await page.goto('/security/crowdsec'); const whitelistTab = page.getByRole('tab', { name: 'Whitelist' }); await expect(whitelistTab).toBeVisible({ timeout: 15_000 }); await whitelistTab.click(); await expect(page.getByRole('cell', { name: testIP, exact: true })).toBeVisible({ timeout: 10_000 }); }); await test.step('Click the delete button for the entry', async () => { const deleteBtn = page.getByRole('button', { name: new RegExp(`Remove whitelist entry for ${testIP.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i'), }); await expect(deleteBtn).toBeVisible(); await deleteBtn.click(); }); await test.step('Verify the confirmation modal appears', async () => { const modal = page.getByRole('dialog'); await expect(modal).toBeVisible(); await expect(modal.locator('#whitelist-delete-modal-title')).toHaveText( 'Remove Whitelist Entry' ); await expect(modal).toMatchAriaSnapshot(` - dialog: - heading "Remove Whitelist Entry" [level=2] `); }); await test.step('Confirm deletion and verify the entry is removed', async () => { const deleteResponsePromise = page.waitForResponse( (resp) => resp.url().includes('/api/v1/admin/crowdsec/whitelist/') && resp.request().method() === 'DELETE' ); await page.getByRole('button', { name: 'Remove', exact: true }).click(); const deleteResponse = await deleteResponsePromise; expect(deleteResponse.ok()).toBeTruthy(); addedUUID = null; // cleaned up by the UI action await expect(page.getByRole('cell', { name: testIP, exact: true })).not.toBeVisible(); await expect(page.getByTestId('whitelist-empty')).toBeVisible(); }); } finally { // Fallback cleanup if the UI delete failed if (addedUUID) { await rc.delete(`/api/v1/admin/crowdsec/whitelist/${addedUUID}`); } } }); test('delete confirmation modal is dismissed by the Cancel button', async ({ page }) => { const testIP = `${TEST_IP_PREFIX}.5.10`; let addedUUID: string | null = null; try { await test.step('Pre-seed a whitelist entry via API', async () => { const addResp = await rc.post('/api/v1/admin/crowdsec/whitelist', { data: { ip_or_cidr: testIP, reason: 'cancel modal test' }, }); expect(addResp.status()).toBe(201); const body = await addResp.json(); addedUUID = body.uuid as string; }); await test.step('Reload the whitelist tab', async () => { await page.goto('/security/crowdsec'); const whitelistTab = page.getByRole('tab', { name: 'Whitelist' }); await expect(whitelistTab).toBeVisible({ timeout: 15_000 }); await whitelistTab.click(); await expect(page.getByRole('cell', { name: testIP, exact: true })).toBeVisible({ timeout: 10_000 }); }); await test.step('Open the delete modal', async () => { const deleteBtn = page.getByRole('button', { name: new RegExp(`Remove whitelist entry for ${testIP.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i'), }); await deleteBtn.click(); await expect(page.getByRole('dialog')).toBeVisible(); }); await test.step('Cancel and verify the entry is still present', async () => { await page.getByRole('button', { name: 'Cancel' }).click(); await expect(page.getByRole('dialog')).not.toBeVisible(); await expect(page.getByRole('cell', { name: testIP, exact: true })).toBeVisible(); }); } finally { if (addedUUID) { await rc.delete(`/api/v1/admin/crowdsec/whitelist/${addedUUID}`); } } }); test('add button is disabled when the IP field is empty', async ({ page }) => { await test.step('Verify add button is disabled with empty IP field', async () => { const ipInput = page.getByTestId('whitelist-ip-input'); const addBtn = page.getByTestId('whitelist-add-btn'); await expect(ipInput).toHaveValue(''); await expect(addBtn).toBeDisabled(); }); await test.step('Button becomes enabled when IP is entered', async () => { await page.getByTestId('whitelist-ip-input').fill('192.168.1.1'); await expect(page.getByTestId('whitelist-add-btn')).toBeEnabled(); }); await test.step('Button returns to disabled state when IP is cleared', async () => { await page.getByTestId('whitelist-ip-input').clear(); await expect(page.getByTestId('whitelist-add-btn')).toBeDisabled(); }); }); }); });