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
407 lines
16 KiB
TypeScript
Executable File
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();
|
|
});
|
|
});
|
|
});
|
|
});
|