# Security Module Testing Plan: Toggle-On-Test-Toggle-Off Pattern
**Plan ID**: SEC-TEST-2026-001
**Status**: ✅ APPROVED (Supervisor Review: 2026-01-25)
**Priority**: HIGH
**Created**: 2026-01-25
**Updated**: 2026-01-25 (Added Phase -1: Container Startup Fix)
**Branch**: feature/beta-release
**Scope**: Complete security module testing with toggle-on-test-toggle-off pattern
---
## Executive Summary
This plan provides a **definitive testing strategy** for ALL security modules in Charon. Each module will be tested with the **toggle-on-test-toggle-off** pattern to:
1. Verify security features work when enabled
2. Ensure tests don't leave security features in a state that blocks other tests
3. Provide comprehensive coverage of security blocking behavior
---
## Security Module Inventory
### Complete Module List
| Layer | Module | Toggle Key | Implementation | Blocks Requests? |
|-------|--------|------------|----------------|------------------|
| **Master** | Cerberus | `feature.cerberus.enabled` | Backend middleware + Caddy | Controls all layers |
| **Layer 1** | CrowdSec | `security.crowdsec.enabled` | Caddy bouncer plugin | ✅ Yes (IP bans) |
| **Layer 2** | ACL | `security.acl.enabled` | Cerberus middleware | ✅ Yes (IP whitelist/blacklist) |
| **Layer 3** | WAF (Coraza) | `security.waf.enabled` | Caddy Coraza plugin | ✅ Yes (malicious requests) |
| **Layer 4** | Rate Limiting | `security.rate_limit.enabled` | Caddy rate limiter | ✅ Yes (threshold exceeded) |
| **Layer 5** | Security Headers | N/A (per-host) | Caddy headers | ❌ No (affects behavior) |
---
## 1. API Endpoints for Each Module
### 1.1 Master Toggle (Cerberus)
```http
POST /api/v1/settings
Content-Type: application/json
{ "key": "feature.cerberus.enabled", "value": "true" | "false" }
```
**Implementation**: [settings_handler.go](../../backend/internal/api/handlers/settings_handler.go#L73-L108)
**Effect**: When disabled, ALL security modules are disabled regardless of individual settings.
### 1.2 ACL (Access Control Lists)
```http
POST /api/v1/settings
{ "key": "security.acl.enabled", "value": "true" | "false" }
```
**Get Status**:
```http
GET /api/v1/security/status
Returns: { "acl": { "mode": "enabled", "enabled": true } }
```
**Implementation**:
- [cerberus.go](../../backend/internal/cerberus/cerberus.go#L135-L160) - Middleware blocks requests
- [access_list_handler.go](../../backend/internal/api/handlers/access_list_handler.go) - CRUD operations
**Blocking Logic** (from cerberus.go):
```go
for _, acl := range acls {
allowed, _, err := c.accessSvc.TestIP(acl.ID, clientIP)
if err == nil && !allowed {
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Blocked by access control list"})
return
}
}
```
### 1.3 CrowdSec
```http
POST /api/v1/settings
{ "key": "security.crowdsec.enabled", "value": "true" | "false" }
```
**Mode setting**:
```http
POST /api/v1/settings
{ "key": "security.crowdsec.mode", "value": "local" | "disabled" }
```
**Implementation**:
- [crowdsec_handler.go](../../backend/internal/api/handlers/crowdsec_handler.go) - API handlers
- Caddy crowdsec-bouncer plugin - Actual blocking at proxy layer
### 1.4 WAF (Coraza)
```http
POST /api/v1/settings
{ "key": "security.waf.enabled", "value": "true" | "false" }
```
**Implementation**:
- [security_handler.go](../../backend/internal/api/handlers/security_handler.go#L51-L130) - Status and config
- Caddy Coraza plugin - Actual blocking (SQL injection, XSS, etc.)
### 1.5 Rate Limiting
```http
POST /api/v1/settings
{ "key": "security.rate_limit.enabled", "value": "true" | "false" }
```
**Implementation**:
- [security_handler.go](../../backend/internal/api/handlers/security_handler.go#L425-L460) - Presets
- Caddy rate limiter directive - Actual blocking
### 1.6 Security Headers
**No global toggle** - Applied per proxy host via:
```http
POST /api/v1/proxy-hosts/:id
{ "securityHeaders": { "hsts": true, "csp": "...", ... } }
```
---
## 2. Existing Test Inventory
### 2.1 Test Files by Security Module
| Module | E2E Test Files | Backend Unit Test Files |
|--------|----------------|-------------------------|
| **ACL** | [access-lists-crud.spec.ts](../../tests/core/access-lists-crud.spec.ts) (35+ tests), [proxy-acl-integration.spec.ts](../../tests/integration/proxy-acl-integration.spec.ts) (18 tests) | access_list_handler_test.go, access_list_service_test.go |
| **CrowdSec** | [crowdsec-config.spec.ts](../../tests/security/crowdsec-config.spec.ts) (12 tests), [crowdsec-decisions.spec.ts](../../tests/security/crowdsec-decisions.spec.ts) | crowdsec_handler_test.go (20+ tests) |
| **WAF** | [waf-config.spec.ts](../../tests/security/waf-config.spec.ts) (15 tests) | security_handler_waf_test.go |
| **Rate Limiting** | [rate-limiting.spec.ts](../../tests/security/rate-limiting.spec.ts) (14 tests) | security_ratelimit_test.go |
| **Security Headers** | [security-headers.spec.ts](../../tests/security/security-headers.spec.ts) (16 tests) | security_headers_handler_test.go |
| **Dashboard** | [security-dashboard.spec.ts](../../tests/security/security-dashboard.spec.ts) (20 tests) | N/A |
| **Integration** | [security-suite-integration.spec.ts](../../tests/integration/security-suite-integration.spec.ts) (23 tests) | N/A |
### 2.2 Coverage Gaps (Blocking Tests Needed)
| Module | What's Tested | What's Missing |
|--------|---------------|----------------|
| **ACL** | CRUD, UI toggles, API TestIP | ❌ E2E blocking verification (real HTTP blocked) |
| **CrowdSec** | UI config, decisions display | ❌ E2E IP ban blocking verification |
| **WAF** | UI config, mode toggle | ❌ E2E SQL injection/XSS blocking verification |
| **Rate Limiting** | UI config, settings | ❌ E2E threshold exceeded blocking |
| **Security Headers** | UI config, profiles | ⚠️ Headers present but not enforcement |
---
## 3. Proposed Playwright Project Structure
### 3.1 Test Execution Flow
```text
┌──────────────────┐
│ global-setup │ ← Disable ALL security (clean slate)
└────────┬─────────┘
│
┌────────▼─────────┐
│ setup │ ← auth.setup.ts (login, save state)
└────────┬─────────┘
│
┌────────▼─────────────────────────────────────────────────────┐
│ security-tests (sequential) │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ acl-tests │→ │ waf-tests │→ │crowdsec-tests│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ rate-limit │→ │sec-headers │→ │ combined │ │
│ │ -tests │ │ -tests │ │ -tests │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└────────────────────────────┬─────────────────────────────────┘
│
┌────────────────────────────▼─────────────────────────────────┐
│ security-teardown │
│ │
│ Disable: ACL, CrowdSec, WAF, Rate Limiting │
│ Restore: Cerberus to disabled state │
└────────────────────────────┬─────────────────────────────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
┌────▼────┐ ┌─────▼────┐ ┌─────▼────┐
│chromium │ │ firefox │ │ webkit │
└─────────┘ └──────────┘ └──────────┘
All run with security modules DISABLED
```
### 3.2 Why Sequential for Security Tests?
Security tests must run **sequentially** (not parallel) because:
1. **Shared state**: All modules share the Cerberus master toggle
2. **Port conflicts**: Tests may use the same proxy hosts
3. **Blocking cascade**: One module enabled can block another's test requests
4. **Cleanup dependencies**: Each module must be disabled before the next runs
### 3.3 Updated `playwright.config.js`
```javascript
projects: [
// 1. Setup project - authentication (runs FIRST)
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
// 2. Security Tests - Run WITH security enabled (SEQUENTIAL, headless Chromium)
{
name: 'security-tests',
testDir: './tests/security-enforcement',
dependencies: ['setup'],
teardown: 'security-teardown',
fullyParallel: false, // Force sequential - modules share state
use: {
...devices['Desktop Chrome'],
headless: true, // Security tests are API-level, don't need headed
},
},
// 3. Security Teardown - Disable ALL security modules
{
name: 'security-teardown',
testMatch: /security-teardown\.setup\.ts/,
},
// 4. Browser projects - Depend on TEARDOWN to ensure security is disabled
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
dependencies: ['setup', 'security-teardown'], // Explicit teardown dependency
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'], storageState: STORAGE_STATE },
dependencies: ['setup', 'security-teardown'],
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'], storageState: STORAGE_STATE },
dependencies: ['setup', 'security-teardown'],
},
],
```
---
## 4. New Test Files Needed
### 4.1 Directory Structure
```text
tests/
├── security-enforcement/ ← NEW FOLDER (no numeric prefixes - order via project config)
│ ├── acl-enforcement.spec.ts
│ ├── waf-enforcement.spec.ts ← Requires Caddy proxy running
│ ├── crowdsec-enforcement.spec.ts
│ ├── rate-limit-enforcement.spec.ts ← Requires Caddy proxy running
│ ├── security-headers-enforcement.spec.ts
│ └── combined-enforcement.spec.ts
├── security-teardown.setup.ts ← NEW FILE
├── security/ ← EXISTING (UI config tests)
│ ├── security-dashboard.spec.ts
│ ├── waf-config.spec.ts
│ ├── rate-limiting.spec.ts
│ ├── crowdsec-config.spec.ts
│ ├── crowdsec-decisions.spec.ts
│ ├── security-headers.spec.ts
│ └── audit-logs.spec.ts
└── utils/
└── security-helpers.ts ← EXISTING (to enhance)
```
### 4.2 Test File Specifications
#### `acl-enforcement.spec.ts` (5 tests)
| Test | Description |
|------|-------------|
| `should verify ACL is enabled` | Check security status returns acl.enabled=true |
| `should block IP not in whitelist` | Create whitelist ACL, verify 403 for excluded IP |
| `should allow IP in whitelist` | Add test IP to whitelist, verify 200 |
| `should block IP in blacklist` | Create blacklist with test IP, verify 403 |
| `should show correct error message` | Verify "Blocked by access control list" message |
#### `waf-enforcement.spec.ts` (4 tests) — Requires Caddy Proxy
| Test | Description |
|------|-------------|
| `should verify WAF is enabled` | Check security status returns waf.enabled=true |
| `should block SQL injection attempt` | Send `' OR 1=1--` in query, verify 403/418 |
| `should block XSS attempt` | Send ``, verify 403/418 |
| `should allow legitimate requests` | Verify normal requests pass through |
#### `crowdsec-enforcement.spec.ts` (3 tests)
| Test | Description |
|------|-------------|
| `should verify CrowdSec is enabled` | Check crowdsec.enabled=true, mode="local" |
| `should create manual ban decision` | POST to /api/v1/security/decisions |
| `should list ban decisions` | GET /api/v1/security/decisions |
#### `rate-limit-enforcement.spec.ts` (3 tests) — Requires Caddy Proxy
| Test | Description |
|------|-------------|
| `should verify rate limiting is enabled` | Check rate_limit.enabled=true |
| `should return rate limit presets` | GET /api/v1/security/rate-limit-presets |
| `should document threshold behavior` | Describe expected 429 behavior |
#### `security-headers-enforcement.spec.ts` (4 tests)
| Test | Description |
|------|-------------|
| `should return X-Content-Type-Options` | Check header = 'nosniff' |
| `should return X-Frame-Options` | Check header = 'DENY' or 'SAMEORIGIN' |
| `should return HSTS on HTTPS` | Check Strict-Transport-Security |
| `should return CSP when configured` | Check Content-Security-Policy |
#### `combined-enforcement.spec.ts` (5 tests)
| Test | Description |
|------|-------------|
| `should enable all modules simultaneously` | Enable all, verify all status=true |
| `should log security events to audit log` | Verify audit entries created |
| `should handle rapid module toggle without race conditions` | Toggle on/off quickly, verify stable state |
| `should persist settings across page reload` | Toggle, refresh, verify settings retained |
| `should enforce priority when multiple modules conflict` | ACL + WAF both enabled, verify correct behavior |
#### `security-teardown.setup.ts`
Disables all security modules with error handling (continue-on-error pattern):
```typescript
import { test as teardown } from '@bgotink/playwright-coverage';
import { request } from '@playwright/test';
teardown('disable-all-security-modules', async () => {
const modules = [
{ key: 'security.acl.enabled', value: 'false' },
{ key: 'security.waf.enabled', value: 'false' },
{ key: 'security.crowdsec.enabled', value: 'false' },
{ key: 'security.rate_limit.enabled', value: 'false' },
{ key: 'feature.cerberus.enabled', value: 'false' },
];
const requestContext = await request.newContext({
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080',
storageState: 'playwright/.auth/user.json',
});
const errors: string[] = [];
for (const { key, value } of modules) {
try {
await requestContext.post('/api/v1/settings', { data: { key, value } });
console.log(`✓ Disabled: ${key}`);
} catch (e) {
errors.push(`Failed to disable ${key}: ${e}`);
}
}
await requestContext.dispose();
// Stabilization delay - wait for Caddy config reload
await new Promise(resolve => setTimeout(resolve, 1000));
if (errors.length > 0) {
console.error('Security teardown had errors (continuing anyway):', errors.join('\n'));
// Don't throw - let other tests run even if teardown partially failed
}
});
```
---
## 5. Questions Answered
### Q1: What's the API to toggle each module?
| Module | Setting Key | Values |
|--------|-------------|--------|
| Cerberus (Master) | `feature.cerberus.enabled` | `"true"` / `"false"` |
| ACL | `security.acl.enabled` | `"true"` / `"false"` |
| CrowdSec | `security.crowdsec.enabled` | `"true"` / `"false"` |
| WAF | `security.waf.enabled` | `"true"` / `"false"` |
| Rate Limiting | `security.rate_limit.enabled` | `"true"` / `"false"` |
All via: `POST /api/v1/settings` with `{ "key": "", "value": "" }`
### Q2: Should security tests run sequentially or parallel?
**SEQUENTIAL** - Because:
- Modules share Cerberus master toggle
- Enabling one module can block other tests
- Race conditions in security state
- Cleanup dependencies between modules
### Q3: One teardown or separate per module?
**ONE TEARDOWN** - Using Playwright's `teardown` project relationship:
- Runs after ALL security tests complete
- Disables ALL modules in one sweep
- Guaranteed to run even if tests fail
- Simpler maintenance
### Q4: Minimum tests per module?
| Module | Minimum Tests | Requires Caddy? |
|--------|---------------|----------------|
| ACL | 5 | No (Backend) |
| WAF | 4 | Yes |
| CrowdSec | 3 | No (API) |
| Rate Limiting | 3 | Yes |
| Security Headers | 4 | No |
| Combined | 5 | Partial |
| **Total** | **24** | |
---
## 6. Implementation Checklist
### Phase -1: Container Startup Fix (URGENT BLOCKER - 15 min)
**STATUS**: 🔴 BLOCKING — E2E tests cannot run until this is fixed
**Problem**: Docker entrypoint creates directories as root before dropping privileges to `charon` user, causing Caddy permission errors:
```
{"error":"save snapshot: write snapshot: open /app/data/caddy/config-1769363949.json: permission denied"}
```
**Evidence** (from `docker exec charon-e2e ls -la /app/data/`):
```
drwxr-xr-x 2 root root 40 Jan 25 17:59 caddy <-- WRONG: root ownership
drwxr-xr-x 2 root root 40 Jan 25 17:59 geoip <-- WRONG: root ownership
drwxr-xr-x 2 charon charon 100 Jan 25 17:59 crowdsec <-- CORRECT
```
**Required Fix** in `.docker/docker-entrypoint.sh`:
After the mkdir block (around line 35), add ownership fix:
```bash
# Fix ownership for directories created as root
if is_root; then
chown -R charon:charon /app/data/caddy 2>/dev/null || true
chown -R charon:charon /app/data/crowdsec 2>/dev/null || true
chown -R charon:charon /app/data/geoip 2>/dev/null || true
fi
```
- [ ] **Fix docker-entrypoint.sh**: Add chown commands after mkdir block
- [ ] **Rebuild E2E container**: Run `.github/skills/scripts/skill-runner.sh docker-rebuild-e2e`
- [ ] **Verify fix**: Confirm `ls -la /app/data/` shows `charon:charon` ownership
---
### Phase 0: Critical Fixes (Blocking - 30 min)
**From Supervisor Review — MUST FIX BEFORE PROCEEDING:**
- [ ] **Fix hardcoded IP**: Change `tests/global-setup.ts` line 17 from `100.98.12.109` to `localhost`
- [ ] **Expand emergency reset**: Update `emergencySecurityReset()` in `global-setup.ts` to disable ALL security modules (not just ACL)
- [ ] **Add failsafe**: Global-setup should attempt to disable all security modules BEFORE auth (crash protection)
### Phase 1: Infrastructure (1 hour)
- [ ] Create `tests/security-enforcement/` directory
- [ ] Create `tests/security-teardown.setup.ts` (with error handling + stabilization delay)
- [ ] Update `playwright.config.js` with security-tests and security-teardown projects
- [ ] Enhance `tests/utils/security-helpers.ts`
### Phase 2: Enforcement Tests (3 hours)
- [ ] Create `acl-enforcement.spec.ts` (5 tests)
- [ ] Create `waf-enforcement.spec.ts` (4 tests) — requires Caddy
- [ ] Create `crowdsec-enforcement.spec.ts` (3 tests)
- [ ] Create `rate-limit-enforcement.spec.ts` (3 tests) — requires Caddy
- [ ] Create `security-headers-enforcement.spec.ts` (4 tests)
- [ ] Create `combined-enforcement.spec.ts` (5 tests)
### Phase 3: Verification (1 hour)
- [ ] Run: `npx playwright test --project=security-tests`
- [ ] Verify teardown disables all modules
- [ ] Run full suite: `npx playwright test`
- [ ] Verify < 10 failures (only genuine issues)
---
## 7. Success Criteria
| Metric | Before | Target |
|--------|--------|--------|
| Security enforcement tests | 0 | 24 |
| Test failures from ACL blocking | 222 | 0 |
| Security module toggle coverage | Partial | 100% |
| CI security test job | N/A | Passing |
---
## References
- [Playwright Project Dependencies](https://playwright.dev/docs/test-projects#dependencies)
- [Playwright Teardown](https://playwright.dev/docs/test-global-setup-teardown#teardown)
- [Security Helpers](../../tests/utils/security-helpers.ts)
- [Cerberus Middleware](../../backend/internal/cerberus/cerberus.go)
- [Security Handler](../../backend/internal/api/handlers/security_handler.go)
---
## 8. Known Pre-existing Test Failures (Not Blocking)
**Analysis Date**: 2026-01-25
**Status**: ⚠️ DOCUMENTED — Fix separately from security testing work
These 5 failures pre-date the Docker Hub, break-glass, and security testing infrastructure changes. Git history confirms no settings test files were modified in the current work.
### Failure Summary
| Test File | Line | Failure | Root Cause | Type |
|-----------|------|---------|------------|------|
| `account-settings.spec.ts` | 289 | `getByText(/invalid.*email|email.*invalid/i)` not found | Frontend email validation error text doesn't match test regex | Locator mismatch |
| `system-settings.spec.ts` | 412 | `data-testid="toast-success"` or `/success|saved/i` not found | Success toast implementation doesn't match test expectations | Locator mismatch |
| `user-management.spec.ts` | 277 | Strict mode: 2 elements match `/send.*invite/i` | Commit `0492c1be` added "Resend Invite" button conflicting with "Send Invite" | UI change without test update |
| `user-management.spec.ts` | 436 | Strict mode: 2 elements match `/send.*invite/i` | Same as above | UI change without test update |
| `user-management.spec.ts` | 948 | Strict mode: 2 elements match `/send.*invite/i` | Same as above | UI change without test update |
### Evidence
**Last modification to settings test files**: Commit `0492c1be` (Jan 24, 2026) — "fix: implement user management UI"
This commit added:
- "Resend Invite" button for pending users in the users table
- Email format validation with error display
- But did not update the test locators to distinguish between buttons
### Recommended Fix (Future PR)
```typescript
// CURRENT (fails strict mode):
const sendButton = page.getByRole('button', { name: /send.*invite/i });
// FIX: Be more specific to match only modal button
const sendButton = page
.locator('.invite-modal') // or modal dialog locator
.getByRole('button', { name: /send.*invite/i });
// OR use exact name:
const sendButton = page.getByRole('button', { name: 'Send Invite' });
```
### Tracking
These should be fixed in a separate PR after the security testing implementation is complete. They do not block the current work.
---
## 10. Supervisor Review Summary
**Review Date**: 2026-01-25
**Verdict**: ✅ APPROVED with Recommendations
### Grades
| Criteria | Grade | Notes |
|----------|-------|-------|
| Test Structure | B+ → A | Fixed with explicit teardown dependencies |
| API Correctness | A | Verified against settings_handler.go |
| Coverage | B → A- | Expanded from 21 to 24 tests |
| Pitfall Handling | B- → A | Added error handling + stabilization delay |
| Best Practices | A- | Removed numeric prefixes |
### Key Changes Incorporated
1. **Browser dependencies fixed**: Now depend on `['setup', 'security-teardown']` not just `['security-tests']`
2. **Teardown error handling**: Continue-on-error pattern with logging
3. **Stabilization delay**: 1-second wait after teardown for Caddy reload
4. **Test count increased**: 21 → 24 tests (3 new combined tests)
5. **Numeric prefixes removed**: Playwright ignores them; rely on project config
6. **Headless enforcement**: Security tests run headless Chromium (API-level tests)
7. **Caddy requirements documented**: WAF and Rate Limiting tests need Caddy proxy
### Critical Pre-Implementation Fixes (Phase 0)
These MUST be completed before Phase 1:
1. ❌ `tests/global-setup.ts:17` — Change `100.98.12.109` → `localhost`
2. ❌ `emergencySecurityReset()` — Expand to disable ALL modules, not just ACL
3. ❌ Add pre-auth security disable attempt (crash protection)
---