Files
Charon/tests/crowdsec-whitelist.spec.ts
akanealw eec8c28fb3
Some checks are pending
Go Benchmark / Performance Regression Check (push) Waiting to run
Cerberus Integration / Cerberus Security Stack Integration (push) Waiting to run
Upload Coverage to Codecov / Backend Codecov Upload (push) Waiting to run
Upload Coverage to Codecov / Frontend Codecov Upload (push) Waiting to run
CodeQL - Analyze / CodeQL analysis (go) (push) Waiting to run
CodeQL - Analyze / CodeQL analysis (javascript-typescript) (push) Waiting to run
CrowdSec Integration / CrowdSec Bouncer Integration (push) Waiting to run
Docker Build, Publish & Test / build-and-push (push) Waiting to run
Docker Build, Publish & Test / Security Scan PR Image (push) Blocked by required conditions
Quality Checks / Auth Route Protection Contract (push) Waiting to run
Quality Checks / Codecov Trigger/Comment Parity Guard (push) Waiting to run
Quality Checks / Backend (Go) (push) Waiting to run
Quality Checks / Frontend (React) (push) Waiting to run
Rate Limit integration / Rate Limiting Integration (push) Waiting to run
Security Scan (PR) / Trivy Binary Scan (push) Waiting to run
Supply Chain Verification (PR) / Verify Supply Chain (push) Waiting to run
WAF integration / Coraza WAF Integration (push) Waiting to run
changed perms
2026-04-22 18:19:14 +00:00

407 lines
16 KiB
TypeScript
Executable File

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<APIRequestContext> {
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<void>;
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();
});
});
});
});