diff --git a/backend/internal/api/handlers/uptime_handler.go b/backend/internal/api/handlers/uptime_handler.go index 7eeb9206..33d48869 100644 --- a/backend/internal/api/handlers/uptime_handler.go +++ b/backend/internal/api/handlers/uptime_handler.go @@ -27,6 +27,34 @@ func (h *UptimeHandler) List(c *gin.Context) { c.JSON(http.StatusOK, monitors) } +// CreateMonitorRequest represents the JSON payload for creating a new monitor +type CreateMonitorRequest struct { + Name string `json:"name" binding:"required"` + URL string `json:"url" binding:"required"` + Type string `json:"type" binding:"required,oneof=http tcp https"` + Interval int `json:"interval"` + MaxRetries int `json:"max_retries"` +} + +// Create creates a new uptime monitor +func (h *UptimeHandler) Create(c *gin.Context) { + var req CreateMonitorRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.Log().WithError(err).Warn("Invalid JSON payload for monitor creation") + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + monitor, err := h.service.CreateMonitor(req.Name, req.URL, req.Type, req.Interval, req.MaxRetries) + if err != nil { + logger.Log().WithError(err).Error("Failed to create uptime monitor") + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, monitor) +} + func (h *UptimeHandler) GetHistory(c *gin.Context) { id := c.Param("id") limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) diff --git a/backend/internal/api/handlers/uptime_handler_test.go b/backend/internal/api/handlers/uptime_handler_test.go index 24a94f7e..2e190bcf 100644 --- a/backend/internal/api/handlers/uptime_handler_test.go +++ b/backend/internal/api/handlers/uptime_handler_test.go @@ -31,6 +31,7 @@ func setupUptimeHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) { api := r.Group("/api/v1") uptime := api.Group("/uptime") uptime.GET("", handler.List) + uptime.POST("", handler.Create) uptime.GET(":id/history", handler.GetHistory) uptime.PUT(":id", handler.Update) uptime.DELETE(":id", handler.Delete) @@ -64,6 +65,194 @@ func TestUptimeHandler_List(t *testing.T) { assert.Equal(t, "Test Monitor", list[0].Name) } +func TestUptimeHandler_Create(t *testing.T) { + t.Run("success_http", func(t *testing.T) { + r, db := setupUptimeHandlerTest(t) + + payload := map[string]any{ + "name": "New HTTP Monitor", + "url": "https://example.com", + "type": "http", + "interval": 120, + "max_retries": 5, + } + body, _ := json.Marshal(payload) + + req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + var result models.UptimeMonitor + err := json.Unmarshal(w.Body.Bytes(), &result) + require.NoError(t, err) + assert.Equal(t, "New HTTP Monitor", result.Name) + assert.Equal(t, "https://example.com", result.URL) + assert.Equal(t, "http", result.Type) + assert.Equal(t, 120, result.Interval) + assert.Equal(t, 5, result.MaxRetries) + assert.True(t, result.Enabled) + assert.Equal(t, "pending", result.Status) + assert.NotEmpty(t, result.ID) + + // Verify it's in the database + var dbMonitor models.UptimeMonitor + require.NoError(t, db.First(&dbMonitor, "id = ?", result.ID).Error) + assert.Equal(t, "New HTTP Monitor", dbMonitor.Name) + }) + + t.Run("success_tcp", func(t *testing.T) { + r, _ := setupUptimeHandlerTest(t) + + payload := map[string]any{ + "name": "New TCP Monitor", + "url": "example.com:8080", + "type": "tcp", + } + body, _ := json.Marshal(payload) + + req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + var result models.UptimeMonitor + err := json.Unmarshal(w.Body.Bytes(), &result) + require.NoError(t, err) + assert.Equal(t, "New TCP Monitor", result.Name) + assert.Equal(t, "example.com:8080", result.URL) + assert.Equal(t, "tcp", result.Type) + assert.Equal(t, 60, result.Interval) // Default + assert.Equal(t, 3, result.MaxRetries) // Default + }) + + t.Run("success_defaults", func(t *testing.T) { + r, _ := setupUptimeHandlerTest(t) + + payload := map[string]any{ + "name": "Default Monitor", + "url": "https://example.com/health", + "type": "https", + } + body, _ := json.Marshal(payload) + + req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + var result models.UptimeMonitor + err := json.Unmarshal(w.Body.Bytes(), &result) + require.NoError(t, err) + assert.Equal(t, 60, result.Interval) // Default + assert.Equal(t, 3, result.MaxRetries) // Default + }) + + t.Run("missing_name", func(t *testing.T) { + r, _ := setupUptimeHandlerTest(t) + + payload := map[string]any{ + "url": "https://example.com", + "type": "http", + } + body, _ := json.Marshal(payload) + + req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("missing_url", func(t *testing.T) { + r, _ := setupUptimeHandlerTest(t) + + payload := map[string]any{ + "name": "No URL Monitor", + "type": "http", + } + body, _ := json.Marshal(payload) + + req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("missing_type", func(t *testing.T) { + r, _ := setupUptimeHandlerTest(t) + + payload := map[string]any{ + "name": "No Type Monitor", + "url": "https://example.com", + } + body, _ := json.Marshal(payload) + + req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("invalid_type", func(t *testing.T) { + r, _ := setupUptimeHandlerTest(t) + + payload := map[string]any{ + "name": "Invalid Type Monitor", + "url": "https://example.com", + "type": "invalid", + } + body, _ := json.Marshal(payload) + + req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("invalid_json", func(t *testing.T) { + r, _ := setupUptimeHandlerTest(t) + + req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer([]byte("invalid"))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("invalid_tcp_url", func(t *testing.T) { + r, _ := setupUptimeHandlerTest(t) + + payload := map[string]any{ + "name": "Bad TCP Monitor", + "url": "not-host-port", + "type": "tcp", + } + body, _ := json.Marshal(payload) + + req, _ := http.NewRequest("POST", "/api/v1/uptime", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) +} + func TestUptimeHandler_GetHistory(t *testing.T) { r, db := setupUptimeHandlerTest(t) diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 5f28a2df..df9574bd 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -336,6 +336,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { uptimeService := services.NewUptimeService(db, notificationService) uptimeHandler := handlers.NewUptimeHandler(uptimeService) protected.GET("/uptime/monitors", uptimeHandler.List) + protected.POST("/uptime/monitors", uptimeHandler.Create) protected.GET("/uptime/monitors/:id/history", uptimeHandler.GetHistory) protected.PUT("/uptime/monitors/:id", uptimeHandler.Update) protected.DELETE("/uptime/monitors/:id", uptimeHandler.Delete) diff --git a/backend/internal/services/uptime_service.go b/backend/internal/services/uptime_service.go index b1c04840..64625818 100644 --- a/backend/internal/services/uptime_service.go +++ b/backend/internal/services/uptime_service.go @@ -1040,6 +1040,62 @@ func (s *UptimeService) ListMonitors() ([]models.UptimeMonitor, error) { return monitors, result.Error } +// CreateMonitor creates a new uptime monitor with the given parameters +func (s *UptimeService) CreateMonitor(name, urlStr, monitorType string, interval, maxRetries int) (*models.UptimeMonitor, error) { + // Validate URL format + parsedURL, err := url.Parse(urlStr) + if err != nil { + return nil, fmt.Errorf("invalid URL format: %w", err) + } + + // For HTTP/HTTPS, ensure the scheme is present + if monitorType == "http" || monitorType == "https" { + if parsedURL.Scheme == "" { + return nil, errors.New("URL must include scheme (http:// or https://)") + } + if parsedURL.Host == "" { + return nil, errors.New("URL must include host") + } + } + + // For TCP, validate host:port format + if monitorType == "tcp" { + if _, _, err := net.SplitHostPort(urlStr); err != nil { + return nil, fmt.Errorf("TCP URL must be in host:port format: %w", err) + } + } + + // Set defaults + if interval <= 0 { + interval = 60 // Default 60 seconds + } + if maxRetries <= 0 { + maxRetries = 3 // Default 3 retries + } + + monitor := &models.UptimeMonitor{ + Name: name, + URL: urlStr, + Type: monitorType, + Interval: interval, + MaxRetries: maxRetries, + Enabled: true, + Status: "pending", + } + + if err := s.DB.Create(monitor).Error; err != nil { + return nil, fmt.Errorf("failed to create monitor: %w", err) + } + + logger.Log().WithFields(map[string]any{ + "monitor_id": monitor.ID, + "monitor_name": monitor.Name, + "monitor_type": monitor.Type, + }).Info("Created new uptime monitor") + + return monitor, nil +} + func (s *UptimeService) GetMonitorByID(id string) (*models.UptimeMonitor, error) { var monitor models.UptimeMonitor if err := s.DB.First(&monitor, "id = ?", id).Error; err != nil { diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 2a255d37..cb0343e0 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -2736,29 +2736,568 @@ Test Scenarios: - Encryption management (key rotation, backup) - Account settings (profile, password, 2FA) -### Phase 5: Tasks (Week 9) +### Phase 5: Tasks & Monitoring (Week 9) -**Goal:** Cover backup, logs, and monitoring features +**Status:** 🔄 IN PROGRESS +**Detailed Plan:** [phase5-implementation.md](phase5-implementation.md) + +**Goal:** Cover backup, logs, import, and monitoring features **Estimated Effort:** 5 days +**Total Estimated Tests:** 92-114 (updated per Supervisor review) -**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 +> **Supervisor Approved:** Plan reviewed and approved with 3 recommendations incorporated: +> - ✅ Added backup download test (P1) to section 5.1 +> - ✅ Added import session timeout tests (P2) to section 5.4 +> - ✅ Added WebSocket reconnection mock utility note to section 5.7 -**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) +**Directory Structure:** +``` +tests/ +├── tasks/ +│ ├── backups-create.spec.ts # Backup creation workflows +│ ├── backups-restore.spec.ts # Backup restoration workflows +│ ├── logs-viewing.spec.ts # Log viewer functionality +│ ├── import-caddyfile.spec.ts # Caddyfile import wizard +│ └── import-crowdsec.spec.ts # CrowdSec config import +└── monitoring/ + ├── uptime-monitoring.spec.ts # Uptime monitor CRUD + └── real-time-logs.spec.ts # WebSocket log streaming +``` + +--- + +#### 5.1 Backups - Create (`tests/tasks/backups-create.spec.ts`) + +**Routes & Components:** + +| Route | Component | API Endpoints | +|-------|-----------|---------------| +| `/tasks/backups` | `Backups.tsx` | `GET /api/v1/backups`, `POST /api/v1/backups`, `DELETE /api/v1/backups/:filename` | + +**Test Scenarios (12-15 tests):** + +**Page Layout & Navigation:** +| # | Test Name | Priority | +|---|-----------|----------| +| 1 | should display backups page with correct heading and navigation | P0 | +| 2 | should show Create Backup button for admin users | P0 | +| 3 | should hide Create Backup button for guest users | P1 | + +**Backup List Display:** +| # | Test Name | Priority | +|---|-----------|----------| +| 4 | should display empty state when no backups exist | P0 | +| 5 | should display list of existing backups with filename, size, and timestamp | P0 | +| 6 | should sort backups by date (newest first) | P1 | +| 7 | should show loading skeleton while fetching backups | P2 | + +**Create Backup Flow:** +| # | Test Name | Priority | +|---|-----------|----------| +| 8 | should create a new backup successfully | P0 | +| 9 | should show success toast after backup creation | P0 | +| 10 | should update backup list with new backup | P0 | +| 11 | should disable create button while backup is in progress | P1 | +| 12 | should handle backup creation failure gracefully | P1 | + +**Delete Backup Flow:** +| # | Test Name | Priority | +|---|-----------|----------| +| 13 | should show confirmation dialog before deleting | P0 | +| 14 | should delete backup after confirmation | P0 | +| 15 | should show success toast after deletion | P1 | + +**Download Backup Flow:** +| # | Test Name | Priority | +|---|-----------|----------| +| 16 | should download backup file successfully | P0 | +| 17 | should show error toast when download fails | P1 | + +> **Supervisor Note (P1):** Explicit backup download test added per review - verifies the `/api/v1/backups/:filename/download` endpoint functions correctly. + +**API Endpoints:** +```typescript +GET /api/v1/backups // List backups +POST /api/v1/backups // Create backup +DELETE /api/v1/backups/:filename // Delete backup +GET /api/v1/backups/:filename/download // Download backup +``` + +--- + +#### 5.2 Backups - Restore (`tests/tasks/backups-restore.spec.ts`) + +**Routes & Components:** + +| Route | Component | API Endpoints | +|-------|-----------|---------------| +| `/tasks/backups` | `Backups.tsx` | `POST /api/v1/backups/:filename/restore` | + +**Test Scenarios (6-8 tests):** + +**Restore Flow:** +| # | Test Name | Priority | +|---|-----------|----------| +| 1 | should show warning dialog before restore | P0 | +| 2 | should require explicit confirmation for restore action | P0 | +| 3 | should restore backup successfully | P0 | +| 4 | should show success toast after restoration | P0 | +| 5 | should show progress indicator during restore | P1 | +| 6 | should handle restore failure gracefully | P1 | + +**Post-Restore Verification:** +| # | Test Name | Priority | +|---|-----------|----------| +| 7 | should reload application state after restore | P1 | +| 8 | should preserve user session after restore | P2 | + +**API Endpoints:** +```typescript +POST /api/v1/backups/:filename/restore // Restore from backup +``` + +**Mock Data Requirements:** +- Valid backup file for restoration testing +- Corrupt/invalid backup file for error handling + +--- + +#### 5.3 Log Viewer (`tests/tasks/logs-viewing.spec.ts`) + +**Routes & Components:** + +| Route | Component | API Endpoints | +|-------|-----------|---------------| +| `/tasks/logs` | `Logs.tsx`, `LogTable.tsx`, `LogFilters.tsx` | `GET /api/v1/logs`, `GET /api/v1/logs/:filename` | + +**Test Scenarios (15-18 tests):** + +**Page Layout:** +| # | Test Name | Priority | +|---|-----------|----------| +| 1 | should display logs page with file selector | P0 | +| 2 | should show list of available log files | P0 | +| 3 | should display log filters (search, level, host, status) | P0 | + +**Log File Selection:** +| # | Test Name | Priority | +|---|-----------|----------| +| 4 | should list all available log files | P0 | +| 5 | should display file size and modification time | P1 | +| 6 | should load log content when file is selected | P0 | +| 7 | should show empty state for empty log files | P1 | + +**Log Content Display:** +| # | Test Name | Priority | +|---|-----------|----------| +| 8 | should display log entries in table format | P0 | +| 9 | should show timestamp, level, message, and request details | P0 | +| 10 | should paginate large log files | P1 | +| 11 | should sort logs by timestamp | P1 | +| 12 | should highlight error and warning entries | P2 | + +**Log Filtering:** +| # | Test Name | Priority | +|---|-----------|----------| +| 13 | should filter logs by search text | P0 | +| 14 | should filter logs by log level | P0 | +| 15 | should filter logs by host | P1 | +| 16 | should filter logs by status code range | P1 | +| 17 | should combine multiple filters | P1 | +| 18 | should clear all filters | P1 | + +**API Endpoints:** +```typescript +GET /api/v1/logs // List log files +GET /api/v1/logs/:filename // Read log file with filters +GET /api/v1/logs/:filename/download // Download log file +``` + +**Log Entry Interface:** +```typescript +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; +} +``` + +--- + +#### 5.4 Caddyfile Import (`tests/tasks/import-caddyfile.spec.ts`) + +**Routes & Components:** + +| Route | Component | API Endpoints | +|-------|-----------|---------------| +| `/tasks/import/caddyfile` | `ImportCaddy.tsx`, `ImportReviewTable.tsx`, `ImportSitesModal.tsx` | `POST /api/v1/import/upload`, `GET /api/v1/import/preview`, `POST /api/v1/import/commit` | + +**Test Scenarios (14-16 tests):** + +**Upload Interface:** +| # | Test Name | Priority | +|---|-----------|----------| +| 1 | should display file upload dropzone | P0 | +| 2 | should accept valid Caddyfile | P0 | +| 3 | should reject invalid file types | P0 | +| 4 | should show upload progress | P1 | +| 5 | should handle multi-file upload | P1 | +| 6 | should detect import directives in Caddyfile | P1 | + +**Preview & Review:** +| # | Test Name | Priority | +|---|-----------|----------| +| 7 | should show parsed hosts from Caddyfile | P0 | +| 8 | should display host configuration details | P0 | +| 9 | should allow selection/deselection of hosts | P0 | +| 10 | should show validation warnings for problematic configs | P1 | +| 11 | should highlight conflicts with existing hosts | P1 | + +**Commit Import:** +| # | Test Name | Priority | +|---|-----------|----------| +| 12 | should commit selected hosts | P0 | +| 13 | should skip deselected hosts | P1 | +| 14 | should show success toast after import | P0 | +| 15 | should navigate to proxy hosts after import | P1 | +| 16 | should handle partial import failures | P1 | + +**Session Management:** +| # | Test Name | Priority | +|---|-----------|----------| +| 17 | should handle import session timeout/expiry | P2 | +| 18 | should show warning when session is about to expire | P2 | + +> **Supervisor Note (P2):** Session timeout tests added per review - import sessions have server-side TTL and should gracefully handle expiration. + +**API Endpoints:** +```typescript +POST /api/v1/import/upload // Upload Caddyfile +POST /api/v1/import/upload-multi // Upload multiple files +GET /api/v1/import/status // Get import session status +GET /api/v1/import/preview // Get parsed hosts preview +POST /api/v1/import/detect-imports // Detect import directives +POST /api/v1/import/commit // Commit import +DELETE /api/v1/import/cancel // Cancel import session +``` + +--- + +#### 5.5 CrowdSec Import (`tests/tasks/import-crowdsec.spec.ts`) + +**Routes & Components:** + +| Route | Component | API Endpoints | +|-------|-----------|---------------| +| `/tasks/import/crowdsec` | `ImportCrowdSec.tsx` | `POST /api/v1/crowdsec/import` | + +**Test Scenarios (6-8 tests):** + +**Upload Interface:** +| # | Test Name | Priority | +|---|-----------|----------| +| 1 | should display file upload interface | P0 | +| 2 | should accept YAML configuration files | P0 | +| 3 | should reject invalid file types | P0 | +| 4 | should create backup before import | P0 | + +**Import Flow:** +| # | Test Name | Priority | +|---|-----------|----------| +| 5 | should import CrowdSec configuration | P0 | +| 6 | should show success toast after import | P0 | +| 7 | should validate configuration format | P1 | +| 8 | should handle import errors gracefully | P1 | + +**Component Behavior (from `ImportCrowdSec.tsx`):** +```typescript +// Import triggers backup creation first +const backupResult = await createBackup(); +// Then imports CrowdSec config +await importCrowdsecConfig(file); +``` + +--- + +#### 5.6 Uptime Monitoring (`tests/monitoring/uptime-monitoring.spec.ts`) + +**Routes & Components:** + +| Route | Component | API Endpoints | +|-------|-----------|---------------| +| `/uptime` | `Uptime.tsx`, `UptimeWidget.tsx` | `GET /api/v1/uptime/monitors`, `POST /api/v1/uptime/monitors`, `PUT /api/v1/uptime/monitors/:id` | + +**Test Scenarios (18-22 tests):** + +**Page Layout:** +| # | Test Name | Priority | +|---|-----------|----------| +| 1 | should display uptime monitoring page | P0 | +| 2 | should show monitor list or empty state | P0 | +| 3 | should display overall uptime summary | P1 | + +**Monitor List Display:** +| # | Test Name | Priority | +|---|-----------|----------| +| 4 | should display all monitors with status indicators | P0 | +| 5 | should show uptime percentage for each monitor | P0 | +| 6 | should show last check timestamp | P1 | +| 7 | should differentiate between up/down/unknown states | P0 | +| 8 | should group monitors by category if configured | P2 | + +**Monitor CRUD:** +| # | Test Name | Priority | +|---|-----------|----------| +| 9 | should create new HTTP monitor | P0 | +| 10 | should create new TCP monitor | P1 | +| 11 | should update existing monitor | P0 | +| 12 | should delete monitor with confirmation | P0 | +| 13 | should validate monitor URL format | P0 | +| 14 | should validate check interval | P1 | + +**Manual Check:** +| # | Test Name | Priority | +|---|-----------|----------| +| 15 | should trigger manual health check | P0 | +| 16 | should update status after manual check | P0 | +| 17 | should show check in progress indicator | P1 | + +**Monitor History:** +| # | Test Name | Priority | +|---|-----------|----------| +| 18 | should display uptime history chart | P1 | +| 19 | should show incident timeline | P2 | +| 20 | should filter history by date range | P2 | + +**Sync with Proxy Hosts:** +| # | Test Name | Priority | +|---|-----------|----------| +| 21 | should sync monitors from proxy hosts | P1 | +| 22 | should preserve manually added monitors | P1 | + +**API Endpoints:** +```typescript +GET /api/v1/uptime/monitors // List monitors +POST /api/v1/uptime/monitors // Create monitor +PUT /api/v1/uptime/monitors/:id // Update monitor +DELETE /api/v1/uptime/monitors/:id // Delete monitor +GET /api/v1/uptime/monitors/:id/history // Get history +POST /api/v1/uptime/monitors/:id/check // Trigger check +POST /api/v1/uptime/sync // Sync with proxy hosts +``` + +--- + +#### 5.7 Real-time Logs (`tests/monitoring/real-time-logs.spec.ts`) + +**Routes & Components:** + +| Route | Component | API Endpoints | +|-------|-----------|---------------| +| `/tasks/logs` (Live tab) | `LiveLogViewer.tsx` | `WS /api/v1/logs/live`, `WS /api/v1/cerberus/logs/ws` | + +**Test Scenarios (16-20 tests):** + +**WebSocket Connection:** +| # | Test Name | Priority | +|---|-----------|----------| +| 1 | should establish WebSocket connection | P0 | +| 2 | should show connected status indicator | P0 | +| 3 | should handle connection failure gracefully | P0 | +| 4 | should auto-reconnect on connection loss | P1 | +| 5 | should authenticate via HttpOnly cookies | P1 | +| 6 | should recover from network interruption | P1 | + +> **Supervisor Note:** Add `simulateNetworkInterruption()` utility to `tests/utils/wait-helpers.ts` for testing WebSocket reconnection scenarios. This mock should temporarily close the WebSocket and verify the component reconnects automatically. + +**Log Streaming:** +| # | Test Name | Priority | +|---|-----------|----------| +| 6 | should display incoming log entries in real-time | P0 | +| 7 | should auto-scroll to latest logs | P1 | +| 8 | should respect max log limit (500 entries) | P1 | +| 9 | should format timestamps correctly | P1 | +| 10 | should colorize log levels appropriately | P2 | + +**Mode Switching:** +| # | Test Name | Priority | +|---|-----------|----------| +| 11 | should toggle between Application and Security modes | P0 | +| 12 | should clear logs when switching modes | P1 | +| 13 | should reconnect to correct WebSocket endpoint | P0 | + +**Live Filters:** +| # | Test Name | Priority | +|---|-----------|----------| +| 14 | should filter by text search | P0 | +| 15 | should filter by log level | P0 | +| 16 | should filter by source (security mode) | P1 | +| 17 | should filter blocked requests only (security mode) | P1 | + +**Playback Controls:** +| # | Test Name | Priority | +|---|-----------|----------| +| 18 | should pause log streaming | P0 | +| 19 | should resume log streaming | P0 | +| 20 | should clear all logs | P1 | + +**WebSocket Interfaces:** +```typescript +// Application logs +interface LiveLogEntry { + level: string; + timestamp: string; + message: string; + source?: string; + data?: Record; +} + +// Security logs (Cerberus) +interface SecurityLogEntry { + timestamp: string; + level: string; + logger: string; + client_ip: string; + method: string; + uri: string; + status: number; + duration: number; + size: number; + user_agent: string; + host: string; + source: 'waf' | 'crowdsec' | 'ratelimit' | 'acl' | 'normal'; + blocked: boolean; + block_reason?: string; + details?: Record; +} +``` + +**WebSocket Testing Strategy:** +```typescript +// Use Playwright's WebSocket interception +test('should display incoming log entries in real-time', async ({ page }) => { + await page.goto('/tasks/logs'); + + // Wait for WebSocket connection + await waitForWebSocketConnection(page); + + // Verify connection indicator shows "Connected" + await expect(page.locator('[data-testid="connection-status"]')) + .toContainText('Connected'); + + // Intercept WebSocket messages + page.on('websocket', ws => { + ws.on('framereceived', event => { + const log = JSON.parse(event.payload); + // Verify log entry structure + expect(log).toHaveProperty('timestamp'); + expect(log).toHaveProperty('level'); + }); + }); +}); +``` + +--- + +#### Phase 5 Implementation Priority + +| Priority | Test File | Reason | Est. Tests | +|----------|-----------|--------|------------| +| 1 | `backups-create.spec.ts` | Core data protection feature | 12-15 | +| 2 | `backups-restore.spec.ts` | Critical recovery workflow | 6-8 | +| 3 | `logs-viewing.spec.ts` | Essential debugging tool | 15-18 | +| 4 | `uptime-monitoring.spec.ts` | Key operational feature | 18-22 | +| 5 | `real-time-logs.spec.ts` | WebSocket testing complexity | 16-20 | +| 6 | `import-caddyfile.spec.ts` | Multi-step wizard | 14-16 | +| 7 | `import-crowdsec.spec.ts` | Simpler import flow | 6-8 | +| **Total** | | | **87-107** | + +--- + +#### Phase 5 Test Utilities + +**Wait Helpers (from `tests/utils/wait-helpers.ts`):** +```typescript +// Key utilities to use: +await waitForToast(page, /success|created|deleted/i); +await waitForLoadingComplete(page); +await waitForAPIResponse(page, '/api/v1/backups', 200); +await waitForWebSocketConnection(page); +await waitForWebSocketMessage(page, (msg) => msg.level === 'error'); +await waitForTableLoad(page, locator); +await retryAction(page, async () => { /* action */ }, { maxAttempts: 3 }); +``` + +**Test Data Manager (from `tests/utils/TestDataManager.ts`):** +```typescript +// For creating test data with automatic cleanup: +const manager = new TestDataManager(page, 'backups-test'); +const host = await manager.createProxyHost({ domain: 'test.example.com' }); +// ... test +await manager.cleanup(); // Auto-cleanup in reverse order +``` + +**Authentication (from `tests/fixtures/auth-fixtures.ts`):** +```typescript +// Use admin fixture for full access: +test.use({ ...adminUser }); + +// Or regular user for permission testing: +test.use({ ...regularUser }); + +// Or guest for read-only testing: +test.use({ ...guestUser }); +``` + +--- + +#### Phase 5 Acceptance Criteria + +**Backups (18-23 tests minimum):** +- [ ] All CRUD operations covered +- [ ] Restore workflow with confirmation +- [ ] Download functionality works +- [ ] Error handling for failures +- [ ] Role-based access verified + +**Logs (31-38 tests minimum):** +- [ ] Static log viewing works +- [ ] All filters functional +- [ ] WebSocket streaming works +- [ ] Mode switching (App/Security) +- [ ] Pause/Resume controls + +**Imports (20-24 tests minimum):** +- [ ] File upload works +- [ ] Preview shows parsed data +- [ ] Commit creates resources +- [ ] Error handling for invalid files + +**Uptime (18-22 tests minimum):** +- [ ] Monitor CRUD operations +- [ ] Status indicators correct +- [ ] Manual check works +- [ ] Sync with proxy hosts + +**Overall Phase 5:** +- [ ] 87+ tests passing +- [ ] <5% flaky test rate +- [ ] All P0 tests complete +- [ ] 90%+ P1 tests complete +- [ ] No hardcoded waits (use wait-helpers) +- [ ] All tests use TestDataManager for cleanup ### Phase 6: Integration & Buffer (Week 10) diff --git a/docs/plans/phase5-implementation.md b/docs/plans/phase5-implementation.md new file mode 100644 index 00000000..187bd794 --- /dev/null +++ b/docs/plans/phase5-implementation.md @@ -0,0 +1,1984 @@ +# 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 + +```typescript +interface BackupFile { + filename: string; // e.g., "backup_2024-01-15_120000.tar.gz" + size: number; // Bytes + time: string; // ISO timestamp +} +``` + +### UI Selectors + +```typescript +// 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 | + +```typescript +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 | + +```typescript +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 | + +```typescript +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 | + +```typescript +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 | + +```typescript +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 + +```typescript +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 | + +```typescript +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 | + +```typescript +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 + +```typescript +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 + +```typescript +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 | + +```typescript +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 | + +```typescript +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 | + +```typescript +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 = {}; + 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 + +```typescript +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; +} + +interface ImportCommitResult { + created: number; + updated: number; + skipped: number; + errors: string[]; +} +``` + +### UI Selectors + +```typescript +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 | + +```typescript +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 | + +```typescript +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 | + +```typescript +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 | + +```typescript +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 + +```typescript +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 | + +```typescript +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 | + +```typescript +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 + +```typescript +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 + +```typescript +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 | + +```typescript +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 | + +```typescript +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 | + +```typescript +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 | + +```typescript +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 + +```typescript +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; +} + +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; +} +``` + +### UI Selectors + +```typescript +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 | + +```typescript +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 | + +```typescript +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 | + +```typescript +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 | + +```typescript +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 | + +```typescript +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`: + +```typescript +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 + +```bash +# 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`. diff --git a/docs/plans/task.md b/docs/plans/task.md index 96b4813c..e603891c 100644 --- a/docs/plans/task.md +++ b/docs/plans/task.md @@ -1,1200 +1,9 @@ -Run npx playwright test --project=chromium +- Is there a playwright skill to make sure cits ran consistently and easly without guessing every time? -Running 55 tests using 1 worker -Running initial setup to create test admin user... -Initial setup completed successfully -Logging in as test user... -Login successful -Auth state saved to /home/runner/work/Charon/Charon/playwright/.auth/user.json -·API Response: 404 {"error":"not found"} -×API Response: 404 {"error":"not found"} -×API Response: 404 {"error":"not found"} -FType select found: true -Number of options: 1 - Option 0: Loading... -Webhook option not found -°··Add button count: 2 -Page URL: http://localhost:8080/dns/providers -···°°°××F·××F····××F××F××F××F××F××F··××F···××F························ +- Are there any playwright blockers that need implementations to pass? - 1) [chromium] › tests/dns-provider-crud.spec.ts:16:5 › DNS Provider CRUD Operations › Create Provider › should create a Manual DNS provider › Save provider +- Coverage is calculating at unknown %. Fix coverage calculation. - Error: expect(locator).not.toBeVisible() failed +- Set e2e coverage minimum to 85% to match go and react. - Locator: getByRole('dialog') - Expected: not visible - Received: visible - Timeout: 10000ms - - Call log: - - Expect "not toBeVisible" with timeout 10000ms - - waiting for getByRole('dialog') - 14 × locator resolved to