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
This commit is contained in:
GitHub Actions
2026-01-25 14:09:38 +00:00
parent a41cfaae10
commit 103f0e0ae9
8 changed files with 1193 additions and 71 deletions
+35
View File
@@ -4,10 +4,13 @@
* This setup ensures a clean test environment by:
* 1. Cleaning up any orphaned test data from previous runs
* 2. Verifying the application is accessible
* 3. Performing emergency ACL reset to prevent deadlock from previous failed runs
*/
import { request } from '@playwright/test';
import { existsSync } from 'fs';
import { TestDataManager } from './utils/TestDataManager';
import { STORAGE_STATE } from './constants';
/**
* Get the base URL for the application
@@ -83,6 +86,38 @@ async function globalSetup(): Promise<void> {
} finally {
await requestContext.dispose();
}
// Emergency ACL reset to prevent deadlock from previous failed runs
await emergencySecurityReset(baseURL);
}
/**
* Perform emergency security reset to disable ACL.
* This prevents deadlock if a previous test run left ACL enabled.
*/
async function emergencySecurityReset(baseURL: string): Promise<void> {
// Only run if auth state exists (meaning we can make authenticated requests)
if (!existsSync(STORAGE_STATE)) {
console.log('⏭️ Skipping security reset (no auth state file)');
return;
}
try {
const authenticatedContext = await request.newContext({
baseURL,
storageState: STORAGE_STATE,
});
// Disable ACL to prevent deadlock from previous failed runs
await authenticatedContext.post('/api/v1/settings', {
data: { key: 'security.acl.enabled', value: 'false' },
});
await authenticatedContext.dispose();
console.log('✓ Security reset: ACL disabled');
} catch (error) {
console.warn('⚠️ Could not reset security state:', error);
}
}
export default globalSetup;
+47 -35
View File
@@ -11,7 +11,14 @@
*/
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 }) => {
@@ -105,15 +112,46 @@ test.describe('Security Dashboard', () => {
});
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');
// Check if toggle is disabled (Cerberus must be enabled for toggles to work)
const isDisabled = await toggle.isDisabled();
if (isDisabled) {
test.info().annotations.push({
type: 'skip-reason',
description: 'Toggle is disabled because Cerberus security is not enabled'
description: 'Toggle is disabled because Cerberus security is not enabled',
});
test.skip();
return;
@@ -124,27 +162,20 @@ test.describe('Security Dashboard', () => {
await toggle.scrollIntoViewIfNeeded();
await page.waitForTimeout(200);
await toggle.click({ force: true });
// Wait for success toast to confirm action completed
await waitForToast(page, /updated|success|enabled|disabled/i, 10000);
});
await test.step('Toggle back to original state', async () => {
await page.waitForTimeout(200);
await toggle.scrollIntoViewIfNeeded();
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');
// Check if toggle is disabled (Cerberus must be enabled for toggles to work)
const isDisabled = await toggle.isDisabled();
if (isDisabled) {
test.info().annotations.push({
type: 'skip-reason',
description: 'Toggle is disabled because Cerberus security is not enabled'
description: 'Toggle is disabled because Cerberus security is not enabled',
});
test.skip();
return;
@@ -158,23 +189,17 @@ test.describe('Security Dashboard', () => {
await waitForToast(page, /updated|success|enabled|disabled/i, 10000);
});
await test.step('Toggle back', async () => {
await page.waitForTimeout(200);
await toggle.scrollIntoViewIfNeeded();
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');
// Check if toggle is disabled (Cerberus must be enabled for toggles to work)
const isDisabled = await toggle.isDisabled();
if (isDisabled) {
test.info().annotations.push({
type: 'skip-reason',
description: 'Toggle is disabled because Cerberus security is not enabled'
description: 'Toggle is disabled because Cerberus security is not enabled',
});
test.skip();
return;
@@ -188,23 +213,17 @@ test.describe('Security Dashboard', () => {
await waitForToast(page, /updated|success|enabled|disabled/i, 10000);
});
await test.step('Toggle back', async () => {
await page.waitForTimeout(200);
await toggle.scrollIntoViewIfNeeded();
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');
// Check if toggle is disabled (Cerberus must be enabled for toggles to work)
const isDisabled = await toggle.isDisabled();
if (isDisabled) {
test.info().annotations.push({
type: 'skip-reason',
description: 'Toggle is disabled because Cerberus security is not enabled'
description: 'Toggle is disabled because Cerberus security is not enabled',
});
test.skip();
return;
@@ -230,14 +249,7 @@ test.describe('Security Dashboard', () => {
expect(newChecked).toBe(!initialChecked);
});
await test.step('Restore original state', async () => {
await page.waitForLoadState('networkidle');
const restoreToggle = page.getByTestId('toggle-acl');
await restoreToggle.scrollIntoViewIfNeeded();
await page.waitForTimeout(200);
await restoreToggle.click({ force: true });
await waitForToast(page, /updated|success|enabled|disabled/i, 10000);
});
// NOTE: Do NOT restore here - afterAll handles cleanup
});
});
+283
View File
@@ -0,0 +1,283 @@
/**
* Security Test Helpers - Safe ACL/WAF/Rate Limit toggle for E2E tests
*
* These helpers provide safe mechanisms to temporarily enable security features
* during tests, with guaranteed cleanup even on test failure.
*
* Problem: If ACL is left enabled after a test failure, it blocks all API requests
* causing subsequent tests to fail with 403 Forbidden (deadlock).
*
* Solution: Use Playwright's test.afterAll() with captured original state to
* guarantee restoration regardless of test outcome.
*
* @example
* ```typescript
* import { withSecurityEnabled, getSecurityStatus } from './utils/security-helpers';
*
* test.describe('ACL Tests', () => {
* let cleanup: () => Promise<void>;
*
* test.beforeAll(async ({ request }) => {
* cleanup = await withSecurityEnabled(request, { acl: true });
* });
*
* test.afterAll(async () => {
* await cleanup();
* });
*
* test('should enforce ACL', async ({ page }) => {
* // ACL is now enabled, test enforcement
* });
* });
* ```
*/
import { APIRequestContext } from '@playwright/test';
/**
* Security module status from GET /api/v1/security/status
*/
export interface SecurityStatus {
cerberus: { enabled: boolean };
crowdsec: { mode: string; api_url: string; enabled: boolean };
waf: { mode: string; enabled: boolean };
rate_limit: { mode: string; enabled: boolean };
acl: { mode: string; enabled: boolean };
}
/**
* Options for enabling specific security modules
*/
export interface SecurityModuleOptions {
/** Enable ACL enforcement */
acl?: boolean;
/** Enable WAF protection */
waf?: boolean;
/** Enable rate limiting */
rateLimit?: boolean;
/** Enable CrowdSec */
crowdsec?: boolean;
/** Enable master Cerberus toggle (required for other modules) */
cerberus?: boolean;
}
/**
* Captured state for restoration
*/
export interface CapturedSecurityState {
acl: boolean;
waf: boolean;
rateLimit: boolean;
crowdsec: boolean;
cerberus: boolean;
}
/**
* Mapping of module names to their settings keys
*/
const SECURITY_SETTINGS_KEYS: Record<keyof SecurityModuleOptions, string> = {
acl: 'security.acl.enabled',
waf: 'security.waf.enabled',
rateLimit: 'security.rate_limit.enabled',
crowdsec: 'security.crowdsec.enabled',
cerberus: 'feature.cerberus.enabled',
};
/**
* Get current security status from the API
* @param request - Playwright APIRequestContext (authenticated)
* @returns Current security status
*/
export async function getSecurityStatus(
request: APIRequestContext
): Promise<SecurityStatus> {
const response = await request.get('/api/v1/security/status');
if (!response.ok()) {
throw new Error(
`Failed to get security status: ${response.status()} ${await response.text()}`
);
}
return response.json();
}
/**
* Set a specific security module's enabled state
* @param request - Playwright APIRequestContext (authenticated)
* @param module - Which module to toggle
* @param enabled - Whether to enable or disable
*/
export async function setSecurityModuleEnabled(
request: APIRequestContext,
module: keyof SecurityModuleOptions,
enabled: boolean
): Promise<void> {
const key = SECURITY_SETTINGS_KEYS[module];
const value = enabled ? 'true' : 'false';
const response = await request.post('/api/v1/settings', {
data: { key, value },
});
if (!response.ok()) {
throw new Error(
`Failed to set ${module} to ${enabled}: ${response.status()} ${await response.text()}`
);
}
// Wait a brief moment for Caddy config reload
await new Promise((resolve) => setTimeout(resolve, 500));
}
/**
* Capture current security state for later restoration
* @param request - Playwright APIRequestContext (authenticated)
* @returns Captured state object
*/
export async function captureSecurityState(
request: APIRequestContext
): Promise<CapturedSecurityState> {
const status = await getSecurityStatus(request);
return {
acl: status.acl.enabled,
waf: status.waf.enabled,
rateLimit: status.rate_limit.enabled,
crowdsec: status.crowdsec.enabled,
cerberus: status.cerberus.enabled,
};
}
/**
* Restore security state to previously captured values
* @param request - Playwright APIRequestContext (authenticated)
* @param state - Previously captured state
*/
export async function restoreSecurityState(
request: APIRequestContext,
state: CapturedSecurityState
): Promise<void> {
const currentStatus = await getSecurityStatus(request);
// Restore in reverse dependency order (features before master toggle)
const modules: (keyof SecurityModuleOptions)[] = ['acl', 'waf', 'rateLimit', 'crowdsec', 'cerberus'];
for (const module of modules) {
const currentValue = module === 'rateLimit'
? currentStatus.rate_limit.enabled
: module === 'crowdsec'
? currentStatus.crowdsec.enabled
: currentStatus[module].enabled;
if (currentValue !== state[module]) {
await setSecurityModuleEnabled(request, module, state[module]);
}
}
}
/**
* Enable security modules temporarily with guaranteed cleanup.
*
* Returns a cleanup function that MUST be called in test.afterAll().
* The cleanup function restores the original state even if tests fail.
*
* @param request - Playwright APIRequestContext (authenticated)
* @param options - Which modules to enable
* @returns Cleanup function to restore original state
*
* @example
* ```typescript
* test.describe('ACL Tests', () => {
* let cleanup: () => Promise<void>;
*
* test.beforeAll(async ({ request }) => {
* cleanup = await withSecurityEnabled(request, { acl: true, cerberus: true });
* });
*
* test.afterAll(async () => {
* await cleanup();
* });
* });
* ```
*/
export async function withSecurityEnabled(
request: APIRequestContext,
options: SecurityModuleOptions
): Promise<() => Promise<void>> {
// Capture original state BEFORE making any changes
const originalState = await captureSecurityState(request);
// Enable Cerberus first (master toggle) if any security module is requested
const needsCerberus = options.acl || options.waf || options.rateLimit || options.crowdsec;
if ((needsCerberus || options.cerberus) && !originalState.cerberus) {
await setSecurityModuleEnabled(request, 'cerberus', true);
}
// Enable requested modules
if (options.acl) {
await setSecurityModuleEnabled(request, 'acl', true);
}
if (options.waf) {
await setSecurityModuleEnabled(request, 'waf', true);
}
if (options.rateLimit) {
await setSecurityModuleEnabled(request, 'rateLimit', true);
}
if (options.crowdsec) {
await setSecurityModuleEnabled(request, 'crowdsec', true);
}
// Return cleanup function that restores original state
return async () => {
try {
await restoreSecurityState(request, originalState);
} catch (error) {
// Log error but don't throw - cleanup should not fail tests
console.error('Failed to restore security state:', error);
// Try emergency disable of ACL to prevent deadlock
try {
await setSecurityModuleEnabled(request, 'acl', false);
} catch {
console.error('Emergency ACL disable also failed - manual intervention may be required');
}
}
};
}
/**
* Disable all security modules (emergency reset).
* Use this in global-setup.ts or when tests need a clean slate.
*
* @param request - Playwright APIRequestContext (authenticated)
*/
export async function disableAllSecurityModules(
request: APIRequestContext
): Promise<void> {
const modules: (keyof SecurityModuleOptions)[] = ['acl', 'waf', 'rateLimit', 'crowdsec'];
for (const module of modules) {
try {
await setSecurityModuleEnabled(request, module, false);
} catch (error) {
console.warn(`Failed to disable ${module}:`, error);
}
}
}
/**
* Check if ACL is currently blocking requests.
* Useful for debugging test failures.
*
* @param request - Playwright APIRequestContext
* @returns True if ACL is enabled and blocking
*/
export async function isAclBlocking(request: APIRequestContext): Promise<boolean> {
try {
const status = await getSecurityStatus(request);
return status.acl.enabled && status.cerberus.enabled;
} catch {
// If we can't get status, ACL might be blocking
return true;
}
}