diff --git a/tests/crowdsec-whitelist.spec.ts b/tests/crowdsec-whitelist.spec.ts new file mode 100644 index 00000000..a0e72117 --- /dev/null +++ b/tests/crowdsec-whitelist.spec.ts @@ -0,0 +1,407 @@ +import { test, expect, request as playwrightRequest } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; +import { + withSecurityEnabled, + captureSecurityState, + setSecurityModuleEnabled, + getSecurityStatus, +} 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(); + }); + }); + }); +});