Files
Charon/docs/plans/phase5-implementation.md
2026-01-26 19:22:05 +00:00

64 KiB

Phase 5: Tasks & Monitoring - Detailed Implementation Plan

Status: IN PROGRESS Timeline: Week 9 Total Estimated Tests: 92-114 tests across 7 test files Last Updated: 2025-01-XX


Overview

Phase 5 covers backup management, log viewing, import wizards, and monitoring features. This document provides the complete implementation plan with exact file paths, test scenarios, UI selectors, API endpoints, and mock data requirements.


Directory Structure

tests/
├── tasks/
│   ├── backups-create.spec.ts      # 17 tests - Backup creation, list, delete, download
│   ├── backups-restore.spec.ts     # 8 tests  - Backup restoration workflows
│   ├── logs-viewing.spec.ts        # 18 tests - Static log file viewing
│   ├── import-caddyfile.spec.ts    # 18 tests - Caddyfile import wizard
│   └── import-crowdsec.spec.ts     # 8 tests  - CrowdSec config import
└── monitoring/
    ├── uptime-monitoring.spec.ts   # 22 tests - Uptime monitor CRUD & sync
    └── real-time-logs.spec.ts      # 20 tests - WebSocket log streaming

Implementation Order

Execute in this order based on dependencies and complexity:

Order File Priority Reason Depends On
1 backups-create.spec.ts P0 Foundation for restore tests auth-fixtures
2 backups-restore.spec.ts P0 Requires backup data backups-create
3 logs-viewing.spec.ts P0 Static logs, no WebSocket auth-fixtures
4 import-caddyfile.spec.ts P1 Multi-step wizard auth-fixtures
5 import-crowdsec.spec.ts P1 Simpler import flow auth-fixtures
6 uptime-monitoring.spec.ts P1 Monitor CRUD auth-fixtures
7 real-time-logs.spec.ts P2 WebSocket complexity logs-viewing

File 1: tests/tasks/backups-create.spec.ts

Route & Component Mapping

Route Component Source File
/tasks/backups Backups.tsx frontend/src/pages/Backups.tsx

API Endpoints

Method Endpoint Purpose Response
GET /api/v1/backups List all backups BackupFile[]
POST /api/v1/backups Create new backup BackupFile
DELETE /api/v1/backups/:filename Delete backup 204 No Content
GET /api/v1/backups/:filename/download Download backup Binary file

TypeScript Interfaces

interface BackupFile {
  filename: string;  // e.g., "backup_2024-01-15_120000.tar.gz"
  size: number;      // Bytes
  time: string;      // ISO timestamp
}

UI Selectors

// Page elements
const SELECTORS = {
  // Page shell
  pageTitle: 'h1 >> text=Backups',

  // Create button
  createBackupButton: 'button:has-text("Create Backup")',

  // Backup list (DataTable component)
  backupTable: '[role="table"]',
  backupRows: '[role="row"]',
  emptyState: '[data-testid="empty-state"]',

  // Row actions
  restoreButton: 'button:has-text("Restore")',
  deleteButton: 'button:has-text("Delete")',
  downloadButton: 'button:has([data-icon="download"])',

  // Confirmation dialogs (Dialog component)
  confirmDialog: '[role="dialog"]',
  confirmButton: 'button:has-text("Confirm")',
  cancelButton: 'button:has-text("Cancel")',

  // Settings section (optional)
  retentionInput: 'input[name="retention"]',
  intervalSelect: 'select[name="interval"]',
  saveSettingsButton: 'button:has-text("Save Settings")',

  // Loading states
  loadingSpinner: '[data-testid="loading"]',
  skeleton: '[data-testid="skeleton"]',
};

Test Scenarios (17 tests)

Page Layout & Navigation (3 tests)

# Test Name Description Priority Auth
1 should display backups page with correct heading Navigate to /tasks/backups, verify page title P0 admin
2 should show Create Backup button for admin users Verify button visible for admin role P0 admin
3 should hide Create Backup button for guest users Verify button hidden for guest role P1 guest
test.describe('Page Layout', () => {
  test('should display backups page with correct heading', async ({ page }) => {
    await page.goto('/tasks/backups');
    await waitForLoadingComplete(page);
    await expect(page.locator('h1')).toContainText('Backups');
  });

  test('should show Create Backup button for admin users', async ({ page }) => {
    await page.goto('/tasks/backups');
    await expect(page.locator(SELECTORS.createBackupButton)).toBeVisible();
  });
});

test.describe('Guest Access', () => {
  test.use({ ...guestUser });

  test('should hide Create Backup button for guest users', async ({ page }) => {
    await page.goto('/tasks/backups');
    await expect(page.locator(SELECTORS.createBackupButton)).not.toBeVisible();
  });
});

Backup List Display (4 tests)

# Test Name Description Priority
4 should display empty state when no backups exist Show EmptyState component P0
5 should display list of existing backups Show table with filename, size, time P0
6 should sort backups by date newest first Verify descending order P1
7 should show loading skeleton while fetching Skeleton during API call P2
test('should display empty state when no backups exist', async ({ page }) => {
  // Mock empty response
  await page.route('**/api/v1/backups', route => {
    route.fulfill({ status: 200, json: [] });
  });

  await page.goto('/tasks/backups');
  await expect(page.locator(SELECTORS.emptyState)).toBeVisible();
  await expect(page.getByText('No backups found')).toBeVisible();
});

test('should display list of existing backups', async ({ page }) => {
  const mockBackups: BackupFile[] = [
    { filename: 'backup_2024-01-15_120000.tar.gz', size: 1048576, time: '2024-01-15T12:00:00Z' },
    { filename: 'backup_2024-01-14_120000.tar.gz', size: 2097152, time: '2024-01-14T12:00:00Z' },
  ];

  await page.route('**/api/v1/backups', route => {
    route.fulfill({ status: 200, json: mockBackups });
  });

  await page.goto('/tasks/backups');
  await waitForTableLoad(page, page.locator(SELECTORS.backupTable));

  // Verify both backups displayed
  await expect(page.getByText('backup_2024-01-15_120000.tar.gz')).toBeVisible();
  await expect(page.getByText('backup_2024-01-14_120000.tar.gz')).toBeVisible();
});

Create Backup Flow (5 tests)

# Test Name Description Priority
8 should create a new backup successfully Click button, verify API call P0
9 should show success toast after backup creation Toast appears with message P0
10 should update backup list with new backup List refreshes after creation P0
11 should disable create button while in progress Button disabled during API call P1
12 should handle backup creation failure Show error toast on 500 P1
test('should create a new backup successfully', async ({ page }) => {
  const newBackup = { filename: 'backup_2024-01-16_120000.tar.gz', size: 512000, time: new Date().toISOString() };

  await page.route('**/api/v1/backups', async route => {
    if (route.request().method() === 'POST') {
      await route.fulfill({ status: 201, json: newBackup });
    } else {
      await route.continue();
    }
  });

  await page.goto('/tasks/backups');
  await page.click(SELECTORS.createBackupButton);

  await waitForAPIResponse(page, '/api/v1/backups', 201);
  await waitForToast(page, /backup created|success/i);
});

test('should disable create button while in progress', async ({ page }) => {
  // Delay response to observe disabled state
  await page.route('**/api/v1/backups', async route => {
    if (route.request().method() === 'POST') {
      await new Promise(r => setTimeout(r, 500));
      await route.fulfill({ status: 201, json: { filename: 'test.tar.gz', size: 100, time: new Date().toISOString() } });
    } else {
      await route.continue();
    }
  });

  await page.goto('/tasks/backups');
  await page.click(SELECTORS.createBackupButton);

  // Button should be disabled during request
  await expect(page.locator(SELECTORS.createBackupButton)).toBeDisabled();

  // After completion, button should be enabled
  await waitForAPIResponse(page, '/api/v1/backups', 201);
  await expect(page.locator(SELECTORS.createBackupButton)).toBeEnabled();
});

Delete Backup Flow (3 tests)

# Test Name Description Priority
13 should show confirmation dialog before deleting Click delete, dialog appears P0
14 should delete backup after confirmation Confirm, verify DELETE call P0
15 should show success toast after deletion Toast after successful delete P1
test('should show confirmation dialog before deleting', async ({ page }) => {
  await setupBackupsList(page); // Helper to mock backup list
  await page.goto('/tasks/backups');

  // Click delete on first backup
  await page.locator(SELECTORS.backupRows).first().locator(SELECTORS.deleteButton).click();

  // Verify dialog appears
  await expect(page.locator(SELECTORS.confirmDialog)).toBeVisible();
  await expect(page.getByText(/confirm|are you sure/i)).toBeVisible();
});

test('should delete backup after confirmation', async ({ page }) => {
  const filename = 'backup_2024-01-15_120000.tar.gz';
  let deleteRequested = false;

  await page.route(`**/api/v1/backups/${filename}`, async route => {
    if (route.request().method() === 'DELETE') {
      deleteRequested = true;
      await route.fulfill({ status: 204 });
    }
  });

  await setupBackupsList(page, [{ filename, size: 1000, time: new Date().toISOString() }]);
  await page.goto('/tasks/backups');

  // Trigger delete flow
  await page.locator(SELECTORS.deleteButton).first().click();
  await page.locator(SELECTORS.confirmButton).click();

  await waitForAPIResponse(page, `/api/v1/backups/${filename}`, 204);
  expect(deleteRequested).toBe(true);
});

Download Backup Flow (2 tests)

# Test Name Description Priority
16 should download backup file successfully Trigger download, verify request P0
17 should show error toast when download fails Handle 404/500 errors P1
test('should download backup file successfully', async ({ page }) => {
  const filename = 'backup_2024-01-15_120000.tar.gz';

  // Track download event
  const downloadPromise = page.waitForEvent('download');

  await page.route(`**/api/v1/backups/${filename}/download`, route => {
    route.fulfill({
      status: 200,
      headers: {
        'Content-Type': 'application/gzip',
        'Content-Disposition': `attachment; filename="${filename}"`,
      },
      body: Buffer.from('mock backup content'),
    });
  });

  await setupBackupsList(page, [{ filename, size: 1000, time: new Date().toISOString() }]);
  await page.goto('/tasks/backups');

  await page.locator(SELECTORS.downloadButton).first().click();

  const download = await downloadPromise;
  expect(download.suggestedFilename()).toBe(filename);
});

File 2: tests/tasks/backups-restore.spec.ts

API Endpoints

Method Endpoint Purpose Response
POST /api/v1/backups/:filename/restore Restore from backup { message: string }

UI Selectors

const SELECTORS = {
  // Restore specific
  restoreButton: 'button:has-text("Restore")',

  // Warning dialog (AlertDialog style)
  warningDialog: '[role="alertdialog"]',
  warningMessage: '[data-testid="restore-warning"]',

  // Confirmation input (type backup name)
  confirmationInput: 'input[placeholder*="backup name"]',
  confirmRestoreButton: 'button:has-text("Restore"):not([disabled])',

  // Progress indicator
  progressBar: '[role="progressbar"]',
  restoreStatus: '[data-testid="restore-status"]',
};

Test Scenarios (8 tests)

Restore Flow (6 tests)

# Test Name Description Priority
1 should show warning dialog before restore Click restore, see warning P0
2 should require explicit confirmation Must type backup name P0
3 should restore backup successfully Complete flow, API call P0
4 should show success toast after restoration Toast message P0
5 should show progress indicator during restore Progress bar visible P1
6 should handle restore failure gracefully Error toast on 500 P1
test.describe('Restore Flow', () => {
  test.beforeEach(async ({ page }) => {
    await setupBackupsList(page);
    await page.goto('/tasks/backups');
  });

  test('should show warning dialog before restore', async ({ page }) => {
    await page.locator(SELECTORS.restoreButton).first().click();

    await expect(page.locator(SELECTORS.warningDialog)).toBeVisible();
    await expect(page.getByText(/warning|caution|data loss/i)).toBeVisible();
    await expect(page.getByText(/current configuration will be replaced/i)).toBeVisible();
  });

  test('should require explicit confirmation', async ({ page }) => {
    await page.locator(SELECTORS.restoreButton).first().click();

    // Confirm button should be disabled initially
    await expect(page.locator(SELECTORS.confirmRestoreButton)).toBeDisabled();

    // Type backup name to enable
    await page.locator(SELECTORS.confirmationInput).fill('backup_2024-01-15');
    await expect(page.locator(SELECTORS.confirmRestoreButton)).toBeEnabled();
  });

  test('should restore backup successfully', async ({ page }) => {
    const filename = 'backup_2024-01-15_120000.tar.gz';

    await page.route(`**/api/v1/backups/${filename}/restore`, route => {
      route.fulfill({ status: 200, json: { message: 'Restore completed successfully' } });
    });

    await page.locator(SELECTORS.restoreButton).first().click();
    await page.locator(SELECTORS.confirmationInput).fill('backup_2024-01-15');
    await page.locator(SELECTORS.confirmRestoreButton).click();

    await waitForAPIResponse(page, `/api/v1/backups/${filename}/restore`, 200);
    await waitForToast(page, /restore.*success|completed/i);
  });
});

Post-Restore Verification (2 tests)

# Test Name Description Priority
7 should reload application state after restore App refreshes/reloads P1
8 should preserve user session after restore Stay logged in P2
test('should reload application state after restore', async ({ page }) => {
  // Track navigation/reload
  let reloadTriggered = false;
  page.on('load', () => { reloadTriggered = true; });

  await completeRestoreFlow(page);

  // After restore, app should reload or navigate
  await page.waitForTimeout(1000); // Allow reload to trigger
  expect(reloadTriggered).toBe(true);
});

File 3: tests/tasks/logs-viewing.spec.ts

Route & Component Mapping

Route Component Source File
/tasks/logs Logs.tsx frontend/src/pages/Logs.tsx
- LogTable.tsx frontend/src/components/LogTable.tsx
- LogFilters.tsx frontend/src/components/LogFilters.tsx

API Endpoints

Method Endpoint Purpose Response
GET /api/v1/logs List log files LogFile[]
GET /api/v1/logs/:filename Read log content LogResponse
GET /api/v1/logs/:filename/download Download log file Binary

TypeScript Interfaces

interface LogFile {
  name: string;
  size: number;
  modified: string;
}

interface LogResponse {
  entries: CaddyAccessLog[];
  total: number;
  page: number;
  limit: number;
}

interface CaddyAccessLog {
  level: string;
  ts: number;
  logger: string;
  msg: string;
  request: {
    remote_ip: string;
    method: string;
    host: string;
    uri: string;
    proto: string;
  };
  status: number;
  duration: number;
  size: number;
}

interface LogFilter {
  search?: string;
  level?: string;
  host?: string;
  status_min?: number;
  status_max?: number;
  sort?: 'asc' | 'desc';
  page?: number;
  limit?: number;
}

UI Selectors

const SELECTORS = {
  // Page layout
  pageTitle: 'h1 >> text=Logs',

  // Log file list (sidebar)
  logFileList: '[data-testid="log-file-list"]',
  logFileButton: 'button[data-log-file]',
  selectedLogFile: 'button[data-log-file][data-selected="true"]',

  // Log table
  logTable: '[data-testid="log-table"]',
  logTableRow: '[data-testid="log-entry"]',

  // Filters (LogFilters component)
  searchInput: 'input[placeholder*="Search"]',
  levelSelect: 'select[name="level"]',
  hostFilter: 'input[name="host"]',
  statusMinInput: 'input[name="status_min"]',
  statusMaxInput: 'input[name="status_max"]',
  clearFiltersButton: 'button:has-text("Clear")',

  // Pagination
  prevPageButton: 'button[aria-label="Previous page"]',
  nextPageButton: 'button[aria-label="Next page"]',
  pageInfo: '[data-testid="page-info"]',

  // Sort
  sortByTimestamp: 'th:has-text("Timestamp")',
  sortIndicator: '[data-sort]',

  // Empty state
  emptyLogState: '[data-testid="empty-log"]',
};

Test Scenarios (18 tests)

Page Layout (3 tests)

# Test Name Description Priority
1 should display logs page with file selector Page loads with sidebar P0
2 should show list of available log files Files listed in sidebar P0
3 should display log filters section Filter inputs visible P0

Log File Selection (4 tests)

# Test Name Description Priority
4 should list all available log files with metadata Filename, size, date P0
5 should load log content when file selected Click file, content loads P0
6 should show empty state for empty log files EmptyState component P1
7 should highlight selected log file Visual selection indicator P1
test('should list all available log files with metadata', async ({ page }) => {
  const mockFiles: LogFile[] = [
    { name: 'access.log', size: 1048576, modified: '2024-01-15T12:00:00Z' },
    { name: 'error.log', size: 256000, modified: '2024-01-15T11:30:00Z' },
  ];

  await page.route('**/api/v1/logs', route => {
    route.fulfill({ status: 200, json: mockFiles });
  });

  await page.goto('/tasks/logs');

  await expect(page.getByText('access.log')).toBeVisible();
  await expect(page.getByText('error.log')).toBeVisible();
  // Verify size display (formatted)
  await expect(page.getByText(/1.*MB|1048.*KB/i)).toBeVisible();
});

test('should load log content when file selected', async ({ page }) => {
  const mockEntries: CaddyAccessLog[] = [
    {
      level: 'info',
      ts: Date.now() / 1000,
      logger: 'http.log.access',
      msg: 'handled request',
      request: { remote_ip: '192.168.1.1', method: 'GET', host: 'example.com', uri: '/', proto: 'HTTP/2' },
      status: 200,
      duration: 0.05,
      size: 1234,
    },
  ];

  await page.route('**/api/v1/logs/access.log*', route => {
    route.fulfill({ status: 200, json: { entries: mockEntries, total: 1, page: 1, limit: 50 } });
  });

  await setupLogFiles(page);
  await page.goto('/tasks/logs');

  await page.click('button:has-text("access.log")');
  await waitForAPIResponse(page, '/api/v1/logs/access.log', 200);

  // Verify log entry displayed
  await expect(page.getByText('192.168.1.1')).toBeVisible();
  await expect(page.getByText('GET')).toBeVisible();
  await expect(page.getByText('200')).toBeVisible();
});

Log Content Display (5 tests)

# Test Name Description Priority
8 should display log entries in table format Table with columns P0
9 should show timestamp, level, method, uri, status Key columns visible P0
10 should paginate large log files Page controls work P1
11 should sort logs by timestamp Click header to sort P1
12 should highlight error entries Red styling for errors P2
test('should paginate large log files', async ({ page }) => {
  // Mock paginated response
  let requestedPage = 1;
  await page.route('**/api/v1/logs/access.log*', route => {
    const url = new URL(route.request().url());
    requestedPage = parseInt(url.searchParams.get('page') || '1');

    route.fulfill({
      status: 200,
      json: {
        entries: generateMockEntries(50, requestedPage),
        total: 150,
        page: requestedPage,
        limit: 50,
      },
    });
  });

  await selectLogFile(page, 'access.log');

  // Verify initial page
  await expect(page.locator(SELECTORS.pageInfo)).toContainText('Page 1');

  // Navigate to next page
  await page.click(SELECTORS.nextPageButton);
  await waitForAPIResponse(page, '/api/v1/logs/access.log', 200);

  await expect(page.locator(SELECTORS.pageInfo)).toContainText('Page 2');
  expect(requestedPage).toBe(2);
});

Log Filtering (6 tests)

# Test Name Description Priority
13 should filter logs by search text Text in message/uri P0
14 should filter logs by log level Level dropdown P0
15 should filter logs by host Host input P1
16 should filter logs by status code range Min/max status P1
17 should combine multiple filters All filters together P1
18 should clear all filters Clear button resets P1
test('should filter logs by search text', async ({ page }) => {
  let searchQuery = '';
  await page.route('**/api/v1/logs/access.log*', route => {
    const url = new URL(route.request().url());
    searchQuery = url.searchParams.get('search') || '';
    route.fulfill({ status: 200, json: { entries: [], total: 0, page: 1, limit: 50 } });
  });

  await selectLogFile(page, 'access.log');

  await page.fill(SELECTORS.searchInput, 'api/users');
  await page.keyboard.press('Enter');

  await waitForAPIResponse(page, '/api/v1/logs/access.log', 200);
  expect(searchQuery).toBe('api/users');
});

test('should combine multiple filters', async ({ page }) => {
  let capturedParams: Record<string, string> = {};
  await page.route('**/api/v1/logs/access.log*', route => {
    const url = new URL(route.request().url());
    capturedParams = Object.fromEntries(url.searchParams);
    route.fulfill({ status: 200, json: { entries: [], total: 0, page: 1, limit: 50 } });
  });

  await selectLogFile(page, 'access.log');

  // Apply multiple filters
  await page.fill(SELECTORS.searchInput, 'error');
  await page.selectOption(SELECTORS.levelSelect, 'error');
  await page.fill(SELECTORS.statusMinInput, '400');
  await page.fill(SELECTORS.statusMaxInput, '599');
  await page.keyboard.press('Enter');

  await waitForAPIResponse(page, '/api/v1/logs/access.log', 200);

  expect(capturedParams.search).toBe('error');
  expect(capturedParams.level).toBe('error');
  expect(capturedParams.status_min).toBe('400');
  expect(capturedParams.status_max).toBe('599');
});

File 4: tests/tasks/import-caddyfile.spec.ts

Route & Component Mapping

Route Component Source File
/tasks/import/caddyfile ImportCaddy.tsx frontend/src/pages/ImportCaddy.tsx
- ImportReviewTable.tsx frontend/src/components/ImportReviewTable.tsx
- ImportSitesModal.tsx frontend/src/components/ImportSitesModal.tsx

API Endpoints

Method Endpoint Purpose Response
POST /api/v1/import/upload Upload Caddyfile content ImportPreview
POST /api/v1/import/upload-multi Upload multiple files ImportPreview
GET /api/v1/import/status Get session status { has_pending, session? }
GET /api/v1/import/preview Get parsed preview ImportPreview
POST /api/v1/import/detect-imports Detect import directives { imports: string[] }
POST /api/v1/import/commit Commit import ImportCommitResult
DELETE /api/v1/import/cancel Cancel session 204

TypeScript Interfaces

interface ImportSession {
  id: string;
  state: 'pending' | 'reviewing' | 'completed' | 'failed' | 'transient';
  created_at: string;
  updated_at: string;
  source_file?: string;
}

interface ImportPreview {
  session: ImportSession;
  preview: {
    hosts: Array<{ domain_names: string; [key: string]: unknown }>;
    conflicts: string[];
    errors: string[];
  };
  caddyfile_content?: string;
  conflict_details?: Record<string, ConflictDetail>;
}

interface ImportCommitResult {
  created: number;
  updated: number;
  skipped: number;
  errors: string[];
}

UI Selectors

const SELECTORS = {
  // Upload section
  fileDropzone: '[data-testid="file-dropzone"]',
  fileInput: 'input[type="file"]',
  pasteTextarea: 'textarea[placeholder*="Paste"]',
  uploadButton: 'button:has-text("Upload")',

  // Import banner (active session)
  importBanner: '[data-testid="import-banner"]',
  continueButton: 'button:has-text("Continue")',
  cancelButton: 'button:has-text("Cancel")',

  // Preview/Review table
  reviewTable: '[data-testid="import-review-table"]',
  hostRow: '[data-testid="import-host-row"]',
  hostCheckbox: 'input[type="checkbox"][name="selected"]',
  conflictBadge: '[data-testid="conflict-badge"]',
  errorBadge: '[data-testid="error-badge"]',

  // Actions
  commitButton: 'button:has-text("Commit")',
  selectAllCheckbox: 'input[type="checkbox"][name="select-all"]',

  // Success modal
  successModal: '[data-testid="import-success-modal"]',
  viewHostsButton: 'button:has-text("View Hosts")',

  // Session expiry warning
  expiryWarning: '[data-testid="session-expiry-warning"]',
};

Test Scenarios (18 tests)

Upload Interface (6 tests)

# Test Name Description Priority
1 should display file upload dropzone Dropzone visible P0
2 should accept valid Caddyfile via file upload File input works P0
3 should accept valid Caddyfile via paste Textarea paste P0
4 should reject invalid file types Error for .exe, etc. P0
5 should show upload progress indicator Progress during upload P1
6 should detect import directives in Caddyfile Parse import statements P1
test('should accept valid Caddyfile via file upload', async ({ page }) => {
  const caddyfileContent = `
example.com {
  reverse_proxy localhost:3000
}
  `;

  await page.route('**/api/v1/import/upload', route => {
    route.fulfill({
      status: 200,
      json: {
        session: { id: 'test-session', state: 'reviewing', created_at: new Date().toISOString(), updated_at: new Date().toISOString() },
        preview: {
          hosts: [{ domain_names: 'example.com', forward_host: 'localhost', forward_port: 3000 }],
          conflicts: [],
          errors: [],
        },
      },
    });
  });

  await page.goto('/tasks/import/caddyfile');

  // Upload file
  const fileInput = page.locator(SELECTORS.fileInput);
  await fileInput.setInputFiles({
    name: 'Caddyfile',
    mimeType: 'text/plain',
    buffer: Buffer.from(caddyfileContent),
  });

  await waitForAPIResponse(page, '/api/v1/import/upload', 200);

  // Should show review table
  await expect(page.locator(SELECTORS.reviewTable)).toBeVisible();
});

test('should accept valid Caddyfile via paste', async ({ page }) => {
  await mockImportAPI(page);
  await page.goto('/tasks/import/caddyfile');

  await page.fill(SELECTORS.pasteTextarea, 'example.com {\n  reverse_proxy localhost:8080\n}');
  await page.click(SELECTORS.uploadButton);

  await waitForAPIResponse(page, '/api/v1/import/upload', 200);
  await expect(page.locator(SELECTORS.reviewTable)).toBeVisible();
});

Preview & Review (5 tests)

# Test Name Description Priority
7 should show parsed hosts from Caddyfile Hosts in review table P0
8 should display host configuration details Domain, upstream, etc. P0
9 should allow selection/deselection of hosts Checkboxes work P0
10 should show validation warnings for invalid configs Warning badges P1
11 should highlight conflicts with existing hosts Conflict indicator P1
test('should show parsed hosts from Caddyfile', async ({ page }) => {
  const mockPreview: ImportPreview = {
    session: { id: 'test', state: 'reviewing', created_at: '', updated_at: '' },
    preview: {
      hosts: [
        { domain_names: 'api.example.com', forward_host: 'api-server', forward_port: 8080 },
        { domain_names: 'web.example.com', forward_host: 'web-server', forward_port: 3000 },
      ],
      conflicts: [],
      errors: [],
    },
  };

  await mockImportPreview(page, mockPreview);
  await uploadCaddyfile(page, 'test content');

  // Verify both hosts shown
  await expect(page.getByText('api.example.com')).toBeVisible();
  await expect(page.getByText('web.example.com')).toBeVisible();

  // Verify upstream details
  await expect(page.getByText('api-server:8080')).toBeVisible();
  await expect(page.getByText('web-server:3000')).toBeVisible();
});

test('should highlight conflicts with existing hosts', async ({ page }) => {
  const mockPreview: ImportPreview = {
    session: { id: 'test', state: 'reviewing', created_at: '', updated_at: '' },
    preview: {
      hosts: [{ domain_names: 'existing.com' }],
      conflicts: ['existing.com'],
      errors: [],
    },
    conflict_details: {
      'existing.com': {
        existing: { forward_scheme: 'http', forward_host: 'old-server', forward_port: 80, ssl_forced: false, websocket: false, enabled: true },
        imported: { forward_scheme: 'https', forward_host: 'new-server', forward_port: 443, ssl_forced: true, websocket: true },
      },
    },
  };

  await mockImportPreview(page, mockPreview);
  await uploadCaddyfile(page, 'test');

  // Verify conflict badge visible
  await expect(page.locator(SELECTORS.conflictBadge)).toBeVisible();
  await expect(page.getByText(/conflict|already exists/i)).toBeVisible();
});

Commit Import (5 tests)

# Test Name Description Priority
12 should commit selected hosts POST with resolutions P0
13 should skip deselected hosts Uncheck excludes host P1
14 should show success toast after import Toast message P0
15 should navigate to proxy hosts after import Redirect to list P1
16 should handle partial import failures Some succeed, some fail P1
test('should commit selected hosts', async ({ page }) => {
  let commitPayload: any = null;
  await page.route('**/api/v1/import/commit', async route => {
    commitPayload = await route.request().postDataJSON();
    route.fulfill({
      status: 200,
      json: { created: 2, updated: 0, skipped: 0, errors: [] },
    });
  });

  await setupImportReview(page, 2);

  await page.click(SELECTORS.commitButton);
  await waitForAPIResponse(page, '/api/v1/import/commit', 200);

  expect(commitPayload).toBeTruthy();
  expect(commitPayload.session_uuid).toBeTruthy();
});

test('should skip deselected hosts', async ({ page }) => {
  let commitPayload: any = null;
  await page.route('**/api/v1/import/commit', async route => {
    commitPayload = await route.request().postDataJSON();
    route.fulfill({ status: 200, json: { created: 1, updated: 0, skipped: 1, errors: [] } });
  });

  await setupImportReview(page, 2);

  // Deselect first host
  await page.locator(SELECTORS.hostCheckbox).first().uncheck();

  await page.click(SELECTORS.commitButton);
  await waitForAPIResponse(page, '/api/v1/import/commit', 200);

  // Verify skipped host in payload
  expect(commitPayload.resolutions).toBeTruthy();
});

Session Management (2 tests)

# Test Name Description Priority
17 should handle import session timeout Graceful expiry handling P2
18 should show warning when session expiring Expiry warning banner P2
test('should handle import session timeout', async ({ page }) => {
  // Mock session expired error
  await page.route('**/api/v1/import/preview', route => {
    route.fulfill({ status: 410, json: { error: 'Import session expired' } });
  });

  await page.goto('/tasks/import/caddyfile');

  // Try to continue expired session
  await page.locator(SELECTORS.continueButton).click();

  await waitForToast(page, /session expired|try again/i);

  // Should return to upload state
  await expect(page.locator(SELECTORS.fileDropzone)).toBeVisible();
});

File 5: tests/tasks/import-crowdsec.spec.ts

Route & Component Mapping

Route Component Source File
/tasks/import/crowdsec ImportCrowdSec.tsx frontend/src/pages/ImportCrowdSec.tsx

API Endpoints

Method Endpoint Purpose Response
POST /api/v1/backups Create backup before import BackupFile
POST /api/v1/crowdsec/import Import CrowdSec config { message: string }

UI Selectors

const SELECTORS = {
  // File input
  fileInput: 'input[data-testid="crowdsec-import-file"]',
  uploadButton: 'button:has-text("Import")',

  // File type indicator
  acceptedFormats: '[data-testid="accepted-formats"]',

  // Progress/status
  importProgress: '[data-testid="import-progress"]',
  backupIndicator: '[data-testid="backup-created"]',

  // Validation
  invalidFileError: '[data-testid="invalid-file-error"]',
};

Test Scenarios (8 tests)

Upload Interface (4 tests)

# Test Name Description Priority
1 should display file upload interface Page loads correctly P0
2 should accept .tar.gz configuration files Valid file accepted P0
3 should accept .zip configuration files Valid file accepted P0
4 should reject invalid file types Error for .txt, .exe P0
test('should display file upload interface', async ({ page }) => {
  await page.goto('/tasks/import/crowdsec');

  await expect(page.locator(SELECTORS.fileInput)).toBeVisible();
  await expect(page.locator(SELECTORS.uploadButton)).toBeVisible();
  await expect(page.getByText(/\.tar\.gz|\.zip/i)).toBeVisible();
});

test('should accept .tar.gz configuration files', async ({ page }) => {
  await mockCrowdSecImportAPI(page);
  await page.goto('/tasks/import/crowdsec');

  await page.locator(SELECTORS.fileInput).setInputFiles({
    name: 'crowdsec-config.tar.gz',
    mimeType: 'application/gzip',
    buffer: Buffer.from('mock tar content'),
  });

  await page.click(SELECTORS.uploadButton);
  await waitForAPIResponse(page, '/api/v1/crowdsec/import', 200);
  await waitForToast(page, /success|imported/i);
});

Import Flow (4 tests)

# Test Name Description Priority
5 should create backup before import Backup API called first P0
6 should import CrowdSec configuration Import API called P0
7 should validate configuration format Parse errors shown P1
8 should handle import errors gracefully Error toast P1
test('should create backup before import', async ({ page }) => {
  let backupCalled = false;
  let importCalled = false;
  let callOrder: string[] = [];

  await page.route('**/api/v1/backups', async route => {
    if (route.request().method() === 'POST') {
      backupCalled = true;
      callOrder.push('backup');
      await route.fulfill({ status: 201, json: { filename: 'pre-import-backup.tar.gz', size: 1000, time: new Date().toISOString() } });
    } else {
      await route.continue();
    }
  });

  await page.route('**/api/v1/crowdsec/import', async route => {
    importCalled = true;
    callOrder.push('import');
    await route.fulfill({ status: 200, json: { message: 'Import successful' } });
  });

  await page.goto('/tasks/import/crowdsec');
  await uploadCrowdSecConfig(page);

  expect(backupCalled).toBe(true);
  expect(importCalled).toBe(true);
  expect(callOrder).toEqual(['backup', 'import']); // Backup MUST come first
});

File 6: tests/monitoring/uptime-monitoring.spec.ts

Route & Component Mapping

Route Component Source File
/uptime Uptime.tsx frontend/src/pages/Uptime.tsx
- MonitorCard (inline in Uptime.tsx)
- EditMonitorModal (inline in Uptime.tsx)

API Endpoints

Method Endpoint Purpose Response
GET /api/v1/uptime/monitors List all monitors UptimeMonitor[]
POST /api/v1/uptime/monitors Create monitor UptimeMonitor
PUT /api/v1/uptime/monitors/:id Update monitor UptimeMonitor
DELETE /api/v1/uptime/monitors/:id Delete monitor 204
GET /api/v1/uptime/monitors/:id/history Get heartbeat history UptimeHeartbeat[]
POST /api/v1/uptime/monitors/:id/check Trigger immediate check { message }
POST /api/v1/uptime/sync Sync with proxy hosts { synced: number }

TypeScript Interfaces

interface UptimeMonitor {
  id: string;
  upstream_host?: string;
  proxy_host_id?: number;
  remote_server_id?: number;
  name: string;
  type: string;           // 'http', 'tcp'
  url: string;
  interval: number;       // seconds
  enabled: boolean;
  status: string;         // 'up', 'down', 'unknown', 'paused'
  last_check?: string | null;
  latency: number;        // ms
  max_retries: number;
}

interface UptimeHeartbeat {
  id: number;
  monitor_id: string;
  status: string;
  latency: number;
  message: string;
  created_at: string;
}

UI Selectors

const SELECTORS = {
  // Page layout
  pageTitle: 'h1 >> text=Uptime',
  summaryCard: '[data-testid="uptime-summary"]',

  // Monitor cards
  monitorCard: '[data-testid="monitor-card"]',
  statusBadge: '[data-testid="status-badge"]',
  uptimePercentage: '[data-testid="uptime-percentage"]',
  lastCheck: '[data-testid="last-check"]',
  heartbeatBar: '[data-testid="heartbeat-bar"]',

  // Card actions
  refreshButton: 'button[aria-label="Check now"]',
  settingsDropdown: 'button[aria-label="Settings"]',
  editOption: '[role="menuitem"]:has-text("Edit")',
  deleteOption: '[role="menuitem"]:has-text("Delete")',
  toggleOption: '[role="menuitem"]:has-text("Pause")',

  // Edit modal
  editModal: '[role="dialog"]',
  nameInput: 'input[name="name"]',
  urlInput: 'input[name="url"]',
  intervalSelect: 'select[name="interval"]',
  saveButton: 'button:has-text("Save")',

  // Create button
  createButton: 'button:has-text("Add Monitor")',

  // Sync button
  syncButton: 'button:has-text("Sync")',

  // Empty state
  emptyState: '[data-testid="empty-state"]',

  // Confirmation dialog
  confirmDialog: '[role="alertdialog"]',
  confirmDelete: 'button:has-text("Delete")',
};

Test Scenarios (22 tests)

Page Layout (3 tests)

# Test Name Description Priority
1 should display uptime monitoring page Page loads correctly P0
2 should show monitor list or empty state Conditional display P0
3 should display overall uptime summary Summary card P1

Monitor List Display (5 tests)

# Test Name Description Priority
4 should display all monitors with status indicators Status badges P0
5 should show uptime percentage for each monitor Percentage display P0
6 should show last check timestamp Timestamp format P1
7 should differentiate up/down/unknown states Color-coded badges P0
8 should show heartbeat history bar Last 60 checks visual P1
test('should display all monitors with status indicators', async ({ page }) => {
  const mockMonitors: UptimeMonitor[] = [
    { id: '1', name: 'API Server', type: 'http', url: 'https://api.example.com', interval: 60, enabled: true, status: 'up', latency: 45, max_retries: 3 },
    { id: '2', name: 'Database', type: 'tcp', url: 'tcp://db.example.com:5432', interval: 30, enabled: true, status: 'down', latency: 0, max_retries: 3 },
    { id: '3', name: 'Cache', type: 'tcp', url: 'tcp://redis.example.com:6379', interval: 60, enabled: false, status: 'paused', latency: 0, max_retries: 3 },
  ];

  await page.route('**/api/v1/uptime/monitors', route => {
    route.fulfill({ status: 200, json: mockMonitors });
  });

  await page.goto('/uptime');
  await waitForLoadingComplete(page);

  // Verify all monitors displayed
  await expect(page.getByText('API Server')).toBeVisible();
  await expect(page.getByText('Database')).toBeVisible();
  await expect(page.getByText('Cache')).toBeVisible();

  // Verify status badges
  const upBadge = page.locator('[data-testid="status-badge"][data-status="up"]');
  const downBadge = page.locator('[data-testid="status-badge"][data-status="down"]');
  const pausedBadge = page.locator('[data-testid="status-badge"][data-status="paused"]');

  await expect(upBadge).toBeVisible();
  await expect(downBadge).toBeVisible();
  await expect(pausedBadge).toBeVisible();
});

test('should show heartbeat history bar', async ({ page }) => {
  const mockHistory: UptimeHeartbeat[] = Array.from({ length: 60 }, (_, i) => ({
    id: i,
    monitor_id: '1',
    status: i % 5 === 0 ? 'down' : 'up', // Every 5th is down
    latency: Math.random() * 100,
    message: 'OK',
    created_at: new Date(Date.now() - i * 60000).toISOString(),
  }));

  await mockMonitorsWithHistory(page, mockHistory);
  await page.goto('/uptime');

  // Verify heartbeat bar rendered
  const heartbeatBar = page.locator(SELECTORS.heartbeatBar).first();
  await expect(heartbeatBar).toBeVisible();

  // Verify bar has colored segments
  await expect(heartbeatBar.locator('[data-status="up"]')).toHaveCount(48);  // 60 - 12 down
  await expect(heartbeatBar.locator('[data-status="down"]')).toHaveCount(12); // Every 5th
});

Monitor CRUD (6 tests)

# Test Name Description Priority
9 should create new HTTP monitor Full create flow P0
10 should create new TCP monitor TCP type P1
11 should update existing monitor Edit and save P0
12 should delete monitor with confirmation Delete flow P0
13 should validate monitor URL format URL validation P0
14 should validate check interval Interval range P1
test('should create new HTTP monitor', async ({ page }) => {
  let createPayload: any = null;
  await page.route('**/api/v1/uptime/monitors', async route => {
    if (route.request().method() === 'POST') {
      createPayload = await route.request().postDataJSON();
      route.fulfill({
        status: 201,
        json: { id: 'new-id', ...createPayload, status: 'unknown', latency: 0 },
      });
    } else {
      route.fulfill({ status: 200, json: [] });
    }
  });

  await page.goto('/uptime');
  await page.click(SELECTORS.createButton);

  // Fill form
  await page.fill(SELECTORS.nameInput, 'New API Monitor');
  await page.fill(SELECTORS.urlInput, 'https://api.newservice.com/health');
  await page.selectOption(SELECTORS.intervalSelect, '60');

  await page.click(SELECTORS.saveButton);
  await waitForAPIResponse(page, '/api/v1/uptime/monitors', 201);

  expect(createPayload.name).toBe('New API Monitor');
  expect(createPayload.url).toBe('https://api.newservice.com/health');
  expect(createPayload.interval).toBe(60);
});

test('should delete monitor with confirmation', async ({ page }) => {
  let deleteRequested = false;
  await page.route('**/api/v1/uptime/monitors/1', async route => {
    if (route.request().method() === 'DELETE') {
      deleteRequested = true;
      route.fulfill({ status: 204 });
    }
  });

  await setupMonitorsList(page);
  await page.goto('/uptime');

  // Open settings dropdown on first monitor
  await page.locator(SELECTORS.settingsDropdown).first().click();
  await page.click(SELECTORS.deleteOption);

  // Confirmation dialog should appear
  await expect(page.locator(SELECTORS.confirmDialog)).toBeVisible();

  // Confirm deletion
  await page.click(SELECTORS.confirmDelete);
  await waitForAPIResponse(page, '/api/v1/uptime/monitors/1', 204);

  expect(deleteRequested).toBe(true);
});

Manual Check (3 tests)

# Test Name Description Priority
15 should trigger manual health check Check button click P0
16 should update status after manual check Status refreshes P0
17 should show check in progress indicator Loading state P1
test('should trigger manual health check', async ({ page }) => {
  let checkRequested = false;
  await page.route('**/api/v1/uptime/monitors/1/check', async route => {
    checkRequested = true;
    await new Promise(r => setTimeout(r, 300)); // Simulate check delay
    route.fulfill({ status: 200, json: { message: 'Check completed: UP' } });
  });

  await setupMonitorsList(page);
  await page.goto('/uptime');

  // Click refresh button on first monitor
  await page.locator(SELECTORS.refreshButton).first().click();

  // Should show loading indicator
  await expect(page.locator('[data-testid="check-loading"]')).toBeVisible();

  await waitForAPIResponse(page, '/api/v1/uptime/monitors/1/check', 200);
  expect(checkRequested).toBe(true);

  await waitForToast(page, /check.*completed|up/i);
});

Monitor History (3 tests)

# Test Name Description Priority
18 should display uptime history chart History visualization P1
19 should show incident timeline Down events listed P2
20 should filter history by date range Date picker P2

Sync with Proxy Hosts (2 tests)

# Test Name Description Priority
21 should sync monitors from proxy hosts Sync button P1
22 should preserve manually added monitors Sync doesn't delete P1
test('should sync monitors from proxy hosts', async ({ page }) => {
  let syncRequested = false;
  await page.route('**/api/v1/uptime/sync', async route => {
    syncRequested = true;
    route.fulfill({ status: 200, json: { synced: 3, message: '3 monitors synced from proxy hosts' } });
  });

  await setupMonitorsList(page);
  await page.goto('/uptime');

  await page.click(SELECTORS.syncButton);
  await waitForAPIResponse(page, '/api/v1/uptime/sync', 200);

  expect(syncRequested).toBe(true);
  await waitForToast(page, /3.*monitors.*synced/i);
});

File 7: tests/monitoring/real-time-logs.spec.ts

Route & Component Mapping

Route Component Source File
/tasks/logs (Live tab) LiveLogViewer.tsx frontend/src/components/LiveLogViewer.tsx

WebSocket Endpoints

Endpoint Purpose Message Type
WS /api/v1/logs/live Application logs stream LiveLogEntry
WS /api/v1/cerberus/logs/ws Security logs stream SecurityLogEntry

TypeScript Interfaces

type LogMode = 'application' | 'security';

interface LiveLogEntry {
  level: string;       // 'debug', 'info', 'warn', 'error', 'fatal'
  timestamp: string;   // ISO format
  message: string;
  source?: string;     // 'app', 'caddy', etc.
  data?: Record<string, unknown>;
}

interface SecurityLogEntry {
  timestamp: string;
  level: string;
  logger: string;
  client_ip: string;
  method: string;      // 'GET', 'POST', etc.
  uri: string;
  status: number;
  duration: number;    // seconds
  size: number;        // bytes
  user_agent: string;
  host: string;
  source: 'waf' | 'crowdsec' | 'ratelimit' | 'acl' | 'normal';
  blocked: boolean;
  block_reason?: string;
  details?: Record<string, unknown>;
}

UI Selectors

const SELECTORS = {
  // Connection status
  connectionStatus: '[data-testid="connection-status"]',
  connectedIndicator: '.bg-green-900',
  disconnectedIndicator: '.bg-red-900',
  connectionError: '[data-testid="connection-error"]',

  // Mode toggle
  modeToggle: '[data-testid="mode-toggle"]',
  applicationModeButton: 'button:has-text("App")',
  securityModeButton: 'button:has-text("Security")',

  // Controls
  pauseButton: 'button[title="Pause"]',
  playButton: 'button[title="Resume"]',
  clearButton: 'button[title="Clear logs"]',

  // Filters
  textFilter: 'input[placeholder*="Filter by text"]',
  levelSelect: 'select >> text=All Levels',
  sourceSelect: 'select >> text=All Sources',
  blockedOnlyCheckbox: 'input[type="checkbox"] >> text=Blocked only',

  // Log display
  logContainer: '.font-mono.text-xs',
  logEntry: '[data-testid="log-entry"]',
  blockedEntry: '.bg-red-900\\/30',

  // Footer
  logCount: '[data-testid="log-count"]',
  pausedIndicator: '.text-yellow-400 >> text=Paused',
};

Test Scenarios (20 tests)

WebSocket Connection (6 tests)

# Test Name Description Priority
1 should establish WebSocket connection WS connects on load P0
2 should show connected status indicator Green badge P0
3 should handle connection failure gracefully Error message P0
4 should auto-reconnect on connection loss Reconnect logic P1
5 should authenticate via cookies Cookie-based auth P1
6 should recover from network interruption Network resume P1
test('should establish WebSocket connection', async ({ page }) => {
  let wsConnected = false;

  page.on('websocket', ws => {
    if (ws.url().includes('/api/v1/cerberus/logs/ws')) {
      ws.on('open', () => { wsConnected = true; });
    }
  });

  await page.goto('/tasks/logs');

  // Switch to live logs tab if needed
  await page.click('[data-testid="live-logs-tab"]');

  await waitForWebSocketConnection(page);
  expect(wsConnected).toBe(true);

  // Verify connected indicator
  await expect(page.locator(SELECTORS.connectionStatus)).toContainText('Connected');
});

test('should show connected status indicator', async ({ page }) => {
  await page.goto('/tasks/logs');
  await page.click('[data-testid="live-logs-tab"]');

  await waitForWebSocketConnection(page);

  await expect(page.locator(SELECTORS.connectedIndicator)).toBeVisible();
  await expect(page.locator(SELECTORS.connectionStatus)).toContainText('Connected');
});

test('should handle connection failure gracefully', async ({ page }) => {
  // Block WebSocket endpoint
  await page.route('**/api/v1/cerberus/logs/ws', route => {
    route.abort('connectionrefused');
  });
  await page.route('**/api/v1/logs/live', route => {
    route.abort('connectionrefused');
  });

  await page.goto('/tasks/logs');
  await page.click('[data-testid="live-logs-tab"]');

  // Should show disconnected/error state
  await expect(page.locator(SELECTORS.disconnectedIndicator)).toBeVisible();
  await expect(page.locator(SELECTORS.connectionError)).toBeVisible();
});

Log Streaming (5 tests)

# Test Name Description Priority
7 should display incoming log entries in real-time Live updates P0
8 should auto-scroll to latest logs Scroll behavior P1
9 should respect max log limit of 500 entries Memory limit P1
10 should format timestamps correctly Time display P1
11 should colorize log levels appropriately Level colors P2
test('should display incoming log entries in real-time', async ({ page }) => {
  const testEntries: SecurityLogEntry[] = [
    {
      timestamp: new Date().toISOString(),
      level: 'info',
      logger: 'http',
      client_ip: '192.168.1.100',
      method: 'GET',
      uri: '/api/users',
      status: 200,
      duration: 0.045,
      size: 1234,
      user_agent: 'Mozilla/5.0',
      host: 'api.example.com',
      source: 'normal',
      blocked: false,
    },
  ];

  await page.goto('/tasks/logs');
  await page.click('[data-testid="live-logs-tab"]');

  // Wait for WebSocket, then send mock message
  await page.evaluate((entries) => {
    // Simulate WebSocket message
    const event = new CustomEvent('mock-ws-message', { detail: entries[0] });
    window.dispatchEvent(event);
  }, testEntries);

  // Alternative: Use Playwright's WebSocket interception
  page.on('websocket', ws => {
    ws.on('framereceived', () => {
      // Log received
    });
  });

  // Verify entry displayed
  await expect(page.getByText('192.168.1.100')).toBeVisible();
  await expect(page.getByText('GET /api/users')).toBeVisible();
});

test('should respect max log limit of 500 entries', async ({ page }) => {
  await page.goto('/tasks/logs');
  await page.click('[data-testid="live-logs-tab"]');
  await waitForWebSocketConnection(page);

  // Send 550 mock log entries
  await page.evaluate(() => {
    for (let i = 0; i < 550; i++) {
      window.dispatchEvent(new CustomEvent('mock-ws-message', {
        detail: { timestamp: new Date().toISOString(), message: `Log ${i}`, level: 'info' }
      }));
    }
  });

  // Wait for rendering
  await page.waitForTimeout(500);

  // Should only have ~500 entries
  const logCount = await page.locator(SELECTORS.logEntry).count();
  expect(logCount).toBeLessThanOrEqual(500);

  // Footer should show limit info
  await expect(page.locator(SELECTORS.logCount)).toContainText(/500/);
});

Mode Switching (3 tests)

# Test Name Description Priority
12 should toggle between Application and Security modes Mode switch P0
13 should clear logs when switching modes Reset on switch P1
14 should reconnect to correct WebSocket endpoint Different WS P0
test('should toggle between Application and Security modes', async ({ page }) => {
  await page.goto('/tasks/logs');
  await page.click('[data-testid="live-logs-tab"]');

  // Default is security mode
  await expect(page.locator(SELECTORS.securityModeButton)).toHaveAttribute('data-active', 'true');

  // Switch to application mode
  await page.click(SELECTORS.applicationModeButton);
  await expect(page.locator(SELECTORS.applicationModeButton)).toHaveAttribute('data-active', 'true');

  // Source filter should be hidden in app mode
  await expect(page.locator(SELECTORS.sourceSelect)).not.toBeVisible();

  // Switch back to security
  await page.click(SELECTORS.securityModeButton);
  await expect(page.locator(SELECTORS.sourceSelect)).toBeVisible();
});

test('should reconnect to correct WebSocket endpoint', async ({ page }) => {
  const connectedEndpoints: string[] = [];

  page.on('websocket', ws => {
    connectedEndpoints.push(ws.url());
  });

  await page.goto('/tasks/logs');
  await page.click('[data-testid="live-logs-tab"]');
  await waitForWebSocketConnection(page);

  // Should connect to security endpoint first
  expect(connectedEndpoints.some(url => url.includes('/cerberus/logs/ws'))).toBe(true);

  // Switch to application mode
  await page.click(SELECTORS.applicationModeButton);
  await waitForWebSocketConnection(page);

  // Should connect to live logs endpoint
  expect(connectedEndpoints.some(url => url.includes('/logs/live'))).toBe(true);
});

Live Filters (4 tests)

# Test Name Description Priority
15 should filter by text search Client-side filter P0
16 should filter by log level Level dropdown P0
17 should filter by source in security mode Source dropdown P1
18 should filter blocked requests only Checkbox filter P1
test('should filter by text search', async ({ page }) => {
  await setupLiveLogsWithMockData(page, [
    { message: 'User login successful', client_ip: '10.0.0.1' },
    { message: 'API request to /users', client_ip: '10.0.0.2' },
    { message: 'Database connection', client_ip: '10.0.0.3' },
  ]);

  await page.goto('/tasks/logs');
  await page.click('[data-testid="live-logs-tab"]');

  // All 3 entries visible initially
  await expect(page.locator(SELECTORS.logEntry)).toHaveCount(3);

  // Filter by "login"
  await page.fill(SELECTORS.textFilter, 'login');

  // Only 1 entry should be visible
  await expect(page.locator(SELECTORS.logEntry)).toHaveCount(1);
  await expect(page.getByText('User login successful')).toBeVisible();
});

test('should filter blocked requests only', async ({ page }) => {
  await setupLiveLogsWithMockData(page, [
    { blocked: false, message: 'Normal request' },
    { blocked: true, block_reason: 'WAF rule', message: 'Blocked by WAF' },
    { blocked: false, message: 'Another normal' },
  ]);

  await page.goto('/tasks/logs');
  await page.click('[data-testid="live-logs-tab"]');

  // All 3 entries visible
  await expect(page.locator(SELECTORS.logEntry)).toHaveCount(3);

  // Check "Blocked only"
  await page.check(SELECTORS.blockedOnlyCheckbox);

  // Only 1 blocked entry visible
  await expect(page.locator(SELECTORS.logEntry)).toHaveCount(1);
  await expect(page.locator(SELECTORS.blockedEntry)).toBeVisible();
});

Playback Controls (2 tests)

# Test Name Description Priority
19 should pause and resume log streaming Pause/play toggle P0
20 should clear all logs Clear button P1
test('should pause and resume log streaming', async ({ page }) => {
  await page.goto('/tasks/logs');
  await page.click('[data-testid="live-logs-tab"]');
  await waitForWebSocketConnection(page);

  // Click pause
  await page.click(SELECTORS.pauseButton);

  // Should show paused indicator
  await expect(page.locator(SELECTORS.pausedIndicator)).toBeVisible();

  // Pause button should become play button
  await expect(page.locator(SELECTORS.playButton)).toBeVisible();

  // Send new log (should be ignored while paused)
  const countBefore = await page.locator(SELECTORS.logEntry).count();
  await sendMockLogEntry(page);
  const countAfter = await page.locator(SELECTORS.logEntry).count();
  expect(countAfter).toBe(countBefore);

  // Resume
  await page.click(SELECTORS.playButton);
  await expect(page.locator(SELECTORS.pausedIndicator)).not.toBeVisible();

  // New logs should now appear
  await sendMockLogEntry(page);
  await expect(page.locator(SELECTORS.logEntry)).toHaveCount(countBefore + 1);
});

test('should clear all logs', async ({ page }) => {
  await setupLiveLogsWithMockData(page, [
    { message: 'Log 1' },
    { message: 'Log 2' },
    { message: 'Log 3' },
  ]);

  await page.goto('/tasks/logs');
  await page.click('[data-testid="live-logs-tab"]');

  // Logs visible
  await expect(page.locator(SELECTORS.logEntry)).toHaveCount(3);

  // Clear logs
  await page.click(SELECTORS.clearButton);

  // All logs cleared
  await expect(page.locator(SELECTORS.logEntry)).toHaveCount(0);
  await expect(page.getByText('No logs yet')).toBeVisible();
});

Helper Functions

Create these helper functions in tests/utils/phase5-helpers.ts:

import { Page } from '@playwright/test';
import { waitForAPIResponse, waitForWebSocketConnection } from './wait-helpers';

/**
 * Sets up mock backup list for testing
 */
export async function setupBackupsList(page: Page, backups?: BackupFile[]) {
  const defaultBackups: BackupFile[] = backups || [
    { filename: 'backup_2024-01-15_120000.tar.gz', size: 1048576, time: '2024-01-15T12:00:00Z' },
    { filename: 'backup_2024-01-14_120000.tar.gz', size: 2097152, time: '2024-01-14T12:00:00Z' },
  ];

  await page.route('**/api/v1/backups', route => {
    if (route.request().method() === 'GET') {
      route.fulfill({ status: 200, json: defaultBackups });
    } else {
      route.continue();
    }
  });
}

/**
 * Sets up mock log files for testing
 */
export async function setupLogFiles(page: Page, files?: LogFile[]) {
  const defaultFiles: LogFile[] = files || [
    { name: 'access.log', size: 1048576, modified: '2024-01-15T12:00:00Z' },
    { name: 'error.log', size: 256000, modified: '2024-01-15T11:30:00Z' },
  ];

  await page.route('**/api/v1/logs', route => {
    route.fulfill({ status: 200, json: defaultFiles });
  });
}

/**
 * Selects a log file and waits for content to load
 */
export async function selectLogFile(page: Page, filename: string) {
  await page.click(`button:has-text("${filename}")`);
  await waitForAPIResponse(page, `/api/v1/logs/${filename}`, 200);
}

/**
 * Sets up mock monitors list for testing
 */
export async function setupMonitorsList(page: Page, monitors?: UptimeMonitor[]) {
  const defaultMonitors: UptimeMonitor[] = monitors || [
    { id: '1', name: 'API Server', type: 'http', url: 'https://api.example.com', interval: 60, enabled: true, status: 'up', latency: 45, max_retries: 3 },
    { id: '2', name: 'Database', type: 'tcp', url: 'tcp://db:5432', interval: 30, enabled: true, status: 'down', latency: 0, max_retries: 3 },
  ];

  await page.route('**/api/v1/uptime/monitors', route => {
    if (route.request().method() === 'GET') {
      route.fulfill({ status: 200, json: defaultMonitors });
    } else {
      route.continue();
    }
  });
}

/**
 * Mock import API for Caddyfile testing
 */
export async function mockImportPreview(page: Page, preview: ImportPreview) {
  await page.route('**/api/v1/import/upload', route => {
    route.fulfill({ status: 200, json: preview });
  });
  await page.route('**/api/v1/import/preview', route => {
    route.fulfill({ status: 200, json: preview });
  });
}

/**
 * Generates mock log entries for pagination testing
 */
export function generateMockEntries(count: number, page: number): CaddyAccessLog[] {
  return Array.from({ length: count }, (_, i) => ({
    level: 'info',
    ts: Date.now() / 1000 - (page * count + i) * 60,
    logger: 'http.log.access',
    msg: 'handled request',
    request: {
      remote_ip: `192.168.1.${i % 255}`,
      method: 'GET',
      host: 'example.com',
      uri: `/page/${page * count + i}`,
      proto: 'HTTP/2',
    },
    status: 200,
    duration: 0.05,
    size: 1234,
  }));
}

/**
 * Simulates WebSocket network interruption for reconnection testing
 */
export async function simulateNetworkInterruption(page: Page, durationMs: number = 1000) {
  // Block WebSocket endpoints
  await page.route('**/api/v1/logs/live', route => route.abort());
  await page.route('**/api/v1/cerberus/logs/ws', route => route.abort());

  await page.waitForTimeout(durationMs);

  // Restore WebSocket endpoints
  await page.unroute('**/api/v1/logs/live');
  await page.unroute('**/api/v1/cerberus/logs/ws');
}

Acceptance Criteria

Backups (25 tests total)

  • All CRUD operations covered (create, list, delete, download)
  • Restore workflow with explicit confirmation
  • Error handling for failures
  • Role-based access (admin vs guest)

Logs (38 tests total)

  • Static log file viewing with filtering
  • WebSocket real-time streaming
  • Mode switching (Application/Security)
  • Pause/Resume/Clear controls
  • Client-side filtering

Imports (26 tests total)

  • Caddyfile upload and paste
  • Preview and review workflow
  • Conflict detection and resolution
  • CrowdSec import with backup

Uptime (22 tests total)

  • Monitor CRUD operations
  • Status indicator display
  • Manual health check
  • Heartbeat history visualization
  • Sync with proxy hosts

Overall Phase 5

  • 111+ tests total (target: 92-114)
  • <5% flaky test rate
  • All P0 tests complete
  • 90%+ P1 tests complete
  • No hardcoded waits
  • All tests use TestDataManager for cleanup
  • WebSocket tests properly mock connections

Test Execution Commands

# Run all Phase 5 tests
npx playwright test tests/tasks tests/monitoring --project=chromium

# Run specific test file
npx playwright test tests/tasks/backups-create.spec.ts --project=chromium

# Run with debug mode
npx playwright test tests/monitoring/real-time-logs.spec.ts --debug

# Run with coverage
npm run test:e2e:coverage -- tests/tasks tests/monitoring

# Generate report
npx playwright show-report

Notes

  1. WebSocket Testing: Use Playwright's page.on('websocket', ...) for real WebSocket testing. For complex scenarios, consider mocking at the API level.

  2. Session Timeouts: Import session tests require understanding server-side TTL. Mock 410 responses for expiry scenarios.

  3. Backup Download: Browser download events must be captured with page.waitForEvent('download').

  4. Real-time Updates: Use retryAction from wait-helpers for assertions that depend on WebSocket messages.

  5. Test Data Cleanup: All tests creating backups or monitors should use TestDataManager for cleanup in afterEach.