Files
Charon/docs/plans/current_spec.md
T
GitHub Actions 00ff546495 chore(e2e): implement Phase 0 E2E testing infrastructure
Add comprehensive E2E testing infrastructure including:

docker-compose.playwright.yml for test environment orchestration
TestDataManager utility for per-test namespace isolation
Wait helpers for flaky test prevention
Role-based auth fixtures for admin/user/guest testing
GitHub Actions e2e-tests.yml with 4-shard parallelization
Health check utility for service readiness validation
Phase 0 of 10-week E2E testing plan (Supervisor approved 9.2/10)
All 52 existing E2E tests pass with new infrastructure
2026-01-20 06:11:59 +00:00

2340 lines
70 KiB
Markdown

# Charon E2E Testing Plan: Comprehensive Playwright Coverage
**Date:** January 16, 2026
**Status:** Planning - Revised (v2.1)
**Priority:** Critical - Blocking new feature development
**Objective:** Establish comprehensive E2E test coverage for all existing Charon features
**Timeline:** 10 weeks (with proper infrastructure setup and comprehensive feature coverage)
> **Revision Note:** This document has been completely revised to address critical infrastructure gaps, expand underspecified sections, and provide implementation-ready specifications. Major additions include test data management, authentication strategy, CI/CD integration, flaky test prevention, and detailed security feature testing.
---
## Table of Contents
1. [Current State & Coverage Gaps](#1-current-state--coverage-gaps)
2. [Testing Infrastructure](#2-testing-infrastructure)
- 2.1 [Test Environment Setup](#21-test-environment-setup)
- 2.2 [Test Data Management Strategy](#22-test-data-management-strategy)
- 2.3 [Authentication Strategy](#23-authentication-strategy)
- 2.4 [Flaky Test Prevention](#24-flaky-test-prevention)
- 2.5 [CI/CD Integration](#25-cicd-integration)
3. [Test Organization](#3-test-organization)
4. [Implementation Plan](#4-implementation-plan)
- Phase 0: Infrastructure Setup (Week 1-2)
- Phase 1: Foundation (Week 3)
- Phase 2: Critical Path (Week 4-5)
- Phase 3: Security Features (Week 6-7)
- Phase 4: Settings (Week 8)
- Phase 5: Tasks (Week 9)
- Phase 6: Integration (Week 10)
5. [Security Feature Testing Strategy](#5-security-feature-testing-strategy)
6. [Risk Mitigation](#6-risk-mitigation)
7. [Success Metrics](#7-success-metrics)
8. [Next Steps](#8-next-steps)
---
## 1. Current State & Coverage Gaps
### Existing Test Files
**Current E2E Test Coverage:**
-`tests/auth.setup.ts` - Authentication setup (shared fixture)
-`tests/manual-dns-provider.spec.ts` - Manual DNS provider E2E tests (comprehensive)
-`tests/dns-provider-crud.spec.ts` - DNS provider CRUD operations
-`tests/dns-provider-types.spec.ts` - DNS provider type validation
-`tests/example.spec.js` - Legacy example (can be removed)
-`tests/fixtures/dns-providers.ts` - Shared DNS test fixtures
**Critical Infrastructure Gaps Identified:**
- ❌ No test data management system (causes data conflicts, FK violations)
- ❌ No per-test user creation (shared auth state breaks parallel execution)
- ❌ No CI/CD integration strategy (no automated testing on PRs)
- ❌ No flaky test prevention utilities (arbitrary timeouts everywhere)
- ❌ No environment setup documentation (manual setup, no verification)
- ❌ No mock external service strategy (tests depend on real services)
### Coverage Gaps
**All major features lack E2E test coverage except DNS providers:**
- ❌ Proxy Hosts management
- ❌ Access Lists (ACL)
- ❌ SSL Certificates
- ❌ CrowdSec integration
- ❌ Coraza WAF
- ❌ Rate Limiting
- ❌ Security Headers
- ❌ Backups & Restore
- ❌ User Management
- ❌ System Settings
- ❌ Audit Logs
- ❌ Remote Servers
- ❌ Uptime Monitoring
- ❌ Notifications
- ❌ Import/Export features
- ❌ Encryption Management
---
## 2. Testing Infrastructure
### 2.1 Test Environment Setup
**Objective:** Ensure consistent, reproducible test environments for local development and CI.
#### 2.1.1 Local Development Setup
**Prerequisites:**
- Docker and Docker Compose installed
- Node.js 18+ and npm
- Go 1.21+ (for backend development)
- Playwright browsers installed (`npx playwright install`)
**Environment Configuration:**
```bash
# .env.test (create in project root)
NODE_ENV=test
DATABASE_URL=sqlite:./data/charon_test.db
BASE_URL=http://localhost:8080
PLAYWRIGHT_BASE_URL=http://localhost:8080
TEST_USER_EMAIL=test-admin@charon.local
TEST_USER_PASSWORD=TestPassword123!
DOCKER_HOST=unix:///var/run/docker.sock
ENABLE_CROWDSEC=false # Disabled for unit tests, enabled for integration
ENABLE_WAF=false
LOG_LEVEL=warn
```
**Required Docker Services:**
> **Note:** Use the committed `docker-compose.playwright.yml` for E2E testing.
> The `docker-compose.test.yml` is gitignored and reserved for personal/local configurations.
```yaml
# .docker/compose/docker-compose.playwright.yml
# See the actual file for the full configuration with:
# - Charon app service with test environment
# - Optional CrowdSec profile: --profile security-tests
# - Optional MailHog profile: --profile notification-tests
#
# Usage:
# docker compose -f .docker/compose/docker-compose.playwright.yml up -d
# docker compose -f .docker/compose/docker-compose.playwright.yml --profile security-tests up -d
**Setup Script:**
```bash
#!/bin/bash
# scripts/setup-e2e-env.sh
set -euo pipefail
echo "🚀 Setting up E2E test environment..."
# 1. Check prerequisites
command -v docker >/dev/null 2>&1 || { echo "❌ Docker not found"; exit 1; }
command -v node >/dev/null 2>&1 || { echo "❌ Node.js not found"; exit 1; }
# 2. Install dependencies
echo "📦 Installing dependencies..."
npm ci
# 3. Install Playwright browsers
echo "🎭 Installing Playwright browsers..."
npx playwright install chromium
# 4. Create test environment file
if [ ! -f .env.test ]; then
echo "📝 Creating .env.test..."
cp .env.example .env.test
# Set test-specific values
sed -i 's/NODE_ENV=.*/NODE_ENV=test/' .env.test
sed -i 's/DATABASE_URL=.*/DATABASE_URL=sqlite:\.\/data\/charon_test.db/' .env.test
fi
# 5. Start test environment
echo "🐳 Starting Docker services..."
docker compose -f .docker/compose/docker-compose.playwright.yml up -d
# 6. Wait for service health
echo "⏳ Waiting for service to be healthy..."
timeout 60 bash -c 'until docker compose -f .docker/compose/docker-compose.playwright.yml exec -T charon-app curl -f http://localhost:8080/api/v1/health; do sleep 2; done'
# 7. Run database migrations
echo "🗄️ Running database migrations..."
docker compose -f .docker/compose/docker-compose.playwright.yml exec -T charon-app /app/backend/charon migrate
echo "✅ E2E environment ready!"
echo "📍 Application: http://localhost:8080"
echo "🧪 Run tests: npm run test:e2e"
```
**Environment Health Check:**
```typescript
// tests/utils/health-check.ts
export async function waitForHealthyEnvironment(baseURL: string, timeout = 60000): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
try {
const response = await fetch(`${baseURL}/api/v1/health`);
if (response.ok) {
const health = await response.json();
if (health.status === 'healthy' && health.database === 'connected') {
console.log('✅ Environment is healthy');
return;
}
}
} catch (error) {
// Service not ready yet
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
throw new Error(`Environment not healthy after ${timeout}ms`);
}
export async function verifyTestPrerequisites(): Promise<void> {
const checks = {
'Database writable': async () => {
// Attempt to create a test record
const response = await fetch(`${process.env.PLAYWRIGHT_BASE_URL}/api/v1/test/db-check`, {
method: 'POST'
});
return response.ok;
},
'Docker socket accessible': async () => {
// Check if Docker is available (for proxy host tests)
const response = await fetch(`${process.env.PLAYWRIGHT_BASE_URL}/api/v1/test/docker-check`);
return response.ok;
}
};
for (const [name, check] of Object.entries(checks)) {
try {
const result = await check();
if (!result) throw new Error(`Check failed: ${name}`);
console.log(`${name}`);
} catch (error) {
console.error(`${name}: ${error}`);
throw new Error(`Prerequisite check failed: ${name}`);
}
}
}
```
#### 2.1.2 CI Environment Configuration
**GitHub Actions Environment:**
- Use `localhost:8080` instead of Tailscale IP
- Run services in Docker containers
- Use GitHub Actions cache for dependencies and browsers
- Upload test artifacts on failure
**Network Configuration:**
```yaml
# In CI, all services communicate via Docker network
services:
charon:
networks:
- test-network
networks:
test-network:
driver: bridge
```
#### 2.1.3 Mock External Service Strategy
**DNS Provider API Mocks:**
```typescript
// tests/mocks/dns-provider-api.ts
import { rest } from 'msw';
import { setupServer } from 'msw/node';
export const dnsProviderMocks = [
// Mock Cloudflare API
rest.post('https://api.cloudflare.com/client/v4/zones/:zoneId/dns_records', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
success: true,
result: { id: 'mock-record-id', name: req.body.name }
})
);
}),
// Mock Route53 API
rest.post('https://route53.amazonaws.com/*', (req, res, ctx) => {
return res(ctx.status(200), ctx.xml('<ChangeInfo><Id>mock-change-id</Id></ChangeInfo>'));
})
];
export const mockServer = setupServer(...dnsProviderMocks);
```
**ACME Server Mock (for certificate tests):**
```typescript
// tests/mocks/acme-server.ts
export const acmeMocks = [
// Mock Let's Encrypt directory
rest.get('https://acme-v02.api.letsencrypt.org/directory', (req, res, ctx) => {
return res(ctx.json({
newNonce: 'https://mock-acme/new-nonce',
newAccount: 'https://mock-acme/new-account',
newOrder: 'https://mock-acme/new-order'
}));
})
];
```
---
### 2.2 Test Data Management Strategy
**Critical Problem:** Current approach uses shared test data, causing conflicts in parallel execution and leaving orphaned records.
**Solution:** Implement `TestDataManager` utility with namespaced isolation and guaranteed cleanup.
#### 2.2.1 TestDataManager Design
```typescript
// tests/utils/TestDataManager.ts
import { APIRequestContext } from '@playwright/test';
import crypto from 'crypto';
export interface ManagedResource {
id: string;
type: 'proxy-host' | 'certificate' | 'access-list' | 'dns-provider' | 'user';
namespace: string;
createdAt: Date;
}
export class TestDataManager {
private resources: ManagedResource[] = [];
private namespace: string;
private request: APIRequestContext;
constructor(request: APIRequestContext, testName?: string) {
this.request = request;
// Create unique namespace per test to avoid conflicts
this.namespace = testName
? `test-${this.sanitize(testName)}-${Date.now()}`
: `test-${crypto.randomUUID()}`;
}
private sanitize(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]/g, '-').substring(0, 30);
}
/**
* Create a proxy host with automatic cleanup tracking
*/
async createProxyHost(data: {
domain: string;
forwardHost: string;
forwardPort: number;
scheme?: 'http' | 'https';
}): Promise<{ id: string; domain: string }> {
const namespaced = {
...data,
domain: `${this.namespace}.${data.domain}` // Ensure unique domain
};
const response = await this.request.post('/api/v1/proxy-hosts', {
data: namespaced
});
if (!response.ok()) {
throw new Error(`Failed to create proxy host: ${await response.text()}`);
}
const result = await response.json();
this.resources.push({
id: result.uuid,
type: 'proxy-host',
namespace: this.namespace,
createdAt: new Date()
});
return { id: result.uuid, domain: namespaced.domain };
}
/**
* Create an access list with automatic cleanup
*/
async createAccessList(data: {
name: string;
rules: Array<{ type: 'allow' | 'deny'; value: string }>;
}): Promise<{ id: string }> {
const namespaced = {
...data,
name: `${this.namespace}-${data.name}`
};
const response = await this.request.post('/api/v1/access-lists', {
data: namespaced
});
if (!response.ok()) {
throw new Error(`Failed to create access list: ${await response.text()}`);
}
const result = await response.json();
this.resources.push({
id: result.id,
type: 'access-list',
namespace: this.namespace,
createdAt: new Date()
});
return { id: result.id };
}
/**
* Create a certificate with automatic cleanup
*/
async createCertificate(data: {
domains: string[];
type: 'letsencrypt' | 'custom';
privateKey?: string;
certificate?: string;
}): Promise<{ id: string }> {
const namespaced = {
...data,
domains: data.domains.map(d => `${this.namespace}.${d}`)
};
const response = await this.request.post('/api/v1/certificates', {
data: namespaced
});
if (!response.ok()) {
throw new Error(`Failed to create certificate: ${await response.text()}`);
}
const result = await response.json();
this.resources.push({
id: result.id,
type: 'certificate',
namespace: this.namespace,
createdAt: new Date()
});
return { id: result.id };
}
/**
* Create a DNS provider with automatic cleanup
*/
async createDNSProvider(data: {
type: 'manual' | 'cloudflare' | 'route53';
name: string;
credentials?: Record<string, string>;
}): Promise<{ id: string }> {
const namespaced = {
...data,
name: `${this.namespace}-${data.name}`
};
const response = await this.request.post('/api/v1/dns-providers', {
data: namespaced
});
if (!response.ok()) {
throw new Error(`Failed to create DNS provider: ${await response.text()}`);
}
const result = await response.json();
this.resources.push({
id: result.id,
type: 'dns-provider',
namespace: this.namespace,
createdAt: new Date()
});
return { id: result.id };
}
/**
* Create a test user with automatic cleanup
*/
async createUser(data: {
email: string;
password: string;
role: 'admin' | 'user' | 'guest';
}): Promise<{ id: string; email: string; token: string }> {
const namespaced = {
...data,
email: `${this.namespace}+${data.email}`
};
const response = await this.request.post('/api/v1/users', {
data: namespaced
});
if (!response.ok()) {
throw new Error(`Failed to create user: ${await response.text()}`);
}
const result = await response.json();
this.resources.push({
id: result.id,
type: 'user',
namespace: this.namespace,
createdAt: new Date()
});
// Automatically log in the user and return token
const loginResponse = await this.request.post('/api/v1/auth/login', {
data: { email: namespaced.email, password: data.password }
});
const { token } = await loginResponse.json();
return { id: result.id, email: namespaced.email, token };
}
/**
* Clean up all resources in reverse order (respects FK constraints)
*/
async cleanup(): Promise<void> {
// Sort by creation time (newest first) to respect dependencies
const sortedResources = [...this.resources].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
);
const errors: Error[] = [];
for (const resource of sortedResources) {
try {
await this.deleteResource(resource);
} catch (error) {
errors.push(error as Error);
console.error(`Failed to cleanup ${resource.type}:${resource.id}:`, error);
}
}
this.resources = [];
if (errors.length > 0) {
throw new Error(`Cleanup completed with ${errors.length} errors`);
}
}
private async deleteResource(resource: ManagedResource): Promise<void> {
const endpoints = {
'proxy-host': `/api/v1/proxy-hosts/${resource.id}`,
'certificate': `/api/v1/certificates/${resource.id}`,
'access-list': `/api/v1/access-lists/${resource.id}`,
'dns-provider': `/api/v1/dns-providers/${resource.id}`,
'user': `/api/v1/users/${resource.id}`
};
const endpoint = endpoints[resource.type];
const response = await this.request.delete(endpoint);
if (!response.ok() && response.status() !== 404) {
throw new Error(`Failed to delete ${resource.type}: ${await response.text()}`);
}
}
/**
* Get all resources created in this namespace
*/
getResources(): ManagedResource[] {
return [...this.resources];
}
/**
* Get namespace identifier
*/
getNamespace(): string {
return this.namespace;
}
}
```
#### 2.2.2 Usage Pattern
```typescript
// Example test using TestDataManager
import { test, expect } from '@playwright/test';
import { TestDataManager } from './utils/TestDataManager';
test.describe('Proxy Host Management', () => {
let testData: TestDataManager;
test.beforeEach(async ({ request }, testInfo) => {
testData = new TestDataManager(request, testInfo.title);
});
test.afterEach(async () => {
await testData.cleanup();
});
test('should create and delete proxy host', async ({ page, request }) => {
await test.step('Create proxy host', async () => {
const { id, domain } = await testData.createProxyHost({
domain: 'app.example.com',
forwardHost: '192.168.1.100',
forwardPort: 3000,
scheme: 'http'
});
await page.goto('/proxy-hosts');
await expect(page.getByText(domain)).toBeVisible();
});
// Cleanup happens automatically in afterEach
});
});
```
#### 2.2.3 Database Seeding Strategy
**Seed Data for Reference Tests:**
```typescript
// tests/fixtures/seed-data.ts
export async function seedReferenceData(request: APIRequestContext): Promise<void> {
// Create stable reference data that doesn't change
const referenceData = {
accessLists: [
{ name: 'Global Allowlist', rules: [{ type: 'allow', value: '0.0.0.0/0' }] }
],
dnsProviders: [
{ type: 'manual', name: 'Manual DNS (Default)' }
]
};
// Idempotent seeding - only create if not exists
for (const list of referenceData.accessLists) {
const response = await request.get('/api/v1/access-lists', {
params: { name: list.name }
});
const existing = await response.json();
if (existing.length === 0) {
await request.post('/api/v1/access-lists', { data: list });
}
}
}
```
#### 2.2.4 Parallel Execution Handling
**Test Isolation Strategy:**
- Each test worker gets its own namespace via `TestDataManager`
- Database transactions are NOT used (not supported in E2E context)
- Unique identifiers prevent collisions (domain names, usernames)
- Cleanup runs independently per test
**Worker ID Integration:**
```typescript
// playwright.config.ts adjustment
export default defineConfig({
workers: process.env.CI ? 4 : undefined,
use: {
storageState: ({ workerIndex }) => `auth/state-worker-${workerIndex}.json`
}
});
```
---
### 2.3 Authentication Strategy
**Critical Problem:** Current `auth.setup.ts` uses a single shared user, causing race conditions in parallel execution.
**Solution:** Per-test user creation with role-based fixtures.
#### 2.3.1 Per-Test User Creation
```typescript
// tests/fixtures/auth-fixtures.ts
import { test as base, expect, APIRequestContext } from '@playwright/test';
import { TestDataManager } from '../utils/TestDataManager';
export interface TestUser {
id: string;
email: string;
token: string;
role: 'admin' | 'user' | 'guest';
}
interface AuthFixtures {
authenticatedUser: TestUser;
adminUser: TestUser;
regularUser: TestUser;
guestUser: TestUser;
testData: TestDataManager;
}
export const test = base.extend<AuthFixtures>({
testData: async ({ request }, use, testInfo) => {
const manager = new TestDataManager(request, testInfo.title);
await use(manager);
await manager.cleanup();
},
// Default authenticated user (admin role)
authenticatedUser: async ({ testData }, use, testInfo) => {
const user = await testData.createUser({
email: `admin-${Date.now()}@test.local`,
password: 'TestPass123!',
role: 'admin'
});
await use(user);
},
// Explicit admin user fixture
adminUser: async ({ testData }, use) => {
const user = await testData.createUser({
email: `admin-${Date.now()}@test.local`,
password: 'TestPass123!',
role: 'admin'
});
await use(user);
},
// Regular user (non-admin)
regularUser: async ({ testData }, use) => {
const user = await testData.createUser({
email: `user-${Date.now()}@test.local`,
password: 'TestPass123!',
role: 'user'
});
await use(user);
},
// Guest user (read-only)
guestUser: async ({ testData }, use) => {
const user = await testData.createUser({
email: `guest-${Date.now()}@test.local`,
password: 'TestPass123!',
role: 'guest'
});
await use(user);
}
});
export { expect } from '@playwright/test';
```
#### 2.3.2 Usage Pattern
```typescript
// Example test with per-test authentication
import { test, expect } from './fixtures/auth-fixtures';
test.describe('User Management', () => {
test('admin can create users', async ({ page, adminUser }) => {
await test.step('Login as admin', async () => {
await page.goto('/login');
await page.getByLabel('Email').fill(adminUser.email);
await page.getByLabel('Password').fill('TestPass123!');
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL('/');
});
await test.step('Create new user', async () => {
await page.goto('/users');
await page.getByRole('button', { name: 'Add User' }).click();
// ... rest of test
});
});
test('regular user cannot create users', async ({ page, regularUser }) => {
await test.step('Login as regular user', async () => {
await page.goto('/login');
await page.getByLabel('Email').fill(regularUser.email);
await page.getByLabel('Password').fill('TestPass123!');
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL('/');
});
await test.step('Verify no access to user management', async () => {
await page.goto('/users');
await expect(page.getByText('Access Denied')).toBeVisible();
});
});
});
```
#### 2.3.3 Storage State Management
**Per-Worker Storage:**
```typescript
// tests/auth.setup.ts (revised)
import { test as setup, expect } from '@playwright/test';
import { TestDataManager } from './utils/TestDataManager';
// Generate storage state per worker
const authFile = process.env.CI
? `auth/state-worker-${process.env.TEST_WORKER_INDEX || 0}.json`
: 'auth/state.json';
setup('authenticate', async ({ request, page }) => {
const testData = new TestDataManager(request, 'setup');
try {
// Create a dedicated setup user for this worker
const user = await testData.createUser({
email: `setup-worker-${process.env.TEST_WORKER_INDEX || 0}@test.local`,
password: 'SetupPass123!',
role: 'admin'
});
await page.goto('/login');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill('SetupPass123!');
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL('/');
// Save authenticated state
await page.context().storageState({ path: authFile });
console.log(`✅ Auth state saved for worker ${process.env.TEST_WORKER_INDEX || 0}`);
} finally {
// Cleanup happens automatically via TestDataManager
await testData.cleanup();
}
});
```
---
### 2.4 Flaky Test Prevention
**Critical Problem:** Arbitrary timeouts (`page.waitForTimeout(1000)`) cause flaky tests and slow execution.
**Solution:** Deterministic wait utilities that poll for specific conditions.
#### 2.4.1 Wait Utilities
```typescript
// tests/utils/wait-helpers.ts
import { Page, Locator, expect } from '@playwright/test';
/**
* Wait for a toast notification with specific text
*/
export async function waitForToast(
page: Page,
text: string | RegExp,
options: { timeout?: number; type?: 'success' | 'error' | 'info' } = {}
): Promise<void> {
const { timeout = 10000, type } = options;
const toastSelector = type
? `[role="alert"][data-type="${type}"]`
: '[role="alert"]';
const toast = page.locator(toastSelector);
await expect(toast).toContainText(text, { timeout });
}
/**
* Wait for a specific API response
*/
export async function waitForAPIResponse(
page: Page,
urlPattern: string | RegExp,
options: { status?: number; timeout?: number } = {}
): Promise<Response> {
const { status, timeout = 30000 } = options;
const responsePromise = page.waitForResponse(
(response) => {
const matchesURL = typeof urlPattern === 'string'
? response.url().includes(urlPattern)
: urlPattern.test(response.url());
const matchesStatus = status ? response.status() === status : true;
return matchesURL && matchesStatus;
},
{ timeout }
);
return await responsePromise;
}
/**
* Wait for loading spinner to disappear
*/
export async function waitForLoadingComplete(
page: Page,
options: { timeout?: number } = {}
): Promise<void> {
const { timeout = 10000 } = options;
// Wait for any loading indicator to disappear
const loader = page.locator('[role="progressbar"], [aria-busy="true"], .loading-spinner');
await expect(loader).toHaveCount(0, { timeout });
}
/**
* Wait for specific element count (e.g., table rows)
*/
export async function waitForElementCount(
locator: Locator,
count: number,
options: { timeout?: number } = {}
): Promise<void> {
const { timeout = 10000 } = options;
await expect(locator).toHaveCount(count, { timeout });
}
/**
* Wait for WebSocket connection to be established
*/
export async function waitForWebSocketConnection(
page: Page,
urlPattern: string | RegExp,
options: { timeout?: number } = {}
): Promise<void> {
const { timeout = 10000 } = options;
await page.waitForEvent('websocket', {
predicate: (ws) => {
const matchesURL = typeof urlPattern === 'string'
? ws.url().includes(urlPattern)
: urlPattern.test(ws.url());
return matchesURL;
},
timeout
});
}
/**
* Wait for WebSocket message with specific content
*/
export async function waitForWebSocketMessage(
page: Page,
matcher: (data: string | Buffer) => boolean,
options: { timeout?: number } = {}
): Promise<string | Buffer> {
const { timeout = 10000 } = options;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`WebSocket message not received within ${timeout}ms`));
}, timeout);
page.on('websocket', (ws) => {
ws.on('framereceived', (event) => {
const data = event.payload;
if (matcher(data)) {
clearTimeout(timer);
resolve(data);
}
});
});
});
}
/**
* Wait for progress bar to complete
*/
export async function waitForProgressComplete(
page: Page,
options: { timeout?: number } = {}
): Promise<void> {
const { timeout = 30000 } = options;
const progressBar = page.locator('[role="progressbar"]');
// Wait for progress to reach 100% or disappear
await page.waitForFunction(
() => {
const bar = document.querySelector('[role="progressbar"]');
if (!bar) return true; // Progress bar gone = complete
const value = bar.getAttribute('aria-valuenow');
return value === '100';
},
{ timeout }
);
// Wait for progress bar to disappear
await expect(progressBar).toHaveCount(0, { timeout: 5000 });
}
/**
* Wait for modal to open
*/
export async function waitForModal(
page: Page,
titleText: string | RegExp,
options: { timeout?: number } = {}
): Promise<Locator> {
const { timeout = 10000 } = options;
const modal = page.locator('[role="dialog"]');
await expect(modal).toBeVisible({ timeout });
if (titleText) {
await expect(modal.getByRole('heading')).toContainText(titleText);
}
return modal;
}
/**
* Wait for dropdown/listbox to open
*/
export async function waitForDropdown(
page: Page,
triggerId: string,
options: { timeout?: number } = {}
): Promise<Locator> {
const { timeout = 5000 } = options;
const trigger = page.locator(`#${triggerId}`);
const expanded = await trigger.getAttribute('aria-expanded');
if (expanded !== 'true') {
throw new Error(`Dropdown ${triggerId} is not expanded`);
}
const listboxId = await trigger.getAttribute('aria-controls');
if (!listboxId) {
throw new Error(`Dropdown ${triggerId} has no aria-controls`);
}
const listbox = page.locator(`#${listboxId}`);
await expect(listbox).toBeVisible({ timeout });
return listbox;
}
/**
* Wait for table to finish loading and render rows
*/
export async function waitForTableLoad(
page: Page,
tableRole: string = 'table',
options: { minRows?: number; timeout?: number } = {}
): Promise<void> {
const { minRows = 0, timeout = 10000 } = options;
const table = page.getByRole(tableRole);
await expect(table).toBeVisible({ timeout });
// Wait for loading state to clear
await waitForLoadingComplete(page);
// If minimum rows specified, wait for them
if (minRows > 0) {
const rows = table.locator('tbody tr');
await expect(rows).toHaveCount(minRows, { timeout });
}
}
/**
* Retry an action until it succeeds or timeout
*/
export async function retryAction<T>(
action: () => Promise<T>,
options: {
maxAttempts?: number;
interval?: number;
timeout?: number;
} = {}
): Promise<T> {
const { maxAttempts = 5, interval = 1000, timeout = 30000 } = options;
const startTime = Date.now();
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (Date.now() - startTime > timeout) {
throw new Error(`Retry timeout after ${timeout}ms`);
}
try {
return await action();
} catch (error) {
lastError = error as Error;
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, interval));
}
}
}
throw lastError || new Error('Retry failed');
}
```
#### 2.4.2 Usage Examples
```typescript
import { test, expect } from '@playwright/test';
import {
waitForToast,
waitForAPIResponse,
waitForLoadingComplete,
waitForModal
} from './utils/wait-helpers';
test('create proxy host with deterministic waits', async ({ page }) => {
await test.step('Navigate and open form', async () => {
await page.goto('/proxy-hosts');
await page.getByRole('button', { name: 'Add Proxy Host' }).click();
await waitForModal(page, 'Create Proxy Host');
});
await test.step('Fill form and submit', async () => {
await page.getByLabel('Domain Name').fill('test.example.com');
await page.getByLabel('Forward Host').fill('192.168.1.100');
await page.getByLabel('Forward Port').fill('3000');
// Wait for API call to complete
const responsePromise = waitForAPIResponse(page, '/api/v1/proxy-hosts', { status: 201 });
await page.getByRole('button', { name: 'Save' }).click();
await responsePromise;
});
await test.step('Verify success', async () => {
await waitForToast(page, 'Proxy host created successfully', { type: 'success' });
await waitForLoadingComplete(page);
await expect(page.getByRole('row', { name: /test.example.com/ })).toBeVisible();
});
});
```
---
### 2.5 CI/CD Integration
**Objective:** Automate E2E test execution on every PR with parallel execution, comprehensive reporting, and failure artifacts.
#### 2.5.1 GitHub Actions Workflow
```yaml
# .github/workflows/e2e-tests.yml
name: E2E Tests (Playwright)
on:
pull_request:
branches: [main, develop]
paths:
- 'frontend/**'
- 'backend/**'
- 'tests/**'
- 'playwright.config.js'
- '.github/workflows/e2e-tests.yml'
push:
branches: [main]
workflow_dispatch:
inputs:
browser:
description: 'Browser to test'
required: false
default: 'chromium'
type: choice
options:
- chromium
- firefox
- webkit
- all
env:
NODE_VERSION: '18'
GO_VERSION: '1.21'
jobs:
# Build application once, share across test shards
build:
name: Build Application
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build frontend
run: npm run build
working-directory: frontend
- name: Build backend
run: make build
working-directory: backend
- name: Build Docker image
run: |
docker build -t charon:test .
docker save charon:test -o charon-test-image.tar
- name: Upload Docker image
uses: actions/upload-artifact@v4
with:
name: docker-image
path: charon-test-image.tar
retention-days: 1
# Run tests in parallel shards
e2e-tests:
name: E2E Tests (Shard ${{ matrix.shard }})
runs-on: ubuntu-latest
needs: build
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
browser: [chromium] # Can be extended to [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Download Docker image
uses: actions/download-artifact@v4
with:
name: docker-image
- name: Load Docker image
run: docker load -i charon-test-image.tar
- name: Start test environment
run: |
docker compose -f .docker/compose/docker-compose.playwright.yml up -d
- name: Wait for service healthy
run: |
timeout 60 bash -c 'until curl -f http://localhost:8080/api/v1/health; do sleep 2; done'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps ${{ matrix.browser }}
- name: Run E2E tests (Shard ${{ matrix.shard }})
run: |
npx playwright test \
--project=${{ matrix.browser }} \
--shard=${{ matrix.shard }}/4 \
--reporter=html,json,junit
env:
PLAYWRIGHT_BASE_URL: http://localhost:8080
CI: true
TEST_WORKER_INDEX: ${{ matrix.shard }}
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.browser }}-shard-${{ matrix.shard }}
path: |
playwright-report/
test-results/
retention-days: 7
- name: Upload test traces
if: failure()
uses: actions/upload-artifact@v4
with:
name: traces-${{ matrix.browser }}-shard-${{ matrix.shard }}
path: test-results/**/*.zip
retention-days: 7
- name: Collect Docker logs
if: failure()
run: |
docker compose -f .docker/compose/docker-compose.playwright.yml logs > docker-logs.txt
- name: Upload Docker logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: docker-logs-shard-${{ matrix.shard }}
path: docker-logs.txt
retention-days: 7
# Merge reports from all shards
merge-reports:
name: Merge Test Reports
runs-on: ubuntu-latest
needs: e2e-tests
if: always()
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Download all reports
uses: actions/download-artifact@v4
with:
pattern: test-results-*
path: all-results
- name: Merge Playwright HTML reports
run: npx playwright merge-reports --reporter html all-results
- name: Upload merged report
uses: actions/upload-artifact@v4
with:
name: merged-playwright-report
path: playwright-report/
retention-days: 30
- name: Generate summary
run: |
echo "## E2E Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
cat all-results/*/report.json | jq -s '
{
total: (map(.stats.expected + .stats.unexpected + .stats.flaky + .stats.skipped) | add),
passed: (map(.stats.expected) | add),
failed: (map(.stats.unexpected) | add),
flaky: (map(.stats.flaky) | add),
skipped: (map(.stats.skipped) | add)
}
' | jq -r '"- **Total**: \(.total)\n- **Passed**: \(.passed)\n- **Failed**: \(.failed)\n- **Flaky**: \(.flaky)\n- **Skipped**: \(.skipped)"' >> $GITHUB_STEP_SUMMARY
# Comment on PR with results
comment-results:
name: Comment Test Results on PR
runs-on: ubuntu-latest
needs: merge-reports
if: github.event_name == 'pull_request'
permissions:
pull-requests: write
steps:
- name: Download merged report
uses: actions/download-artifact@v4
with:
name: merged-playwright-report
path: playwright-report
- name: Extract test stats
id: stats
run: |
STATS=$(cat playwright-report/report.json | jq -c '.stats')
echo "stats=$STATS" >> $GITHUB_OUTPUT
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
const stats = JSON.parse('${{ steps.stats.outputs.stats }}');
const passed = stats.expected;
const failed = stats.unexpected;
const flaky = stats.flaky;
const total = passed + failed + flaky + stats.skipped;
const emoji = failed > 0 ? '❌' : flaky > 0 ? '⚠️' : '✅';
const status = failed > 0 ? 'FAILED' : flaky > 0 ? 'FLAKY' : 'PASSED';
const body = `## ${emoji} E2E Test Results: ${status}
| Metric | Count |
|--------|-------|
| Total | ${total} |
| Passed | ✅ ${passed} |
| Failed | ❌ ${failed} |
| Flaky | ⚠️ ${flaky} |
| Skipped | ⏭️ ${stats.skipped} |
[View full Playwright report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
${failed > 0 ? '⚠️ **Tests failed!** Please review the failures and fix before merging.' : ''}
${flaky > 0 ? '⚠️ **Flaky tests detected!** Please investigate and stabilize before merging.' : ''}
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body
});
# Block merge if tests fail
e2e-results:
name: E2E Test Results
runs-on: ubuntu-latest
needs: e2e-tests
if: always()
steps:
- name: Check test results
run: |
if [ "${{ needs.e2e-tests.result }}" != "success" ]; then
echo "E2E tests failed or were cancelled"
exit 1
fi
```
#### 2.5.2 Test Sharding Strategy
**Why Shard:** Reduces CI run time from ~40 minutes to ~10 minutes with 4 parallel shards.
**Sharding Configuration:**
```typescript
// playwright.config.ts
export default defineConfig({
testDir: './tests',
fullyParallel: true,
workers: process.env.CI ? 4 : undefined,
retries: process.env.CI ? 2 : 0,
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
],
// CI-specific optimizations
...(process.env.CI && {
reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/junit.xml' }]
],
maxFailures: 10 // Stop after 10 failures to save CI time
})
});
```
**Shard Distribution:**
- Shard 1: `tests/core/**`, `tests/proxy/**` (~10 min)
- Shard 2: `tests/dns/**`, `tests/certificates/**` (~10 min)
- Shard 3: `tests/security/**` (~10 min)
- Shard 4: `tests/settings/**`, `tests/tasks/**`, `tests/monitoring/**`, `tests/integration/**` (~10 min)
#### 2.5.3 Cache Strategy
```yaml
# Cache Playwright browsers
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-browsers-${{ hashFiles('package-lock.json') }}
# Cache npm dependencies
- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
# Cache Docker layers
- name: Cache Docker layers
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: docker-${{ github.sha }}
restore-keys: docker-
```
#### 2.5.4 Failure Notification Strategy
**Slack Notification (Optional):**
```yaml
- name: Notify Slack on failure
if: failure() && github.event_name == 'push'
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "❌ E2E tests failed on ${{ github.ref }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*E2E Tests Failed*\n\nBranch: `${{ github.ref }}`\nCommit: `${{ github.sha }}`\n<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>"
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
```
---
## 3. Test Organization
**Directory Structure:**
```
tests/
├── auth.setup.ts # ✅ Exists - Authentication setup
├── fixtures/ # Shared test data and utilities
│ ├── dns-providers.ts # ✅ Exists - DNS fixtures
│ ├── proxy-hosts.ts # 📝 To create
│ ├── access-lists.ts # 📝 To create
│ ├── certificates.ts # 📝 To create
│ └── test-data.ts # 📝 To create - Common test data
├── core/ # Core application features
│ ├── authentication.spec.ts # 📝 Auth flows
│ ├── dashboard.spec.ts # 📝 Dashboard UI
│ └── navigation.spec.ts # 📝 App navigation
├── proxy/ # Proxy management
│ ├── proxy-hosts-crud.spec.ts # 📝 CRUD operations
│ ├── proxy-hosts-validation.spec.ts # 📝 Form validation
│ ├── proxy-hosts-docker.spec.ts # 📝 Docker discovery
│ └── remote-servers.spec.ts # 📝 Remote Docker servers
├── dns/ # DNS management
│ ├── manual-dns-provider.spec.ts # ✅ Exists - Manual provider
│ ├── dns-provider-crud.spec.ts # ✅ Exists - CRUD operations
│ ├── dns-provider-types.spec.ts # ✅ Exists - Provider types
│ ├── dns-provider-credentials.spec.ts # 📝 Credential management
│ └── dns-plugins.spec.ts # 📝 Plugin management
├── certificates/ # SSL certificate management
│ ├── certificates-list.spec.ts # 📝 List and view
│ ├── certificates-upload.spec.ts # 📝 Upload custom certs
│ └── certificates-acme.spec.ts # 📝 ACME integration
├── security/ # Cerberus security suite
│ ├── access-lists-crud.spec.ts # 📝 ACL CRUD
│ ├── access-lists-rules.spec.ts # 📝 ACL rule engine
│ ├── crowdsec-config.spec.ts # 📝 CrowdSec setup
│ ├── crowdsec-decisions.spec.ts # 📝 Ban management
│ ├── crowdsec-presets.spec.ts # 📝 Preset management
│ ├── waf-config.spec.ts # 📝 WAF configuration
│ ├── rate-limiting.spec.ts # 📝 Rate limit rules
│ ├── security-headers.spec.ts # 📝 Security headers
│ └── audit-logs.spec.ts # 📝 Audit trail
├── settings/ # System configuration
│ ├── system-settings.spec.ts # 📝 System config
│ ├── smtp-settings.spec.ts # 📝 Email config
│ ├── notifications.spec.ts # 📝 Notification config
│ ├── user-management.spec.ts # 📝 User CRUD
│ ├── encryption-management.spec.ts # 📝 Key rotation
│ └── account-settings.spec.ts # 📝 User profile
├── tasks/ # Background tasks & maintenance
│ ├── backups-create.spec.ts # 📝 Backup creation
│ ├── backups-restore.spec.ts # 📝 Backup restoration
│ ├── logs-viewing.spec.ts # 📝 Log viewer
│ ├── import-caddyfile.spec.ts # 📝 Caddy import
│ └── import-crowdsec.spec.ts # 📝 CrowdSec import
├── monitoring/ # Monitoring features
│ ├── uptime-monitoring.spec.ts # 📝 Uptime checks
│ └── real-time-logs.spec.ts # 📝 WebSocket logs
└── integration/ # Cross-feature integration
├── proxy-acl-integration.spec.ts # 📝 Proxy + ACL
├── proxy-certificate.spec.ts # 📝 Proxy + SSL
├── security-suite-integration.spec.ts # 📝 Full security stack
└── backup-restore-e2e.spec.ts # 📝 Full backup cycle
```
### Test Execution Strategy
**Playwright Configuration:**
- ✅ Base URL: `http://100.98.12.109:8080` (Tailscale IP) or `http://localhost:8080` (CI)
- ✅ Browser support: Chromium (primary), Firefox, WebKit
- ✅ Parallel execution: Enabled for faster runs
- ✅ Authentication: Shared state via `auth.setup.ts`
- ✅ Timeouts: 30s test, 5s expect
- ✅ Retries: 2 on CI, 0 on local
**Test Data Management:**
- Use fixtures for reusable test data
- Clean up created resources after tests
- Use unique identifiers for test resources (timestamps, UUIDs)
- Avoid hardcoded IDs or names that could conflict
**Accessibility Testing:**
- All tests must verify keyboard navigation
- Use `toMatchAriaSnapshot` for component structure validation
- Verify ARIA labels, roles, and live regions
- Test screen reader announcements for state changes
---
## 4. Implementation Plan
### Phase 0: Infrastructure Setup (Week 1-2)
**Goal:** Build robust test infrastructure before writing feature tests
#### Week 1: Core Infrastructure
**Priority:** Critical - Blocking all test development
**Estimated Effort:** 5 days
**Tasks:**
- [ ] Set up `TestDataManager` utility with namespace isolation
- [ ] Implement per-test user creation in `auth-fixtures.ts`
- [ ] Create all wait helper utilities (`waitForToast`, `waitForAPIResponse`, etc.)
- [ ] Configure test environment Docker Compose files (`.test.yml`)
- [ ] Write `setup-e2e-env.sh` script with health checks
- [ ] Implement mock external services (DNS providers, ACME servers)
- [ ] Configure test environment variables in `.env.test`
**Acceptance Criteria:**
- `TestDataManager` can create and cleanup all resource types
- Per-test users can be created with different roles
- Wait utilities replace all `page.waitForTimeout()` calls
- Test environment starts reliably with `npm run test:env:start`
- Mock services respond to API calls correctly
#### Week 2: CI/CD Integration
**Priority:** Critical - Required for PR automation
**Estimated Effort:** 5 days
**Tasks:**
- [ ] Create `.github/workflows/e2e-tests.yml`
- [ ] Implement test sharding strategy (4 shards)
- [ ] Configure artifact upload (reports, traces, logs)
- [ ] Set up PR comment reporting
- [ ] Configure caching for npm, Playwright browsers, Docker layers
- [ ] Test workflow end-to-end on a feature branch
- [ ] Document CI/CD troubleshooting guide
**Acceptance Criteria:**
- E2E tests run automatically on every PR
- Test results appear as PR comments
- Failed tests upload traces and logs
- CI run completes in <15 minutes with sharding
- Flaky test retries work correctly
### Phase 1: Foundation (Week 3)
**Goal:** Establish core application testing patterns
#### 1.1 Test Fixtures & Helpers
**Priority:** Critical
**Estimated Effort:** 2 days
**Tasks:**
- [ ] Create `tests/fixtures/test-data.ts` with common test data generators
- [ ] Create `tests/fixtures/proxy-hosts.ts` with mock proxy host data
- [ ] Create `tests/fixtures/access-lists.ts` with mock ACL data
- [ ] Create `tests/fixtures/certificates.ts` with mock certificate data
- [ ] Create `tests/utils/api-helpers.ts` for common API operations
**Acceptance Criteria:**
- Fixtures provide consistent, reusable test data
- API helpers reduce code duplication
- All utilities have JSDoc comments and usage examples
**Test File Template:**
```typescript
import { test, expect } from './fixtures/auth-fixtures'; // Use custom fixtures
import { TestDataManager } from './utils/TestDataManager';
test.describe('Feature Name', () => {
let testData: TestDataManager;
test.beforeEach(async ({ request, page }, testInfo) => {
testData = new TestDataManager(request, testInfo.title);
await page.goto('/feature-path');
});
test.afterEach(async () => {
await testData.cleanup(); // Guaranteed cleanup
});
test('should perform specific action', async ({ page, authenticatedUser }) => {
await test.step('User action', async () => {
// Use authenticatedUser fixture for API calls
await page.goto('/feature');
await page.getByRole('button', { name: 'Action' }).click();
});
await test.step('Verify result', async () => {
await expect(page.getByText('Success')).toBeVisible();
});
});
});
```
#### 1.2 Core Authentication & Navigation Tests
**Priority:** Critical
**Estimated Effort:** 3 days
**Test Files to Create:**
**`tests/core/authentication.spec.ts`**
- ✅ Login with valid credentials (covered by auth.setup.ts)
- ❌ Login with invalid credentials
- ❌ Logout functionality
- ❌ Session persistence
- ❌ Session expiration handling
- ❌ Password reset flow (if implemented)
**`tests/core/dashboard.spec.ts`**
- ❌ Dashboard loads successfully
- ❌ Summary cards display correct data
- ❌ Quick action buttons are functional
- ❌ Recent activity shows latest changes
- ❌ System status indicators work
**`tests/core/navigation.spec.ts`**
- ❌ All main menu items are clickable
- ❌ Sidebar navigation works
- ❌ Breadcrumbs display correctly
- ❌ Deep links resolve properly
- ❌ Back button navigation works
**Acceptance Criteria:**
- All authentication flows covered
- Dashboard displays without errors
- Navigation between all pages works
- No console errors during navigation
- Keyboard navigation fully functional
### Phase 2: Critical Path (Week 4-5)
**Goal:** Cover the most critical user journeys
#### 2.1 Proxy Hosts Management
**Priority:** Critical
**Estimated Effort:** 4 days
**Test Files:**
**`tests/proxy/proxy-hosts-crud.spec.ts`**
Test Scenarios:
- ✅ List all proxy hosts (empty state)
- ✅ Create new proxy host with basic configuration
- Enter domain name (e.g., `test-app.example.com`)
- Enter forward hostname (e.g., `192.168.1.100`)
- Enter forward port (e.g., `3000`)
- Select scheme (HTTP/HTTPS)
- Enable/disable WebSocket support
- Save and verify host appears in list
- ✅ View proxy host details
- ✅ Edit existing proxy host
- Update domain name
- Update forward hostname/port
- Toggle WebSocket support
- Save and verify changes
- ✅ Delete proxy host
- Delete single host
- Verify deletion confirmation dialog
- Verify host removed from list
- ✅ Bulk operations (if supported)
**Key User Flows:**
1. **Create Basic Proxy Host:**
```
Navigate → Click "Add Proxy Host" → Fill form → Save → Verify in list
```
2. **Edit Existing Host:**
```
Navigate → Select host → Click edit → Modify → Save → Verify changes
```
3. **Delete Host:**
```
Navigate → Select host → Click delete → Confirm → Verify removal
```
**Critical Assertions:**
- Host appears in list after creation
- Edit changes are persisted
- Deletion removes host from database
- Validation prevents invalid data
- Success/error messages display correctly
#### 2.2 SSL Certificates Management
**Priority:** Critical
**Estimated Effort:** 4 days
**Test Files:**
**`tests/certificates/certificates-list.spec.ts`**
Test Scenarios:
- ✅ List all certificates (empty state)
- ✅ Display certificate details (domain, expiry, issuer)
- ✅ Filter certificates by status (valid, expiring, expired)
- ✅ Sort certificates by expiry date
- ✅ Search certificates by domain name
- ✅ Show certificate chain details
**`tests/certificates/certificates-upload.spec.ts`**
Test Scenarios:
- ✅ Upload custom certificate with private key
- ✅ Validate PEM format
- ✅ Reject invalid certificate formats
- ✅ Reject mismatched certificate and key
- ✅ Support intermediate certificate chains
- ✅ Update existing certificate
- ✅ Delete custom certificate
**`tests/certificates/certificates-acme.spec.ts`**
Test Scenarios:
**ACME HTTP-01 Challenge:**
- ✅ Request certificate via HTTP-01 challenge
- Select domain from proxy hosts
- Choose HTTP-01 validation method
- Verify challenge file is served at `/.well-known/acme-challenge/`
- Mock ACME server validates challenge
- Certificate issued and stored
- ✅ HTTP-01 challenge fails if proxy host not accessible
- ✅ HTTP-01 challenge fails with invalid domain
**ACME DNS-01 Challenge:**
- ✅ Request certificate via DNS-01 challenge
- Select DNS provider (Cloudflare, Route53, Manual)
- Mock DNS provider API for TXT record creation
- Verify TXT record `_acme-challenge.domain.com` created
- Mock ACME server validates DNS record
- Certificate issued and stored
- ✅ DNS-01 challenge supports wildcard certificates
- Request `*.example.com` certificate
- Verify TXT record for `_acme-challenge.example.com`
- Certificate covers all subdomains
- ✅ DNS-01 challenge fails with invalid DNS credentials
- ✅ DNS-01 challenge retries on DNS propagation delay
**Certificate Renewal:**
- ✅ Automatic renewal triggered 30 days before expiry
- Mock certificate with expiry in 29 days
- Verify renewal task scheduled
- Renewal completes successfully
- Old certificate archived
- ✅ Manual certificate renewal
- Click "Renew Now" button
- Renewal process uses same validation method
- New certificate replaces old
- ✅ Renewal fails gracefully
- Old certificate remains active
- Error notification displayed
- Retry mechanism available
**Wildcard Certificates:**
- ✅ Request wildcard certificate (`*.example.com`)
- DNS-01 challenge required (HTTP-01 not supported)
- Verify TXT record created
- Certificate issued with wildcard SAN
- ✅ Wildcard certificate applies to all subdomains
- Create proxy host `app.example.com`
- Wildcard certificate auto-selected
- HTTPS works for any subdomain
**Certificate Revocation:**
- ✅ Revoke Let's Encrypt certificate
- Click "Revoke" button
- Confirm revocation reason
- Certificate marked as revoked
- ACME server notified
- ✅ Revoked certificate cannot be used
- Proxy hosts using certificate show warning
- HTTPS connections fail
**Validation Error Handling:**
- ✅ ACME account registration fails
- Invalid email address
- Rate limit exceeded
- Network error during registration
- ✅ Challenge validation fails
- HTTP-01: Challenge file not accessible
- DNS-01: TXT record not found
- DNS-01: DNS propagation timeout
- ✅ Certificate issuance fails
- ACME server error
- Domain validation failed
- Rate limit exceeded
**Mixed Certificate Sources:**
- ✅ Use Let's Encrypt and custom certificates together
- Some domains use Let's Encrypt
- Some domains use custom certificates
- Certificates don't conflict
- ✅ Migrate from custom to Let's Encrypt
- Replace custom certificate with Let's Encrypt
- No downtime during migration
- Old certificate archived
**Certificate Metadata:**
- ✅ Display certificate information
- Issuer, subject, validity period
- SAN (Subject Alternative Names)
- Signature algorithm
- Certificate chain
- ✅ Export certificate in various formats
- PEM, DER, PFX
- With or without private key
- Include full chain
**Key User Flows:**
1. **HTTP-01 Challenge Flow:**
```
Navigate → Click "Request Certificate" → Select domain → Choose HTTP-01 →
Monitor challenge → Certificate issued → Verify in list
```
2. **DNS-01 Wildcard Flow:**
```
Navigate → Click "Request Certificate" → Enter *.example.com → Choose DNS-01 →
Select DNS provider → Monitor DNS propagation → Certificate issued → Verify wildcard works
```
3. **Certificate Renewal Flow:**
```
Navigate → Select expiring certificate → Click "Renew" →
Automatic challenge re-validation → New certificate issued → Old certificate archived
```
**Critical Assertions:**
- Challenge files/records created correctly
- ACME server validates challenges
- Certificates issued with correct domains
- Renewal happens before expiry
- Validation errors display helpful messages
- Certificate chain is complete and valid
#### 2.3 Access Lists (ACL)
**Priority:** Critical
**Estimated Effort:** 3 days
### Phase 3: Security Features (Week 6-7)
**Goal:** Cover all Cerberus security features
#### 3.1 CrowdSec Integration
**Priority:** High
**Estimated Effort:** 4 days
**Test Files:**
**`tests/security/crowdsec-startup.spec.ts`**
**Objective:** Verify CrowdSec container lifecycle and connectivity
Test Scenarios:
- ✅ CrowdSec container starts successfully
- Verify container health check passes
- Verify LAPI (Local API) is accessible
- Verify logs show successful initialization
- ✅ CrowdSec LAPI connection from Charon
- Backend connects to CrowdSec LAPI
- Authentication succeeds
- API health endpoint returns 200
- ✅ CrowdSec bouncer registration
- Charon registers as a bouncer
- Bouncer API key generated
- Bouncer appears in CrowdSec bouncer list
- ✅ CrowdSec graceful shutdown
- Stop CrowdSec container
- Charon handles disconnection gracefully
- No errors in Charon logs
- Restart CrowdSec, Charon reconnects
- ✅ CrowdSec container restart recovery
- Kill CrowdSec container abruptly
- Charon detects connection loss
- Auto-reconnect after CrowdSec restarts
- ✅ CrowdSec version compatibility
- Verify minimum version check
- Warn if CrowdSec version too old
- Block connection if incompatible
**Key Assertions:**
- Container health: `docker inspect crowdsec --format '{{.State.Health.Status}}' == 'healthy'`
- LAPI reachable: `curl http://crowdsec:8080/health` returns 200
- Bouncer registered: API call to `/v1/bouncers` shows Charon
- Logs clean: No error/warning logs after startup
**`tests/security/crowdsec-decisions.spec.ts`**
**Objective:** Test IP ban management and decision enforcement
Test Scenarios:
**Manual IP Ban:**
- ✅ Add IP ban via Charon UI
- Navigate to CrowdSec → Decisions
- Click "Add Decision"
- Enter IP address (e.g., `192.168.1.100`)
- Select ban duration (1h, 4h, 24h, permanent)
- Select scope (IP, range, country)
- Add ban reason (e.g., "Suspicious activity")
- Save decision
- ✅ Verify decision appears in CrowdSec
- Query CrowdSec LAPI `/v1/decisions`
- Verify decision contains correct IP, duration, reason
- ✅ Banned IP cannot access proxy hosts
- Make HTTP request from banned IP
- Verify 403 Forbidden response
- Verify block logged in audit log
**Automatic IP Ban (Scenario-based):**
- ✅ Trigger ban via brute force scenario
- Simulate 10 failed login attempts from IP
- CrowdSec scenario detects pattern
- Decision created automatically
- IP banned for configured duration
- ✅ Trigger ban via HTTP flood scenario
- Send 100 requests/second from IP
- CrowdSec detects flood pattern
- IP banned automatically
**Ban Duration & Expiration:**
- ✅ Temporary ban expires automatically
- Create 5-second ban
- Verify IP blocked immediately
- Wait 6 seconds
- Verify IP can access again
- ✅ Permanent ban persists
- Create permanent ban
- Restart Charon and CrowdSec
- Verify ban still active
- ✅ Manual ban removal
- Select active ban
- Click "Remove Decision"
- Confirm removal
- Verify IP can access immediately
**Ban Scope Testing:**
- ✅ Single IP ban: `192.168.1.100`
- Only that IP blocked
- `192.168.1.101` can access
- ✅ IP range ban: `192.168.1.0/24`
- All IPs in range blocked
- `192.168.2.1` can access
- ✅ Country-level ban: `CN` (China)
- All Chinese IPs blocked
- Uses GeoIP database
- Other countries can access
**Decision Priority & Conflicts:**
- ✅ Allow decision overrides ban decision
- Ban IP range `10.0.0.0/8`
- Allow specific IP `10.0.0.5`
- Verify `10.0.0.5` can access
- Verify `10.0.0.6` is blocked
- ✅ Multiple decisions for same IP
- Add 1-hour ban
- Add 24-hour ban
- Longer duration takes precedence
**Decision Metadata:**
- ✅ Display decision details
- IP/Range/Country
- Ban duration and expiry time
- Scenario that triggered ban
- Reason/origin
- Creation timestamp
- ✅ Decision history
- View past decisions (expired/deleted)
- Filter by IP, scenario, date range
- Export decision history
**Key User Flows:**
1. **Manual Ban Flow:**
```
CrowdSec page → Decisions tab → Add Decision →
Enter IP → Select duration → Save → Verify block
```
2. **Automatic Ban Flow:**
```
Trigger scenario (e.g., brute force) →
CrowdSec detects → Decision created →
View in Decisions tab → Verify block
```
3. **Ban Removal Flow:**
```
CrowdSec page → Decisions tab → Select decision →
Remove → Confirm → Verify access restored
```
**Critical Assertions:**
- Banned IPs receive 403 status code
- Decision sync between Charon and CrowdSec
- Expiration timing accurate (±5 seconds)
- Allow decisions override ban decisions
- Decision changes appear in audit log
**`tests/security/crowdsec-presets.spec.ts`**
**Objective:** Test CrowdSec scenario preset management
Test Scenarios:
**Preset Listing:**
- ✅ View available presets
- Navigate to CrowdSec → Presets
- Display preset categories (Web, SSH, System)
- Show preset descriptions
- Indicate enabled/disabled status
**Enable/Disable Presets:**
- ✅ Enable web attack preset
- Select "Web Attacks" preset
- Click "Enable"
- Verify scenarios installed in CrowdSec
- Verify collection appears in CrowdSec collections list
- ✅ Disable web attack preset
- Select enabled preset
- Click "Disable"
- Verify scenarios removed
- Existing decisions preserved
- ✅ Bulk enable multiple presets
- Select multiple presets
- Click "Enable Selected"
- All scenarios installed
**Custom Scenarios:**
- ✅ Create custom scenario
- Click "Add Custom Scenario"
- Enter scenario name (e.g., "api-abuse")
- Define pattern (e.g., 50 requests to /api in 10s)
- Set ban duration (e.g., 1h)
- Save scenario
- Verify scenario YAML created
- ✅ Test custom scenario
- Trigger scenario conditions
- Verify decision created
- Verify ban enforced
- ✅ Edit custom scenario
- Modify pattern thresholds
- Save changes
- Reload CrowdSec scenarios
- ✅ Delete custom scenario
- Select scenario
- Confirm deletion
- Scenario removed from CrowdSec
**Preset Configuration:**
- ✅ Configure scenario thresholds
- Select scenario (e.g., "http-bf" brute force)
- Modify threshold (e.g., 5 → 10 failed attempts)
- Modify time window (e.g., 30s → 60s)
- Save configuration
- Verify new thresholds apply
- ✅ Configure ban duration per scenario
- Different scenarios have different ban times
- Brute force: 4h ban
- Port scan: 24h ban
- Verify durations respected
**Scenario Testing & Validation:**
- ✅ Test scenario before enabling
- View scenario details
- Simulate trigger conditions in test mode
- Verify pattern matching works
- No actual bans created (dry-run)
- ✅ Scenario validation on save
- Invalid regex pattern rejected
- Impossible thresholds rejected (e.g., 0 requests)
- Missing required fields flagged
**Key User Flows:**
1. **Enable Preset Flow:**
```
CrowdSec page → Presets tab → Select preset →
Enable → Verify scenarios active
```
2. **Custom Scenario Flow:**
```
CrowdSec page → Presets tab → Add Custom →
Define pattern → Set duration → Save → Test trigger
```
3. **Configure Scenario Flow:**
```
CrowdSec page → Presets tab → Select scenario →
Edit thresholds → Save → Reload CrowdSec
```
**Critical Assertions:**
- Enabled scenarios appear in CrowdSec collections
- Scenario triggers create correct decisions
- Custom scenarios persist after restart
- Threshold changes take effect immediately
- Invalid scenarios rejected with clear error messages
#### 3.2 Coraza WAF (Web Application Firewall)
**Priority:** High
**Estimated Effort:** 3 days
**Test Files:**
**`tests/security/waf-config.spec.ts`**
Test Scenarios:
- ✅ Enable/disable WAF globally
- ✅ Configure WAF for specific proxy host
- ✅ Set WAF rule sets (OWASP Core Rule Set)
- ✅ Configure anomaly scoring thresholds
- ✅ Set blocking/logging mode
- ✅ Custom rule creation
- ✅ Rule exclusions (false positive handling)
**`tests/security/waf-blocking.spec.ts`**
Test Scenarios:
- ✅ Block SQL injection attempts
- Send request with `' OR 1=1--` in query
- Verify 403 response
- Verify attack logged
- ✅ Block XSS attempts
- Send request with `<script>alert('xss')</script>`
- Verify 403 response
- ✅ Block path traversal attempts
- Send request with `../../etc/passwd`
- Verify 403 response
- ✅ Block command injection attempts
- ✅ Block file upload attacks
- ✅ Allow legitimate requests
- Normal user traffic passes through
- No false positives
**Key Assertions:**
- Malicious requests blocked (403 status)
- Legitimate requests allowed (200/3xx status)
- Attacks logged in audit log with details
- WAF performance overhead <50ms
#### 3.3 Rate Limiting
**Priority:** High
**Estimated Effort:** 2 days
### Phase 4: Settings (Week 8)
**Goal:** Cover system configuration and user management
**Estimated Effort:** 5 days
**Test Files:**
- `tests/settings/system-settings.spec.ts` - System configuration
- `tests/settings/smtp-settings.spec.ts` - Email configuration
- `tests/settings/notifications.spec.ts` - Notification rules
- `tests/settings/user-management.spec.ts` - User CRUD and roles
- `tests/settings/encryption-management.spec.ts` - Encryption key rotation
- `tests/settings/account-settings.spec.ts` - User profile management
**Key Features:**
- System configuration (timezone, language, theme)
- Email settings (SMTP, templates)
- Notification rules (email, webhook)
- User management (CRUD, roles, permissions)
- Encryption management (key rotation, backup)
- Account settings (profile, password, 2FA)
### Phase 5: Tasks (Week 9)
**Goal:** Cover backup, logs, and monitoring features
**Estimated Effort:** 5 days
**Test Files:**
- `tests/tasks/backups-create.spec.ts` - Backup creation
- `tests/tasks/backups-restore.spec.ts` - Backup restoration
- `tests/tasks/logs-viewing.spec.ts` - Log viewer functionality
- `tests/tasks/import-caddyfile.spec.ts` - Caddyfile import
- `tests/tasks/import-crowdsec.spec.ts` - CrowdSec config import
- `tests/monitoring/uptime-monitoring.spec.ts` - Uptime checks
- `tests/monitoring/real-time-logs.spec.ts` - WebSocket log streaming
**Key Features:**
- Backup creation (manual, scheduled)
- Backup restoration (full, selective)
- Log viewing (filtering, search, export)
- Caddyfile import (validation, migration)
- CrowdSec import (scenarios, decisions)
- Uptime monitoring (HTTP checks, alerts)
- Real-time logs (WebSocket, filtering)
### Phase 6: Integration & Buffer (Week 10)
**Goal:** Test cross-feature interactions, edge cases, and provide buffer for overruns
**Estimated Effort:** 5 days (3 days testing + 2 days buffer)
**Test Files:**
- `tests/integration/proxy-acl-integration.spec.ts` - Proxy + ACL
- `tests/integration/proxy-certificate.spec.ts` - Proxy + SSL
- `tests/integration/security-suite-integration.spec.ts` - Full security stack
- `tests/integration/backup-restore-e2e.spec.ts` - Full backup cycle
**Key Scenarios:**
- Create proxy host with ACL and SSL certificate
- Test security stack: WAF + CrowdSec + Rate Limiting
- Full backup → Restore → Verify all data intact
- Multi-feature workflows (e.g., import Caddyfile + enable security)
**Buffer Time:**
- Address flaky tests discovered in previous phases
- Fix any infrastructure issues
- Improve test stability and reliability
- Documentation updates
---
## Success Metrics & Acceptance Criteria
### Coverage Goals
**Test Coverage Targets:**
- 🎯 **Core Features:** 100% coverage (auth, navigation, dashboard)
- 🎯 **Critical Features:** 100% coverage (proxy hosts, ACLs, certificates)
- 🎯 **High Priority Features:** 90% coverage (security suite, backups)
- 🎯 **Medium Priority Features:** 80% coverage (settings, monitoring)
- 🎯 **Nice-to-Have Features:** 70% coverage (imports, plugins)
**Feature Coverage Matrix:**
| Feature | Priority | Target Coverage | Test Files | Status |
|---------|----------|----------------|------------|--------|
| Authentication | Critical | 100% | 1 | ✅ Covered |
| Dashboard | Core | 100% | 1 | ❌ Not started |
| Navigation | Core | 100% | 1 | ❌ Not started |
| Proxy Hosts | Critical | 100% | 3 | ❌ Not started |
| Certificates | Critical | 100% | 3 | ❌ Not started |
| Access Lists | Critical | 100% | 2 | ❌ Not started |
| CrowdSec | High | 90% | 3 | ❌ Not started |
| WAF | High | 90% | 1 | ❌ Not started |
| Rate Limiting | High | 90% | 1 | ❌ Not started |
| Security Headers | Medium | 80% | 1 | ❌ Not started |
| Audit Logs | Medium | 80% | 1 | ❌ Not started |
| Backups | High | 90% | 2 | ❌ Not started |
| Users | High | 90% | 2 | ❌ Not started |
| Settings | Medium | 80% | 4 | ❌ Not started |
| Monitoring | Medium | 80% | 3 | ❌ Not started |
| Import/Export | Medium | 80% | 2 | ❌ Not started |
| DNS Providers | Critical | 100% | 4 | ✅ Covered |
---
## Next Steps
1. **Review and Approve Plan:** Stakeholder sign-off
2. **Set Up Test Infrastructure:** Fixtures, utilities, CI configuration
3. **Begin Phase 1 Implementation:** Foundation tests
4. **Daily Standup Check-ins:** Progress tracking, blocker resolution
5. **Weekly Demo:** Show completed test coverage
6. **Iterate Based on Feedback:** Adjust plan as needed
---
**Document Status:** Planning
**Last Updated:** January 16, 2026
**Next Review:** Upon Phase 1 completion (estimated Jan 24, 2026)
**Owner:** Planning Agent / QA Team