Files
Charon/tests/security/security-dashboard.spec.ts
GitHub Actions 103f0e0ae9 fix: resolve WAF integration failure and E2E ACL deadlock
Fix integration scripts using wget-style curl options after Alpine→Debian
migration (PR #550). Add Playwright security test helpers to prevent ACL
from blocking subsequent tests.

Fix curl syntax in 5 scripts: -q -O- → -sf
Create security-helpers.ts with state capture/restore
Add emergency ACL reset to global-setup.ts
Fix fixture reuse bug in security-dashboard.spec.ts
Add security-helpers.md usage guide
Resolves WAF workflow "httpbin backend failed to start" error
2026-01-25 14:09:38 +00:00

425 lines
16 KiB
TypeScript

/**
* Security Dashboard E2E Tests
*
* Tests the Security (Cerberus) dashboard functionality including:
* - Page loading and layout
* - Module toggle states (CrowdSec, ACL, WAF, Rate Limiting)
* - Status indicators
* - Navigation to sub-pages
*
* @see /projects/Charon/docs/plans/current_spec.md - Phase 3
*/
import { test, expect, loginUser } from '../fixtures/auth-fixtures';
import { request } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import { waitForLoadingComplete, waitForToast } from '../utils/wait-helpers';
import {
captureSecurityState,
restoreSecurityState,
CapturedSecurityState,
} from '../utils/security-helpers';
test.describe('Security Dashboard', () => {
test.beforeEach(async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await waitForLoadingComplete(page);
await page.goto('/security');
await waitForLoadingComplete(page);
});
test.describe('Page Loading', () => {
test('should display security dashboard page title', async ({ page }) => {
await expect(page.getByRole('heading', { name: /security/i }).first()).toBeVisible();
});
test('should display Cerberus dashboard header', async ({ page }) => {
await expect(page.getByText(/cerberus.*dashboard/i)).toBeVisible();
});
test('should show all 4 security module cards', async ({ page }) => {
await test.step('Verify CrowdSec card exists', async () => {
await expect(page.getByText(/crowdsec/i).first()).toBeVisible();
});
await test.step('Verify ACL card exists', async () => {
await expect(page.getByText(/access.*control/i).first()).toBeVisible();
});
await test.step('Verify WAF card exists', async () => {
await expect(page.getByText(/coraza.*waf|waf/i).first()).toBeVisible();
});
await test.step('Verify Rate Limiting card exists', async () => {
await expect(page.getByText(/rate.*limiting/i).first()).toBeVisible();
});
});
test('should display layer badges for each module', async ({ page }) => {
await expect(page.getByText(/layer.*1/i)).toBeVisible();
await expect(page.getByText(/layer.*2/i)).toBeVisible();
await expect(page.getByText(/layer.*3/i)).toBeVisible();
await expect(page.getByText(/layer.*4/i)).toBeVisible();
});
test('should show audit logs button in header', async ({ page }) => {
const auditLogsButton = page.getByRole('button', { name: /audit.*logs/i });
await expect(auditLogsButton).toBeVisible();
});
test('should show docs button in header', async ({ page }) => {
const docsButton = page.getByRole('button', { name: /docs/i });
await expect(docsButton).toBeVisible();
});
});
test.describe('Module Status Indicators', () => {
test('should show enabled/disabled badge for each module', async ({ page }) => {
// Each card should have an enabled or disabled badge
// Look for text that matches enabled/disabled patterns
// The Badge component may use various styling approaches
await page.waitForTimeout(500); // Wait for UI to settle
const enabledTexts = page.getByText(/^enabled$/i);
const disabledTexts = page.getByText(/^disabled$/i);
const enabledCount = await enabledTexts.count();
const disabledCount = await disabledTexts.count();
// Should have at least 4 status badges (one per security layer card)
expect(enabledCount + disabledCount).toBeGreaterThanOrEqual(4);
});
test('should display CrowdSec toggle switch', async ({ page }) => {
const toggle = page.getByTestId('toggle-crowdsec');
await expect(toggle).toBeVisible();
});
test('should display ACL toggle switch', async ({ page }) => {
const toggle = page.getByTestId('toggle-acl');
await expect(toggle).toBeVisible();
});
test('should display WAF toggle switch', async ({ page }) => {
const toggle = page.getByTestId('toggle-waf');
await expect(toggle).toBeVisible();
});
test('should display Rate Limiting toggle switch', async ({ page }) => {
const toggle = page.getByTestId('toggle-rate-limit');
await expect(toggle).toBeVisible();
});
});
test.describe('Module Toggle Actions', () => {
// Capture state ONCE for this describe block
let originalState: CapturedSecurityState;
test.beforeAll(async ({ request: reqFixture }) => {
try {
originalState = await captureSecurityState(reqFixture);
} catch (error) {
console.warn('Could not capture initial security state:', error);
}
});
test.afterAll(async () => {
// CRITICAL: Restore original state even if tests fail
if (!originalState) {
return;
}
// Create fresh request context for cleanup (cannot reuse fixture from beforeAll)
const cleanupRequest = await request.newContext({
baseURL: 'http://localhost:8080',
});
try {
await restoreSecurityState(cleanupRequest, originalState);
console.log('✓ Security state restored after toggle tests');
} catch (error) {
console.error('Failed to restore security state:', error);
} finally {
await cleanupRequest.dispose();
}
});
test('should toggle ACL enabled/disabled', async ({ page }) => {
const toggle = page.getByTestId('toggle-acl');
const isDisabled = await toggle.isDisabled();
if (isDisabled) {
test.info().annotations.push({
type: 'skip-reason',
description: 'Toggle is disabled because Cerberus security is not enabled',
});
test.skip();
return;
}
await test.step('Toggle ACL state', async () => {
await page.waitForLoadState('networkidle');
await toggle.scrollIntoViewIfNeeded();
await page.waitForTimeout(200);
await toggle.click({ force: true });
await waitForToast(page, /updated|success|enabled|disabled/i, 10000);
});
// NOTE: Do NOT toggle back here - afterAll handles cleanup
});
test('should toggle WAF enabled/disabled', async ({ page }) => {
const toggle = page.getByTestId('toggle-waf');
const isDisabled = await toggle.isDisabled();
if (isDisabled) {
test.info().annotations.push({
type: 'skip-reason',
description: 'Toggle is disabled because Cerberus security is not enabled',
});
test.skip();
return;
}
await test.step('Toggle WAF state', async () => {
await page.waitForLoadState('networkidle');
await toggle.scrollIntoViewIfNeeded();
await page.waitForTimeout(200);
await toggle.click({ force: true });
await waitForToast(page, /updated|success|enabled|disabled/i, 10000);
});
// NOTE: Do NOT toggle back here - afterAll handles cleanup
});
test('should toggle Rate Limiting enabled/disabled', async ({ page }) => {
const toggle = page.getByTestId('toggle-rate-limit');
const isDisabled = await toggle.isDisabled();
if (isDisabled) {
test.info().annotations.push({
type: 'skip-reason',
description: 'Toggle is disabled because Cerberus security is not enabled',
});
test.skip();
return;
}
await test.step('Toggle Rate Limit state', async () => {
await page.waitForLoadState('networkidle');
await toggle.scrollIntoViewIfNeeded();
await page.waitForTimeout(200);
await toggle.click({ force: true });
await waitForToast(page, /updated|success|enabled|disabled/i, 10000);
});
// NOTE: Do NOT toggle back here - afterAll handles cleanup
});
test('should persist toggle state after page reload', async ({ page }) => {
const toggle = page.getByTestId('toggle-acl');
const isDisabled = await toggle.isDisabled();
if (isDisabled) {
test.info().annotations.push({
type: 'skip-reason',
description: 'Toggle is disabled because Cerberus security is not enabled',
});
test.skip();
return;
}
const initialChecked = await toggle.isChecked();
await test.step('Toggle ACL state', async () => {
await page.waitForLoadState('networkidle');
await toggle.scrollIntoViewIfNeeded();
await page.waitForTimeout(200);
await toggle.click({ force: true });
await waitForToast(page, /updated|success|enabled|disabled/i, 10000);
});
await test.step('Reload page', async () => {
await page.reload();
await waitForLoadingComplete(page);
});
await test.step('Verify state persisted', async () => {
const newChecked = await page.getByTestId('toggle-acl').isChecked();
expect(newChecked).toBe(!initialChecked);
});
// NOTE: Do NOT restore here - afterAll handles cleanup
});
});
test.describe('Navigation', () => {
test('should navigate to CrowdSec page when configure clicked', async ({ page }) => {
// Find the CrowdSec card by locating the configure button within a container that has CrowdSec text
// Cards use rounded-lg border classes, not [class*="card"]
const crowdsecSection = page.locator('div').filter({ hasText: /crowdsec/i }).filter({ has: page.getByRole('button', { name: /configure/i }) }).first();
const configureButton = crowdsecSection.getByRole('button', { name: /configure/i });
// Button may be disabled when Cerberus is off
const isDisabled = await configureButton.isDisabled().catch(() => true);
if (isDisabled) {
test.info().annotations.push({
type: 'skip-reason',
description: 'Configure button is disabled because Cerberus security is not enabled'
});
test.skip();
return;
}
// Wait for any loading overlays to disappear
await page.waitForLoadState('networkidle');
await page.waitForTimeout(300);
// Scroll element into view and use force click to bypass pointer interception
await configureButton.scrollIntoViewIfNeeded();
await configureButton.click({ force: true });
await expect(page).toHaveURL(/\/security\/crowdsec/);
});
test('should navigate to Access Lists page when clicked', async ({ page }) => {
// The ACL card has a "Manage Lists" or "Configure" button
// Find button by looking for buttons with matching text within the page
const allConfigButtons = page.getByRole('button', { name: /manage.*lists|configure/i });
const count = await allConfigButtons.count();
// The ACL button should be the second configure button (after CrowdSec)
// Or we can find it near the "Access Control" or "ACL" text
let aclButton = null;
for (let i = 0; i < count; i++) {
const btn = allConfigButtons.nth(i);
const btnText = await btn.textContent();
// The ACL button says "Manage Lists" when enabled, "Configure" when disabled
if (btnText?.match(/manage.*lists/i)) {
aclButton = btn;
break;
}
}
// Fallback to second configure button if no "Manage Lists" found
if (!aclButton) {
aclButton = allConfigButtons.nth(1);
}
// Wait for any loading overlays and scroll into view
await page.waitForLoadState('networkidle');
await aclButton.scrollIntoViewIfNeeded();
await page.waitForTimeout(200);
await aclButton.click({ force: true });
await expect(page).toHaveURL(/\/security\/access-lists|\/access-lists/);
});
test('should navigate to WAF page when configure clicked', async ({ page }) => {
// WAF is Layer 3 - the third configure button in the security cards grid
const allConfigButtons = page.getByRole('button', { name: /configure/i });
const count = await allConfigButtons.count();
// Should have at least 3 configure buttons (CrowdSec, ACL/Manage Lists, WAF)
if (count < 3) {
test.info().annotations.push({
type: 'skip-reason',
description: 'Not enough configure buttons found on page'
});
test.skip();
return;
}
// WAF is the 3rd configure button (index 2)
const wafButton = allConfigButtons.nth(2);
// Wait and scroll into view
await page.waitForLoadState('networkidle');
await wafButton.scrollIntoViewIfNeeded();
await page.waitForTimeout(200);
await wafButton.click({ force: true });
await expect(page).toHaveURL(/\/security\/waf/);
});
test('should navigate to Rate Limiting page when configure clicked', async ({ page }) => {
// Rate Limiting is Layer 4 - the fourth configure button in the security cards grid
const allConfigButtons = page.getByRole('button', { name: /configure/i });
const count = await allConfigButtons.count();
// Should have at least 4 configure buttons
if (count < 4) {
test.info().annotations.push({
type: 'skip-reason',
description: 'Not enough configure buttons found on page'
});
test.skip();
return;
}
// Rate Limit is the 4th configure button (index 3)
const rateLimitButton = allConfigButtons.nth(3);
// Wait and scroll into view
await page.waitForLoadState('networkidle');
await rateLimitButton.scrollIntoViewIfNeeded();
await page.waitForTimeout(200);
await rateLimitButton.click({ force: true });
await expect(page).toHaveURL(/\/security\/rate-limiting/);
});
test('should navigate to Audit Logs page', async ({ page }) => {
const auditLogsButton = page.getByRole('button', { name: /audit.*logs/i });
await auditLogsButton.click();
await expect(page).toHaveURL(/\/security\/audit-logs/);
});
});
test.describe('Admin Whitelist', () => {
test('should display admin whitelist section when Cerberus enabled', async ({ page }) => {
// Check if the admin whitelist input is visible (only shown when Cerberus is enabled)
const whitelistInput = page.getByPlaceholder(/192\.168|cidr/i);
const isVisible = await whitelistInput.isVisible().catch(() => false);
if (isVisible) {
await expect(whitelistInput).toBeVisible();
} else {
// Cerberus might be disabled - just verify the page loaded correctly
// by checking for the Cerberus Dashboard header which is always visible
const cerberusHeader = page.getByText(/cerberus.*dashboard/i);
await expect(cerberusHeader).toBeVisible();
test.info().annotations.push({
type: 'info',
description: 'Admin whitelist section not visible - Cerberus may be disabled'
});
}
});
});
test.describe('Accessibility', () => {
test('should have accessible toggle switches with labels', async ({ page }) => {
// Each toggle should be within a tooltip that describes its purpose
// The Switch component uses an input[type="checkbox"] under the hood
const toggles = [
page.getByTestId('toggle-crowdsec'),
page.getByTestId('toggle-acl'),
page.getByTestId('toggle-waf'),
page.getByTestId('toggle-rate-limit'),
];
for (const toggle of toggles) {
await expect(toggle).toBeVisible();
// Switch uses checkbox input type (visually styled as toggle)
await expect(toggle).toHaveAttribute('type', 'checkbox');
}
});
test('should navigate with keyboard', async ({ page }) => {
await test.step('Tab through header buttons', async () => {
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
// Should be able to tab to various interactive elements
});
});
});
});