Files
Charon/docs/plans/current_spec.md

1847 lines
60 KiB
Markdown

# CrowdSec Integration - Complete File Inventory
**Generated:** 2025-12-15
**Purpose:** Comprehensive documentation of all CrowdSec-related files in the Charon repository
---
## Table of Contents
1. [Frontend Files](#frontend-files)
2. [Backend API Surface](#backend-api-surface)
3. [Backend Models](#backend-models)
4. [Backend Services](#backend-services)
5. [Caddy Integration](#caddy-integration)
6. [Configuration Files](#configuration-files)
7. [Scripts](#scripts)
8. [Documentation](#documentation)
9. [Test Coverage Summary](#test-coverage-summary)
---
## Frontend Files
### 1. Pages & Components
#### `/frontend/src/pages/CrowdSecConfig.tsx`
**Purpose:** Main CrowdSec configuration page
**Features:**
- CrowdSec mode selection (disabled/local)
- Import/Export configuration (tar.gz)
- File browser and editor for CrowdSec config files
- Preset management (list, pull, apply)
- Banned IP dashboard (list, ban, unban)
- Console enrollment UI
- Integration with live log viewer
**Test Files:**
- `/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx`
- `/frontend/src/pages/__tests__/CrowdSecConfig.test.tsx`
- `/frontend/src/pages/__tests__/CrowdSecConfig.coverage.test.tsx`
#### `/frontend/src/pages/ImportCrowdSec.tsx`
**Purpose:** Dedicated import page for CrowdSec configuration
**Features:**
- File upload for CrowdSec config archives
- Automatic backup creation before import
- Success/error handling with redirects
**Test Files:**
- `/frontend/src/pages/__tests__/ImportCrowdSec.spec.tsx`
- `/frontend/src/pages/__tests__/ImportCrowdSec.test.tsx`
#### `/frontend/src/pages/Security.tsx`
**Purpose:** Main security dashboard with CrowdSec toggle
**Features:**
- Layer 1 security card for CrowdSec
- Toggle control for start/stop CrowdSec process
- Status display (enabled/disabled, running, PID)
- Integration with security status API
- Navigation to CrowdSec config page
**Test Files:**
- `/frontend/src/pages/__tests__/Security.spec.tsx`
- `/frontend/src/pages/__tests__/Security.test.tsx`
- `/frontend/src/pages/__tests__/Security.loading.test.tsx`
- `/frontend/src/pages/__tests__/Security.dashboard.test.tsx`
- `/frontend/src/pages/__tests__/Security.audit.test.tsx`
#### `/frontend/src/components/Layout.tsx`
**Purpose:** Navigation layout with CrowdSec menu items
**Features:**
- Security menu: "CrowdSec" link to `/security/crowdsec`
- Tasks/Import menu: "CrowdSec" link to `/tasks/import/crowdsec`
**Test Files:**
- `/frontend/src/components/__tests__/Layout.test.tsx` (partial coverage)
#### `/frontend/src/components/LiveLogViewer.tsx`
**Purpose:** Real-time log viewer with CrowdSec log support
**Features:**
- Filter by source type (includes "crowdsec" option)
- CrowdSec-specific color coding (purple-600)
**Test Files:**
- `/frontend/src/components/__tests__/LiveLogViewer.test.tsx` (includes CrowdSec filter test)
#### `/frontend/src/components/LoadingStates.tsx`
**Purpose:** Loading overlays for security operations
**Features:**
- `ConfigReloadOverlay`: Used for CrowdSec operations
- Cerberus theme for security operations
**Test Files:** None specific to CrowdSec
#### `/frontend/src/components/AccessListForm.tsx`
**Purpose:** ACL form with CrowdSec guidance
**Features:**
- Help text suggesting CrowdSec for IP blocklists
**Test Files:** None specific to CrowdSec
### 2. Hooks (React Query)
#### `/frontend/src/hooks/useSecurity.ts`
**Purpose:** React Query hooks for security API
**Exports:**
- `useSecurityStatus()` - Fetches overall security status including CrowdSec
- `useSecurityConfig()` - Fetches security config (includes CrowdSec mode/URL)
- `useUpdateSecurityConfig()` - Mutation for updating security config
- `useDecisions(limit?)` - Fetches CrowdSec decisions (banned IPs)
- `useCreateDecision()` - Mutation for creating manual decisions
**Test Files:**
- `/frontend/src/hooks/__tests__/useSecurity.test.tsx` (includes useDecisions tests)
#### `/frontend/src/hooks/useConsoleEnrollment.ts`
**Purpose:** React Query hooks for CrowdSec Console enrollment
**Exports:**
- `useConsoleStatus(enabled?)` - Fetches console enrollment status
- `useEnrollConsole()` - Mutation for enrolling with CrowdSec Console
**Test Files:** None
### 3. API Clients
#### `/frontend/src/api/crowdsec.ts`
**Purpose:** Primary CrowdSec API client
**Exports:**
- **Types:**
- `CrowdSecDecision` - Decision/ban record interface
- `CrowdSecStatus` - Process status interface
- **Functions:**
- `startCrowdsec()` - Start CrowdSec process
- `stopCrowdsec()` - Stop CrowdSec process
- `statusCrowdsec()` - Get process status
- `importCrowdsecConfig(file)` - Upload config archive
- `exportCrowdsecConfig()` - Download config archive
- `listCrowdsecFiles()` - List config files
- `readCrowdsecFile(path)` - Read config file content
- `writeCrowdsecFile(path, content)` - Write config file
- `listCrowdsecDecisions()` - List banned IPs
- `banIP(ip, duration, reason)` - Add ban decision
- `unbanIP(ip)` - Remove ban decision
**Test Files:**
- `/frontend/src/api/__tests__/crowdsec.test.ts`
#### `/frontend/src/api/presets.ts`
**Purpose:** CrowdSec preset management API client
**Exports:**
- **Types:**
- `CrowdsecPresetSummary` - Preset metadata
- `PullCrowdsecPresetResponse` - Pull operation response
- `ApplyCrowdsecPresetResponse` - Apply operation response
- `CachedCrowdsecPresetPreview` - Cached preset data
- **Functions:**
- `listCrowdsecPresets()` - List available presets
- `getCrowdsecPresets()` - Alias for list
- `pullCrowdsecPreset(slug)` - Fetch preset from remote
- `applyCrowdsecPreset(payload)` - Apply preset to config
- `getCrowdsecPresetCache(slug)` - Get cached preset preview
**Test Files:** None
#### `/frontend/src/api/consoleEnrollment.ts`
**Purpose:** CrowdSec Console enrollment API client
**Exports:**
- **Types:**
- `ConsoleEnrollmentStatus` - Enrollment status interface
- `ConsoleEnrollPayload` - Enrollment request payload
- **Functions:**
- `getConsoleStatus()` - Fetch enrollment status
- `enrollConsole(payload)` - Enroll with CrowdSec Console
**Test Files:** None
#### `/frontend/src/api/security.ts`
**Purpose:** General security API client (includes CrowdSec-related types)
**Exports (CrowdSec-related):**
- **Types:**
- `SecurityStatus` - Includes `crowdsec` object with mode, enabled, api_url
- `SecurityConfigPayload` - Includes `crowdsec_mode`, `crowdsec_api_url`
- `CreateDecisionPayload` - Manual decision creation
- **Functions:**
- `getSecurityStatus()` - Fetch security status (includes CrowdSec state)
- `getSecurityConfig()` - Fetch security config
- `updateSecurityConfig(payload)` - Update security config
- `getDecisions(limit?)` - Fetch decisions list
- `createDecision(payload)` - Create manual decision
**Test Files:**
- Various security test files reference CrowdSec status
### 4. Data & Utilities
#### `/frontend/src/data/crowdsecPresets.ts`
**Purpose:** Static CrowdSec preset definitions (local fallback)
**Exports:**
- `CrowdsecPreset` - Preset interface
- `CROWDSEC_PRESETS` - Array of built-in presets:
- bot-mitigation-essentials
- honeypot-friendly-defaults
- geolocation-aware
- `findCrowdsecPreset(slug)` - Lookup function
**Test Files:** None
#### `/frontend/src/utils/crowdsecExport.ts`
**Purpose:** Utility functions for CrowdSec export operations
**Exports:**
- `buildCrowdsecExportFilename()` - Generate timestamped filename
- `promptCrowdsecFilename(default?)` - User prompt with sanitization
- `downloadCrowdsecExport(blob, filename)` - Trigger browser download
**Test Files:** None
---
## Backend API Surface
### 1. Main Handler
#### `/backend/internal/api/handlers/crowdsec_handler.go`
**Purpose:** Primary CrowdSec API handler with all endpoints
**Type:** `CrowdsecHandler`
**Dependencies:**
- `db *gorm.DB` - Database connection
- `Executor CrowdsecExecutor` - Process control interface
- `BinPath string` - Path to crowdsec binary
- `DataDir string` - CrowdSec data directory path
- `Security *SecurityService` - Security config service
**Methods (26 total):**
1. **Process Control:**
- `Start(c *gin.Context)` - POST `/admin/crowdsec/start`
- `Stop(c *gin.Context)` - POST `/admin/crowdsec/stop`
- `Status(c *gin.Context)` - GET `/admin/crowdsec/status`
2. **Configuration Management:**
- `ImportConfig(c *gin.Context)` - POST `/admin/crowdsec/import`
- `ExportConfig(c *gin.Context)` - GET `/admin/crowdsec/export`
- `ListFiles(c *gin.Context)` - GET `/admin/crowdsec/files`
- `ReadFile(c *gin.Context)` - GET `/admin/crowdsec/file?path=...`
- `WriteFile(c *gin.Context)` - POST `/admin/crowdsec/file`
- `GetAcquisitionConfig(c *gin.Context)` - GET `/admin/crowdsec/acquisition`
- `UpdateAcquisitionConfig(c *gin.Context)` - POST `/admin/crowdsec/acquisition`
3. **Preset Management:**
- `ListPresets(c *gin.Context)` - GET `/admin/crowdsec/presets`
- `PullPreset(c *gin.Context)` - POST `/admin/crowdsec/presets/pull`
- `ApplyPreset(c *gin.Context)` - POST `/admin/crowdsec/presets/apply`
- `GetCachedPreset(c *gin.Context)` - GET `/admin/crowdsec/presets/cache/:slug`
4. **Console Enrollment:**
- `ConsoleEnroll(c *gin.Context)` - POST `/admin/crowdsec/console/enroll`
- `ConsoleStatus(c *gin.Context)` - GET `/admin/crowdsec/console/status`
5. **Decision Management (Banned IPs):**
- `ListDecisions(c *gin.Context)` - GET `/admin/crowdsec/decisions` (via cscli)
- `GetLAPIDecisions(c *gin.Context)` - GET `/admin/crowdsec/decisions/lapi` (via LAPI)
- `CheckLAPIHealth(c *gin.Context)` - GET `/admin/crowdsec/lapi/health`
- `BanIP(c *gin.Context)` - POST `/admin/crowdsec/ban`
- `UnbanIP(c *gin.Context)` - DELETE `/admin/crowdsec/ban/:ip`
6. **Bouncer Registration:**
- `RegisterBouncer(c *gin.Context)` - POST `/admin/crowdsec/bouncer/register`
7. **Helper Methods:**
- `isCerberusEnabled() bool` - Check if Cerberus feature flag is enabled
- `isConsoleEnrollmentEnabled() bool` - Check if console enrollment is enabled
- `hubEndpoints() []string` - Return hub API URLs
- `RegisterRoutes(rg *gin.RouterGroup)` - Route registration
**Test Files:**
- `/backend/internal/api/handlers/crowdsec_handler_test.go` - Core unit tests
- `/backend/internal/api/handlers/crowdsec_handler_comprehensive_test.go` - Comprehensive tests
- `/backend/internal/api/handlers/crowdsec_handler_coverage_test.go` - Coverage boost
- `/backend/internal/api/handlers/crowdsec_coverage_boost_test.go` - Additional coverage
- `/backend/internal/api/handlers/crowdsec_coverage_target_test.go` - Target tests
- `/backend/internal/api/handlers/crowdsec_cache_verification_test.go` - Cache tests
- `/backend/internal/api/handlers/crowdsec_decisions_test.go` - Decision endpoint tests
- `/backend/internal/api/handlers/crowdsec_lapi_test.go` - LAPI tests
- `/backend/internal/api/handlers/crowdsec_presets_handler_test.go` - Preset tests
- `/backend/internal/api/handlers/crowdsec_pull_apply_integration_test.go` - Integration tests
#### `/backend/internal/api/handlers/crowdsec_exec.go`
**Purpose:** Process executor interface and implementation
**Exports:**
- `CrowdsecExecutor` interface - Process control abstraction
- `DefaultCrowdsecExecutor` - Production implementation
- Helper functions for process management
**Test Files:**
- `/backend/internal/api/handlers/crowdsec_exec_test.go`
### 2. Security Handler (CrowdSec Integration)
#### `/backend/internal/api/handlers/security_handler.go`
**Purpose:** Security configuration handler (includes CrowdSec mode)
**Methods (CrowdSec-related):**
- `GetStatus(c *gin.Context)` - Returns security status including CrowdSec enabled flag
- `GetConfig(c *gin.Context)` - Returns SecurityConfig including CrowdSec mode/URL
- `UpdateConfig(c *gin.Context)` - Updates security config (can change CrowdSec mode)
- `ListDecisions(c *gin.Context)` - Lists security decisions (includes CrowdSec decisions)
- `CreateDecision(c *gin.Context)` - Creates manual decision
**Routes:**
- GET `/security/status` - Security status
- GET `/security/config` - Security config
- POST `/security/config` - Update config
- GET `/security/decisions` - List decisions
- POST `/security/decisions` - Create decision
**Test Files:**
- Various security handler tests
---
## Backend Models
### 1. Database Models
#### `/backend/internal/models/crowdsec_preset_event.go`
**Purpose:** Audit trail for preset operations
**Table:** `crowdsec_preset_events`
**Fields:**
- `ID uint` - Primary key
- `Slug string` - Preset slug identifier
- `Action string` - "pull" or "apply"
- `Status string` - "success", "failed"
- `CacheKey string` - Cache identifier
- `BackupPath string` - Backup file path (for apply)
- `Error string` - Error message if failed
- `CreatedAt time.Time`
- `UpdatedAt time.Time`
**Test Files:** None
#### `/backend/internal/models/crowdsec_console_enrollment.go`
**Purpose:** CrowdSec Console enrollment state
**Table:** `crowdsec_console_enrollments`
**Fields:**
- `ID uint` - Primary key
- `UUID string` - Unique identifier
- `Status string` - "pending", "enrolled", "failed"
- `Tenant string` - Console tenant name
- `AgentName string` - Agent display name
- `EncryptedEnrollKey string` - Encrypted enrollment key
- `LastError string` - Last error message
- `LastCorrelationID string` - Last API correlation ID
- `LastAttemptAt *time.Time` - Last enrollment attempt
- `EnrolledAt *time.Time` - Successful enrollment timestamp
- `LastHeartbeatAt *time.Time` - Last heartbeat from console
- `CreatedAt time.Time`
- `UpdatedAt time.Time`
**Test Files:** None
#### `/backend/internal/models/security_config.go`
**Purpose:** Global security configuration (includes CrowdSec settings)
**Table:** `security_configs`
**Fields (CrowdSec-related):**
- `CrowdSecMode string` - "disabled" or "local"
- `CrowdSecAPIURL string` - LAPI URL (default: http://127.0.0.1:8085)
**Other Fields:** WAF, Rate Limit, ACL, admin whitelist, break glass
**Test Files:** Various security tests
#### `/backend/internal/models/security_decision.go`
**Purpose:** Security decisions from CrowdSec/WAF/Rate Limit
**Table:** `security_decisions`
**Fields:**
- `ID uint` - Primary key
- `UUID string` - Unique identifier
- `Source string` - "crowdsec", "waf", "ratelimit", "manual"
- `Action string` - "allow", "block", "challenge"
- `IP string` - IP address
- `Host string` - Hostname (optional)
- `RuleID string` - Rule or scenario ID
- `Details string` - JSON details
- `CreatedAt time.Time`
**Test Files:** None
---
## Backend Services
### 1. Startup Reconciliation
#### `/backend/internal/services/crowdsec_startup.go`
**Purpose:** Reconcile CrowdSec state on container restart
**Exports:**
- `CrowdsecProcessManager` interface - Process management abstraction
- `ReconcileCrowdSecOnStartup(db, executor, binPath, dataDir)` - Main reconciliation function
**Logic:**
1. Check if SecurityConfig table exists
2. Check if CrowdSecMode = "local" in SecurityConfig
3. Fallback: Check Settings table for "security.crowdsec.enabled"
4. If enabled, start CrowdSec process
5. Log all actions for debugging
**Called From:** `/backend/internal/api/routes/routes.go` (on server startup)
**Test Files:**
- `/backend/internal/services/crowdsec_startup_test.go`
---
## Caddy Integration
### 1. Config Generation
#### `/backend/internal/caddy/config.go`
**Purpose:** Generate Caddy config with CrowdSec bouncer
**Function:** `generateConfig(..., crowdsecEnabled bool, ...)`
**Logic:**
- If `crowdsecEnabled=true`, inject CrowdSec bouncer handler into route chain
- Handler: `{"handler": "crowdsec"}` (Caddy bouncer module)
**Test Files:**
- `/backend/internal/caddy/config_crowdsec_test.go`
#### `/backend/internal/caddy/manager.go`
**Purpose:** Caddy config manager with CrowdSec flag computation
**Method:** `computeEffectiveFlags(ctx)`
**Returns:** `cerberusEnabled, aclEnabled, wafEnabled, rateLimitEnabled, crowdsecEnabled`
**Logic:**
1. Check SecurityConfig.CrowdSecMode
2. Check Settings table "security.crowdsec.enabled" override
3. Merge with cerberusEnabled flag
**Test Files:**
- `/backend/internal/caddy/manager_additional_test.go` (includes CrowdSec toggle test)
---
## Configuration Files
### 1. CrowdSec Config Templates
#### `/configs/crowdsec/acquis.yaml`
**Purpose:** CrowdSec acquisition config (log sources)
**Content:**
- Defines Caddy access log path: `/data/charon/data/logs/access.log`
- Log type: `caddy`
#### `/configs/crowdsec/install_hub_items.sh`
**Purpose:** Install CrowdSec hub items (parsers, scenarios, collections)
**Content:**
- Install parsers: http-logs, nginx-logs, apache2-logs, syslog-logs, geoip-enrich
- Install scenarios: http-probing, sensitive-files, backdoors-attempts, path-traversal
- Install collections: base-http-scenarios
**Usage:** Called during CrowdSec setup
#### `/configs/crowdsec/register_bouncer.sh`
**Purpose:** Register Caddy bouncer with CrowdSec LAPI
**Content:**
- Script to register bouncer and save API key
**Usage:** Called during bouncer registration
---
## Scripts
### 1. Integration Tests
#### `/scripts/crowdsec_integration.sh`
**Purpose:** Integration test for CrowdSec start/stop/status
**Test Cases:**
- Start CrowdSec process
- Check status endpoint
- Stop CrowdSec process
**Usage:** Run via task "Integration: Coraza WAF"
#### `/scripts/crowdsec_decision_integration.sh`
**Purpose:** Integration test for decision management
**Test Cases:**
- List decisions
- Ban IP
- Unban IP
- LAPI health check
**Usage:** Run via task "Integration: CrowdSec Decisions"
#### `/scripts/crowdsec_startup_test.sh`
**Purpose:** Test CrowdSec startup reconciliation
**Test Cases:**
- Verify ReconcileCrowdSecOnStartup works
- Check process starts on container restart
**Usage:** Run via task "Integration: CrowdSec Startup"
---
## Documentation
### 1. Plans & Specifications
#### `/docs/plans/crowdsec_full_implementation.md`
**Purpose:** Complete implementation plan for CrowdSec integration
#### `/docs/plans/crowdsec_testing_plan.md`
**Purpose:** Comprehensive testing strategy
#### `/docs/plans/crowdsec_reconciliation_failure.md`
**Purpose:** Troubleshooting guide for startup reconciliation
#### `/docs/plans/crowdsec_lapi_error_diagnostic.md`
**Purpose:** LAPI connectivity diagnostic plan
#### `/docs/plans/crowdsec_toggle_fix_plan.md`
**Purpose:** Toggle fix implementation plan (backed up from current_spec.md)
### 2. Reports & QA
#### `/docs/reports/crowdsec_integration_summary.md`
**Purpose:** Summary of CrowdSec integration work
#### `/docs/reports/qa_crowdsec_toggle_fix_summary.md`
**Purpose:** QA report for toggle fix
#### `/docs/reports/qa_report_crowdsec_architecture.md`
**Purpose:** Architecture review report
#### `/docs/reports/crowdsec-preset-fix-summary.md`
**Purpose:** Preset system fix report
#### `/docs/reports/crowdsec_migration_qa_report.md`
**Purpose:** Migration QA report
#### `/docs/reports/qa_report_crowdsec_markdownlint_20251212.md`
**Purpose:** Linting QA report
#### `/docs/reports/qa_crowdsec_lapi_availability_fix.md`
**Purpose:** LAPI availability fix report
#### `/docs/reports/crowdsec-preset-pull-apply-debug.md`
**Purpose:** Preset pull/apply debugging report
#### `/docs/reports/qa_crowdsec_implementation.md`
**Purpose:** Implementation QA report
### 3. User Documentation
#### `/docs/troubleshooting/crowdsec.md`
**Purpose:** User troubleshooting guide for CrowdSec
---
## Test Coverage Summary
### Frontend Tests
| File | Test Files | Coverage Level |
|------|-----------|----------------|
| `CrowdSecConfig.tsx` | 3 test files (spec, test, coverage) | **High** |
| `ImportCrowdSec.tsx` | 2 test files (spec, test) | **High** |
| `Security.tsx` | 4 test files (spec, test, loading, dashboard) | **High** |
| `api/crowdsec.ts` | 1 test file | **Partial** |
| `api/presets.ts` | **None** | **None** |
| `api/consoleEnrollment.ts` | **None** | **None** |
| `hooks/useSecurity.ts` | 1 test file (partial CrowdSec coverage) | **Partial** |
| `hooks/useConsoleEnrollment.ts` | **None** | **None** |
| `data/crowdsecPresets.ts` | **None** | **None** |
| `utils/crowdsecExport.ts` | **None** | **None** |
| `components/Layout.tsx` | 1 test file (partial) | **Partial** |
| `components/LiveLogViewer.tsx` | 1 test file (includes CrowdSec filter) | **Partial** |
### Backend Tests
| File | Test Files | Coverage Level |
|------|-----------|----------------|
| `crowdsec_handler.go` | 10 test files | **Excellent** |
| `crowdsec_exec.go` | 1 test file | **High** |
| `crowdsec_startup.go` | 1 test file | **High** |
| `security_handler.go` | Multiple (includes CrowdSec tests) | **Partial** |
| `models/crowdsec_preset_event.go` | **None** | **None** |
| `models/crowdsec_console_enrollment.go` | **None** | **None** |
| `caddy/config.go` | 1 test file (crowdsec specific) | **High** |
| `caddy/manager.go` | 1 test file (includes CrowdSec toggle) | **High** |
### Integration Tests
| File | Purpose | Coverage Level |
|------|---------|----------------|
| `crowdsec_integration_test.go` | Process control integration | **High** |
| `crowdsec_decisions_integration_test.go` | Decision management integration | **High** |
| `crowdsec_pull_apply_integration_test.go` | Preset system integration | **High** |
### Script Tests
| Script | Status |
|--------|--------|
| `crowdsec_integration.sh` | Available, manual execution |
| `crowdsec_decision_integration.sh` | Available, manual execution |
| `crowdsec_startup_test.sh` | Available, manual execution |
---
## Testing Gaps
### Critical (No Tests)
1. **Frontend:**
- `/frontend/src/api/presets.ts` - Preset API client
- `/frontend/src/api/consoleEnrollment.ts` - Console enrollment API
- `/frontend/src/hooks/useConsoleEnrollment.ts` - Console enrollment hook
- `/frontend/src/data/crowdsecPresets.ts` - Static preset data
- `/frontend/src/utils/crowdsecExport.ts` - Export utilities
2. **Backend:**
- `/backend/internal/models/crowdsec_preset_event.go` - Audit model
- `/backend/internal/models/crowdsec_console_enrollment.go` - Enrollment model
### Medium (Partial Coverage)
1. **Frontend:**
- `api/crowdsec.ts` - Only basic tests, missing edge cases
- `hooks/useSecurity.ts` - useDecisions tests exist, but limited
- `components/Layout.tsx` - CrowdSec navigation only partially tested
2. **Backend:**
- `security_handler.go` - CrowdSec-related methods have partial coverage
---
## API Endpoint Reference
### CrowdSec-Specific Endpoints
| Method | Path | Handler | Purpose |
|--------|------|---------|---------|
| POST | `/admin/crowdsec/start` | `CrowdsecHandler.Start` | Start process |
| POST | `/admin/crowdsec/stop` | `CrowdsecHandler.Stop` | Stop process |
| GET | `/admin/crowdsec/status` | `CrowdsecHandler.Status` | Get status |
| POST | `/admin/crowdsec/import` | `CrowdsecHandler.ImportConfig` | Import config |
| GET | `/admin/crowdsec/export` | `CrowdsecHandler.ExportConfig` | Export config |
| GET | `/admin/crowdsec/files` | `CrowdsecHandler.ListFiles` | List files |
| GET | `/admin/crowdsec/file` | `CrowdsecHandler.ReadFile` | Read file |
| POST | `/admin/crowdsec/file` | `CrowdsecHandler.WriteFile` | Write file |
| GET | `/admin/crowdsec/acquisition` | `CrowdsecHandler.GetAcquisitionConfig` | Get acquis.yaml |
| POST | `/admin/crowdsec/acquisition` | `CrowdsecHandler.UpdateAcquisitionConfig` | Update acquis.yaml |
| GET | `/admin/crowdsec/presets` | `CrowdsecHandler.ListPresets` | List presets |
| POST | `/admin/crowdsec/presets/pull` | `CrowdsecHandler.PullPreset` | Pull preset |
| POST | `/admin/crowdsec/presets/apply` | `CrowdsecHandler.ApplyPreset` | Apply preset |
| GET | `/admin/crowdsec/presets/cache/:slug` | `CrowdsecHandler.GetCachedPreset` | Get cached |
| POST | `/admin/crowdsec/console/enroll` | `CrowdsecHandler.ConsoleEnroll` | Enroll console |
| GET | `/admin/crowdsec/console/status` | `CrowdsecHandler.ConsoleStatus` | Console status |
| GET | `/admin/crowdsec/decisions` | `CrowdsecHandler.ListDecisions` | List (cscli) |
| GET | `/admin/crowdsec/decisions/lapi` | `CrowdsecHandler.GetLAPIDecisions` | List (LAPI) |
| GET | `/admin/crowdsec/lapi/health` | `CrowdsecHandler.CheckLAPIHealth` | LAPI health |
| POST | `/admin/crowdsec/ban` | `CrowdsecHandler.BanIP` | Ban IP |
| DELETE | `/admin/crowdsec/ban/:ip` | `CrowdsecHandler.UnbanIP` | Unban IP |
| POST | `/admin/crowdsec/bouncer/register` | `CrowdsecHandler.RegisterBouncer` | Register bouncer |
### Security Endpoints (CrowdSec-related)
| Method | Path | Handler | Purpose |
|--------|------|---------|---------|
| GET | `/security/status` | `SecurityHandler.GetStatus` | Security status (includes CrowdSec) |
| GET | `/security/config` | `SecurityHandler.GetConfig` | Security config (includes CrowdSec) |
| POST | `/security/config` | `SecurityHandler.UpdateConfig` | Update config (can change CrowdSec mode) |
| GET | `/security/decisions` | `SecurityHandler.ListDecisions` | List decisions (includes CrowdSec) |
| POST | `/security/decisions` | `SecurityHandler.CreateDecision` | Create manual decision |
---
## Dependencies & External Integrations
### External Services
- **CrowdSec Hub API** - `https://hub-api.crowdsec.net/v1/` (preset downloads)
- **CrowdSec Console API** - Enrollment and heartbeat endpoints
- **CrowdSec LAPI** - Local API for decision queries (default: `http://127.0.0.1:8085`)
### External Binaries
- `crowdsec` - CrowdSec security engine
- `cscli` - CrowdSec command-line interface
### Caddy Modules
- `http.handlers.crowdsec` - CrowdSec bouncer module for Caddy
---
## Related Features
### Cerberus Security Suite
CrowdSec is Layer 1 of the Cerberus security suite:
- **Layer 1:** CrowdSec (IP reputation)
- **Layer 2:** WAF (Web Application Firewall)
- **Layer 3:** Rate Limiting
- **Layer 4:** ACL (Access Control Lists)
All layers are controlled via `SecurityConfig` and can be enabled/disabled independently.
---
## Notes
1. **Testing Priority:**
- Frontend preset API client needs tests
- Console enrollment needs full test coverage
- Export utilities need tests
2. **Architecture:**
- CrowdSec runs as a separate process managed by Charon
- Configuration stored in `data/crowdsec/` directory
- Process state reconciled on container restart
- Caddy bouncer communicates with CrowdSec LAPI
3. **Configuration Flow:**
- User changes mode in UI → `SecurityConfig` updated
- `security.crowdsec.enabled` Setting can override mode
- Caddy config regenerated with bouncer enabled/disabled
- Startup reconciliation ensures process state matches config
4. **Future Improvements:**
- Add webhook support for CrowdSec alerts
- Implement preset validation before apply
- Add telemetry for preset usage
- Improve LAPI health monitoring
---
## PHASE 2: FRONTEND TEST STRATEGY
**Generated:** 2025-12-15
**Goal:** Achieve 100% frontend CrowdSec test coverage and expose existing bugs
**Context:** Last 13 commits were CrowdSec hotfixes addressing:
- Toggle state mismatch after restart
- LAPI readiness/availability issues
- 500 errors on startup
- Database migration and verification failures
- Post-rebuild state sync issues
### Priority Order
**Phase 2A: API Clients (Foundation Layer)**
1. `frontend/src/api/presets.ts`
2. `frontend/src/api/consoleEnrollment.ts`
**Phase 2B: Data & Utilities**
3. `frontend/src/data/crowdsecPresets.ts`
4. `frontend/src/utils/crowdsecExport.ts`
**Phase 2C: React Query Hooks**
5. `frontend/src/hooks/useConsoleEnrollment.ts`
**Phase 2D: Integration Tests**
6. Cross-component integration scenarios
---
### PHASE 2A: API Client Tests
#### 1. `/frontend/src/api/__tests__/presets.test.ts`
**Purpose:** Test preset API client with focus on caching and error handling
**Test Scenarios:**
##### Basic Happy Path
```typescript
describe('listCrowdsecPresets', () => {
it('should fetch presets list with cached flags', async () => {
// Mock: API returns presets with various cache states
const mockPresets = [
{ slug: 'bot-mitigation', cached: true, cache_key: 'abc123' },
{ slug: 'honeypot', cached: false }
]
// Assert: Proper GET call to /admin/crowdsec/presets
// Assert: Returns data.presets array
})
})
describe('pullCrowdsecPreset', () => {
it('should pull preset and return preview with cache_key', async () => {
// Mock: API returns preview content and cache metadata
const mockResponse = {
status: 'success',
slug: 'bot-mitigation',
preview: '# Config content...',
cache_key: 'xyz789',
etag: '"abc"',
retrieved_at: '2025-12-15T10:00:00Z'
}
// Assert: POST to /admin/crowdsec/presets/pull with { slug }
})
})
describe('applyCrowdsecPreset', () => {
it('should apply preset with cache_key when available', async () => {
// Mock: API returns success with backup path
const payload = { slug: 'bot-mitigation', cache_key: 'xyz789' }
const mockResponse = {
status: 'success',
backup: '/data/backups/preset-backup-123.tar.gz',
reload_hint: true
}
// Assert: POST to /admin/crowdsec/presets/apply
})
it('should apply preset without cache_key (fallback mode)', async () => {
// Test scenario: User applies preset without pulling first
const payload = { slug: 'bot-mitigation' }
// Assert: Backend should fetch from hub API directly
})
})
describe('getCrowdsecPresetCache', () => {
it('should fetch cached preset preview', async () => {
// Mock: Returns previously cached preview
const mockCache = {
preview: '# Cached content...',
cache_key: 'xyz789',
etag: '"abc"'
}
// Assert: GET to /admin/crowdsec/presets/cache/:slug with URL encoding
})
})
```
##### Edge Cases & Bug Exposure
**🐛 BUG TARGET: Cache Key Mismatch**
```typescript
it('should handle stale cache_key gracefully', async () => {
// Scenario: User pulls preset, backend updates cache, user applies with old key
// Expected: Backend should detect mismatch and re-fetch or error clearly
// Current Bug: May apply wrong version silently
const stalePayload = { slug: 'bot-mitigation', cache_key: 'old_key_123' }
// Assert: Should return error or warning about stale cache
})
```
**🐛 BUG TARGET: Network Failures During Pull**
```typescript
it('should handle hub API timeout during pull', async () => {
// Scenario: CrowdSec Hub API is slow or unreachable
// Mock: API returns 504 Gateway Timeout
// Assert: Client should throw descriptive error
// Assert: Should NOT cache partial/failed response
})
it('should handle ETAG validation failure', async () => {
// Scenario: Hub API returns 304 Not Modified but local cache missing
// Expected: Should re-fetch or error clearly
})
```
**🐛 BUG TARGET: Apply Without CrowdSec Running**
```typescript
it('should error when applying preset with CrowdSec stopped', async () => {
// Scenario: User tries to apply preset but CrowdSec process is not running
// Mock: API returns error about cscli being unavailable
// Assert: Clear error message about starting CrowdSec first
})
```
**Mock Strategies:**
```typescript
// Mock client.get/post with vi.mock
vi.mock('../client')
// Mock data structures
const mockPresetList = {
presets: [
{
slug: 'bot-mitigation-essentials',
title: 'Bot Mitigation Essentials',
summary: 'Core HTTP parsers...',
source: 'hub',
tags: ['bots', 'web'],
requires_hub: true,
available: true,
cached: false
}
]
}
const mockPullResponse = {
status: 'success',
slug: 'bot-mitigation-essentials',
preview: 'configs:\n collections:\n - crowdsecurity/base-http-scenarios',
cache_key: 'abc123def456',
etag: '"w/12345"',
retrieved_at: new Date().toISOString(),
source: 'hub'
}
```
---
#### 2. `/frontend/src/api/__tests__/consoleEnrollment.test.ts`
**Purpose:** Test CrowdSec Console enrollment API with security focus
**Test Scenarios:**
##### Basic Operations
```typescript
describe('getConsoleStatus', () => {
it('should fetch enrollment status with pending state', async () => {
const mockStatus = {
status: 'pending',
tenant: 'my-org',
agent_name: 'charon-prod',
key_present: true,
last_attempt_at: '2025-12-15T09:00:00Z'
}
// Assert: GET to /admin/crowdsec/console/status
})
it('should fetch enrolled status with heartbeat', async () => {
const mockStatus = {
status: 'enrolled',
tenant: 'my-org',
agent_name: 'charon-prod',
key_present: true,
enrolled_at: '2025-12-14T10:00:00Z',
last_heartbeat_at: '2025-12-15T09:55:00Z'
}
// Assert: Shows successful enrollment state
})
it('should fetch failed status with error message', async () => {
const mockStatus = {
status: 'failed',
tenant: 'my-org',
agent_name: 'charon-prod',
key_present: false,
last_error: 'Invalid enrollment key',
last_attempt_at: '2025-12-15T09:00:00Z',
correlation_id: 'req-abc123'
}
// Assert: Error details are surfaced for debugging
})
})
describe('enrollConsole', () => {
it('should enroll with valid payload', async () => {
const payload = {
enrollment_key: 'cs-enroll-abc123xyz',
tenant: 'my-org',
agent_name: 'charon-prod',
force: false
}
// Mock: Returns enrolled status
// Assert: POST to /admin/crowdsec/console/enroll
})
it('should force re-enrollment when force=true', async () => {
const payload = {
enrollment_key: 'cs-enroll-new-key',
agent_name: 'charon-updated',
force: true
}
// Assert: Overwrites existing enrollment
})
})
```
##### Security & Error Cases
**🐛 BUG TARGET: Enrollment Key Exposure**
```typescript
it('should NOT return enrollment key in status response', async () => {
// Security test: Ensure key is never exposed in API responses
const mockStatus = await getConsoleStatus()
expect(mockStatus).not.toHaveProperty('enrollment_key')
expect(mockStatus).not.toHaveProperty('encrypted_enroll_key')
// Only key_present boolean should be exposed
})
```
**🐛 BUG TARGET: Enrollment Retry Logic**
```typescript
it('should handle transient network errors during enrollment', async () => {
// Scenario: CrowdSec Console API temporarily unavailable
// Mock: First call fails with network error, second succeeds
// Assert: Should NOT mark as permanently failed
// Assert: Should retry on next status poll or manual retry
})
it('should handle invalid enrollment key format', async () => {
// Scenario: User pastes malformed key
const payload = {
enrollment_key: 'not-a-valid-key',
agent_name: 'test'
}
// Mock: API returns 400 Bad Request
// Assert: Clear validation error message
})
```
**🐛 BUG TARGET: Tenant Name Validation**
```typescript
it('should sanitize tenant name with special characters', async () => {
// Scenario: Tenant name has spaces/special chars
const payload = {
enrollment_key: 'valid-key',
tenant: 'My Org (Production)', // Invalid chars
agent_name: 'agent1'
}
// Expected: Backend should sanitize or reject
// Assert: Should not cause SQL injection or path traversal
})
```
**Mock Strategies:**
```typescript
const mockEnrollmentStatuses = {
pending: {
status: 'pending',
key_present: true,
last_attempt_at: new Date().toISOString()
},
enrolled: {
status: 'enrolled',
tenant: 'test-tenant',
agent_name: 'test-agent',
key_present: true,
enrolled_at: new Date().toISOString(),
last_heartbeat_at: new Date().toISOString()
},
failed: {
status: 'failed',
key_present: false,
last_error: 'Enrollment key expired',
correlation_id: 'err-123'
}
}
```
---
### PHASE 2B: Data & Utility Tests
#### 3. `/frontend/src/data/__tests__/crowdsecPresets.test.ts`
**Purpose:** Test static preset definitions and lookup logic
**Test Scenarios:**
```typescript
describe('CROWDSEC_PRESETS', () => {
it('should contain all expected presets', () => {
expect(CROWDSEC_PRESETS).toHaveLength(3)
expect(CROWDSEC_PRESETS.map(p => p.slug)).toEqual([
'bot-mitigation-essentials',
'honeypot-friendly-defaults',
'geolocation-aware'
])
})
it('should have valid YAML content for each preset', () => {
CROWDSEC_PRESETS.forEach(preset => {
expect(preset.content).toContain('configs:')
expect(preset.content).toMatch(/collections:|parsers:|scenarios:/)
})
})
it('should have required metadata fields', () => {
CROWDSEC_PRESETS.forEach(preset => {
expect(preset).toHaveProperty('slug')
expect(preset).toHaveProperty('title')
expect(preset).toHaveProperty('description')
expect(preset).toHaveProperty('content')
expect(preset.slug).toMatch(/^[a-z0-9-]+$/) // Slug format validation
})
})
it('should have warnings for production-critical presets', () => {
const botMitigation = CROWDSEC_PRESETS.find(p => p.slug === 'bot-mitigation-essentials')
expect(botMitigation?.warning).toBeDefined()
expect(botMitigation?.warning).toContain('allowlist')
})
})
describe('findCrowdsecPreset', () => {
it('should find preset by slug', () => {
const preset = findCrowdsecPreset('bot-mitigation-essentials')
expect(preset).toBeDefined()
expect(preset?.slug).toBe('bot-mitigation-essentials')
})
it('should return undefined for non-existent slug', () => {
const preset = findCrowdsecPreset('non-existent-preset')
expect(preset).toBeUndefined()
})
it('should be case-sensitive', () => {
const preset = findCrowdsecPreset('BOT-MITIGATION-ESSENTIALS')
expect(preset).toBeUndefined()
})
})
```
**🐛 BUG TARGET: Preset Content Validation**
```typescript
describe('preset content integrity', () => {
it('should have valid CrowdSec YAML structure', () => {
// Test that content can be parsed as YAML
CROWDSEC_PRESETS.forEach(preset => {
expect(() => {
// Simple validation: check for basic structure
const lines = preset.content.split('\n')
expect(lines[0]).toMatch(/^configs:/)
}).not.toThrow()
})
})
it('should reference valid CrowdSec hub items', () => {
// Validate collection/parser/scenario names follow hub naming conventions
CROWDSEC_PRESETS.forEach(preset => {
const collections = preset.content.match(/- crowdsecurity\/[\w-]+/g) || []
collections.forEach(item => {
expect(item).toMatch(/^- crowdsecurity\/[a-z0-9-]+$/)
})
})
})
})
```
---
#### 4. `/frontend/src/utils/__tests__/crowdsecExport.test.ts`
**Purpose:** Test export filename generation and download utilities
**Test Scenarios:**
```typescript
describe('buildCrowdsecExportFilename', () => {
it('should generate filename with ISO timestamp', () => {
const filename = buildCrowdsecExportFilename()
expect(filename).toMatch(/^crowdsec-export-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}.*\.tar\.gz$/)
})
it('should replace colons with hyphens in timestamp', () => {
const filename = buildCrowdsecExportFilename()
expect(filename).not.toContain(':')
})
it('should always end with .tar.gz', () => {
const filename = buildCrowdsecExportFilename()
expect(filename).toEndWith('.tar.gz')
})
})
describe('promptCrowdsecFilename', () => {
beforeEach(() => {
vi.stubGlobal('prompt', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should return default filename when user cancels', () => {
vi.mocked(window.prompt).mockReturnValue(null)
const result = promptCrowdsecFilename('default.tar.gz')
expect(result).toBeNull()
})
it('should sanitize user input by replacing slashes', () => {
vi.mocked(window.prompt).mockReturnValue('backup/prod/config')
const result = promptCrowdsecFilename()
expect(result).toBe('backup-prod-config.tar.gz')
})
it('should replace spaces with hyphens', () => {
vi.mocked(window.prompt).mockReturnValue('crowdsec backup 2025')
const result = promptCrowdsecFilename()
expect(result).toBe('crowdsec-backup-2025.tar.gz')
})
it('should append .tar.gz if missing', () => {
vi.mocked(window.prompt).mockReturnValue('my-backup')
const result = promptCrowdsecFilename()
expect(result).toBe('my-backup.tar.gz')
})
it('should not double-append .tar.gz', () => {
vi.mocked(window.prompt).mockReturnValue('my-backup.tar.gz')
const result = promptCrowdsecFilename()
expect(result).toBe('my-backup.tar.gz')
})
it('should handle empty string by using default', () => {
vi.mocked(window.prompt).mockReturnValue(' ')
const result = promptCrowdsecFilename('default.tar.gz')
expect(result).toBe('default.tar.gz')
})
})
describe('downloadCrowdsecExport', () => {
let createObjectURLSpy: ReturnType<typeof vi.fn>
let revokeObjectURLSpy: ReturnType<typeof vi.fn>
let clickSpy: ReturnType<typeof vi.fn>
beforeEach(() => {
createObjectURLSpy = vi.fn(() => 'blob:mock-url')
revokeObjectURLSpy = vi.fn()
vi.stubGlobal('URL', {
createObjectURL: createObjectURLSpy,
revokeObjectURL: revokeObjectURLSpy
})
clickSpy = vi.fn()
vi.spyOn(document, 'createElement').mockImplementation((tag) => {
if (tag === 'a') {
return {
click: clickSpy,
remove: vi.fn(),
href: '',
download: ''
} as any
}
return document.createElement(tag)
})
vi.spyOn(document.body, 'appendChild').mockImplementation(() => null as any)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('should create blob URL and trigger download', () => {
const blob = new Blob(['test data'], { type: 'application/gzip' })
downloadCrowdsecExport(blob, 'test.tar.gz')
expect(createObjectURLSpy).toHaveBeenCalled()
expect(clickSpy).toHaveBeenCalled()
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
})
it('should set correct filename on anchor element', () => {
const blob = new Blob(['data'])
const createElementSpy = vi.spyOn(document, 'createElement')
downloadCrowdsecExport(blob, 'my-backup.tar.gz')
const anchorCall = createElementSpy.mock.results.find(
result => result.value.tagName === 'A'
)
// Note: Detailed assertion requires DOM manipulation mocking
})
it('should clean up by removing anchor element', () => {
const blob = new Blob(['data'])
const removeSpy = vi.fn()
vi.spyOn(document, 'createElement').mockReturnValue({
click: vi.fn(),
remove: removeSpy,
href: '',
download: ''
} as any)
downloadCrowdsecExport(blob, 'test.tar.gz')
expect(removeSpy).toHaveBeenCalled()
})
})
```
**🐛 BUG TARGET: Path Traversal in Filename**
```typescript
describe('security: path traversal prevention', () => {
it('should sanitize directory traversal attempts', () => {
vi.mocked(window.prompt).mockReturnValue('../../etc/passwd')
const result = promptCrowdsecFilename()
expect(result).toBe('.....-...-etc-passwd.tar.gz')
expect(result).not.toContain('../')
})
it('should handle absolute paths', () => {
vi.mocked(window.prompt).mockReturnValue('/etc/crowdsec/backup')
const result = promptCrowdsecFilename()
expect(result).not.toMatch(/^\//)
})
})
```
---
### PHASE 2C: React Query Hook Tests
#### 5. `/frontend/src/hooks/__tests__/useConsoleEnrollment.test.tsx`
**Purpose:** Test React Query hooks for CrowdSec Console enrollment
**Test Scenarios:**
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useConsoleStatus, useEnrollConsole } from '../useConsoleEnrollment'
import * as consoleEnrollmentApi from '../../api/consoleEnrollment'
vi.mock('../../api/consoleEnrollment')
describe('useConsoleEnrollment hooks', () => {
let queryClient: QueryClient
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false }
}
})
vi.clearAllMocks()
})
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
describe('useConsoleStatus', () => {
it('should fetch console enrollment status when enabled', async () => {
const mockStatus = {
status: 'enrolled',
tenant: 'test-org',
agent_name: 'charon-1',
key_present: true,
enrolled_at: '2025-12-14T10:00:00Z',
last_heartbeat_at: '2025-12-15T09:00:00Z'
}
vi.mocked(consoleEnrollmentApi.getConsoleStatus).mockResolvedValue(mockStatus)
const { result } = renderHook(() => useConsoleStatus(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual(mockStatus)
expect(consoleEnrollmentApi.getConsoleStatus).toHaveBeenCalledTimes(1)
})
it('should NOT fetch when enabled=false', async () => {
const { result } = renderHook(() => useConsoleStatus(false), { wrapper })
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(consoleEnrollmentApi.getConsoleStatus).not.toHaveBeenCalled()
expect(result.current.data).toBeUndefined()
})
it('should use correct query key for invalidation', () => {
renderHook(() => useConsoleStatus(), { wrapper })
const queries = queryClient.getQueryCache().getAll()
const consoleQuery = queries.find(q =>
JSON.stringify(q.queryKey) === JSON.stringify(['crowdsec-console-status'])
)
expect(consoleQuery).toBeDefined()
})
})
describe('useEnrollConsole', () => {
it('should enroll console and invalidate status query', async () => {
const mockResponse = {
status: 'enrolled',
tenant: 'my-org',
agent_name: 'charon-prod',
key_present: true,
enrolled_at: new Date().toISOString()
}
vi.mocked(consoleEnrollmentApi.enrollConsole).mockResolvedValue(mockResponse)
const { result } = renderHook(() => useEnrollConsole(), { wrapper })
const payload = {
enrollment_key: 'cs-enroll-key-123',
tenant: 'my-org',
agent_name: 'charon-prod'
}
result.current.mutate(payload)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(consoleEnrollmentApi.enrollConsole).toHaveBeenCalledWith(payload)
expect(result.current.data).toEqual(mockResponse)
})
it('should invalidate console status query on success', async () => {
const mockResponse = { status: 'enrolled', key_present: true }
vi.mocked(consoleEnrollmentApi.enrollConsole).mockResolvedValue(mockResponse)
// Set up initial status query
queryClient.setQueryData(['crowdsec-console-status'], { status: 'pending' })
const { result } = renderHook(() => useEnrollConsole(), { wrapper })
result.current.mutate({
enrollment_key: 'key',
agent_name: 'agent'
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// Verify invalidation happened
const state = queryClient.getQueryState(['crowdsec-console-status'])
expect(state?.isInvalidated).toBe(true)
})
it('should handle enrollment errors', async () => {
const error = new Error('Invalid enrollment key')
vi.mocked(consoleEnrollmentApi.enrollConsole).mockRejectedValue(error)
const { result } = renderHook(() => useEnrollConsole(), { wrapper })
result.current.mutate({
enrollment_key: 'invalid',
agent_name: 'test'
})
await waitFor(() => expect(result.current.isError).toBe(true))
expect(result.current.error).toEqual(error)
})
})
})
```
**🐛 BUG TARGET: Polling and Stale Data**
```typescript
describe('polling behavior', () => {
it('should refetch status on window focus', async () => {
vi.mocked(consoleEnrollmentApi.getConsoleStatus).mockResolvedValue({
status: 'pending',
key_present: true
})
const { result } = renderHook(() => useConsoleStatus(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// Simulate window focus
window.dispatchEvent(new Event('focus'))
// Should trigger refetch
await waitFor(() => {
expect(consoleEnrollmentApi.getConsoleStatus).toHaveBeenCalledTimes(2)
})
})
it('should NOT poll when enrollment is complete', async () => {
// Scenario: Avoid unnecessary API calls after successful enrollment
vi.mocked(consoleEnrollmentApi.getConsoleStatus).mockResolvedValue({
status: 'enrolled',
enrolled_at: '2025-12-15T10:00:00Z',
key_present: true
})
const { result } = renderHook(() => useConsoleStatus(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// Wait and verify no additional calls
await new Promise(resolve => setTimeout(resolve, 100))
expect(consoleEnrollmentApi.getConsoleStatus).toHaveBeenCalledTimes(1)
})
})
```
**🐛 BUG TARGET: Race Conditions**
```typescript
describe('concurrent enrollment attempts', () => {
it('should handle multiple enrollment mutations gracefully', async () => {
// Scenario: User clicks enroll button multiple times rapidly
const mockResponse = { status: 'enrolled', key_present: true }
vi.mocked(consoleEnrollmentApi.enrollConsole).mockResolvedValue(mockResponse)
const { result } = renderHook(() => useEnrollConsole(), { wrapper })
// Trigger multiple mutations
result.current.mutate({ enrollment_key: 'key1', agent_name: 'agent' })
result.current.mutate({ enrollment_key: 'key2', agent_name: 'agent' })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// Only the last mutation should be active
expect(consoleEnrollmentApi.enrollConsole).toHaveBeenCalledWith(
expect.objectContaining({ enrollment_key: 'key2' })
)
})
})
```
---
### PHASE 2D: Integration Tests
#### 6. Cross-Component Integration Testing
**File:** `/frontend/src/__tests__/integration/crowdsec-preset-flow.test.tsx`
**Purpose:** Test complete preset selection and application workflow
**Test Scenarios:**
```typescript
describe('CrowdSec Preset Integration Flow', () => {
it('should complete full preset workflow: list → pull → preview → apply', async () => {
// 1. List presets
const presets = await listCrowdsecPresets()
expect(presets.presets).toHaveLength(3)
// 2. Pull specific preset
const pullResult = await pullCrowdsecPreset('bot-mitigation-essentials')
expect(pullResult.cache_key).toBeDefined()
// 3. Preview cached preset
const cachedPreset = await getCrowdsecPresetCache('bot-mitigation-essentials')
expect(cachedPreset.preview).toEqual(pullResult.preview)
expect(cachedPreset.cache_key).toEqual(pullResult.cache_key)
// 4. Apply preset
const applyResult = await applyCrowdsecPreset({
slug: 'bot-mitigation-essentials',
cache_key: pullResult.cache_key
})
expect(applyResult.status).toBe('success')
expect(applyResult.backup).toBeDefined()
})
it('should handle preset application without prior pull (direct mode)', async () => {
// Scenario: User applies preset from static list without pulling first
const applyResult = await applyCrowdsecPreset({
slug: 'honeypot-friendly-defaults'
// No cache_key provided
})
expect(applyResult.status).toBe('success')
expect(applyResult.used_cscli).toBe(true) // Backend fetched directly
})
})
```
**File:** `/frontend/src/__tests__/integration/crowdsec-console-enrollment.test.tsx`
**Purpose:** Test console enrollment UI flow with hooks
```typescript
describe('CrowdSec Console Enrollment Integration', () => {
it('should complete enrollment flow with status updates', async () => {
const { result: statusHook } = renderHook(() => useConsoleStatus(), { wrapper })
const { result: enrollHook } = renderHook(() => useEnrollConsole(), { wrapper })
// Initial status: not enrolled
await waitFor(() => expect(statusHook.current.data?.status).toBe('none'))
// Trigger enrollment
enrollHook.current.mutate({
enrollment_key: 'cs-enroll-valid-key',
tenant: 'test-org',
agent_name: 'charon-test'
})
await waitFor(() => expect(enrollHook.current.isSuccess).toBe(true))
// Status should update to enrolled
await waitFor(() => {
expect(statusHook.current.data?.status).toBe('enrolled')
expect(statusHook.current.data?.tenant).toBe('test-org')
})
})
})
```
**File:** `/frontend/src/__tests__/integration/crowdsec-api-hook-consistency.test.tsx`
**Purpose:** Verify API client and hook return consistent data shapes
```typescript
describe('API-Hook Consistency', () => {
it('should return consistent data shapes between API and hooks', async () => {
// Test: Verify that useConsoleStatus returns same shape as getConsoleStatus
const apiResponse = await consoleEnrollmentApi.getConsoleStatus()
const { result } = renderHook(() => useConsoleStatus(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toMatchObject(apiResponse)
expect(Object.keys(result.current.data!)).toEqual(Object.keys(apiResponse))
})
it('should handle API errors consistently across hooks', async () => {
// Test: Error shape is preserved through React Query
const apiError = new Error('Network failure')
vi.mocked(consoleEnrollmentApi.getConsoleStatus).mockRejectedValue(apiError)
const { result } = renderHook(() => useConsoleStatus(), { wrapper })
await waitFor(() => expect(result.current.isError).toBe(true))
expect(result.current.error).toEqual(apiError)
})
})
```
---
### BUG EXPOSURE STRATEGY
**Based on Recent Hotfixes:**
#### 🐛 Focus Area 1: Toggle State Mismatch After Restart
**Files to Test:** `useSecurityStatus`, `Security.tsx`, preset application
**Test Strategy:**
- Simulate container restart (clear React Query cache)
- Verify that CrowdSec toggle state matches backend SecurityConfig
- Test that LAPI availability doesn't cause UI state mismatch
- Check that preset application doesn't break toggle state
**Specific Test:**
```typescript
it('should sync toggle state after simulated restart', async () => {
// 1. Set CrowdSec to enabled
queryClient.setQueryData(['security-status'], { crowdsec: { enabled: true } })
// 2. Clear cache (simulate restart)
queryClient.clear()
// 3. Refetch status
const { result } = renderHook(() => useSecurityStatus(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// 4. Verify state is still correct
expect(result.current.data.crowdsec.enabled).toBe(true)
})
```
#### 🐛 Focus Area 2: LAPI Readiness/Availability Issues
**Files to Test:** `api/crowdsec.ts`, `statusCrowdsec()`, decision endpoints
**Test Strategy:**
- Test scenarios where CrowdSec process is running but LAPI not ready
- Verify error handling when LAPI returns 503/504
- Test decision fetching when LAPI is unavailable
- Ensure UI doesn't show stale decision data
**Specific Test:**
```typescript
it('should handle LAPI not ready when fetching decisions', async () => {
// Mock: CrowdSec running but LAPI not ready
vi.mocked(client.get).mockRejectedValueOnce({
response: { status: 503, data: { error: 'LAPI not ready' } }
})
await expect(listCrowdsecDecisions()).rejects.toThrow()
// UI should show loading state, not error
})
```
#### 🐛 Focus Area 3: Preset Cache Invalidation
**Files to Test:** `api/presets.ts`, `getCrowdsecPresetCache()`
**Test Strategy:**
- Test stale cache key scenarios
- Verify that applying preset invalidates cache properly
- Check that cache survives page refresh but not backend restart
- Test cache hit/miss logic
**Specific Test:**
```typescript
it('should invalidate cache after preset application', async () => {
// 1. Pull preset (populates cache)
const pullResult = await pullCrowdsecPreset('bot-mitigation-essentials')
const cacheKey1 = pullResult.cache_key
// 2. Apply preset
await applyCrowdsecPreset({ slug: 'bot-mitigation-essentials', cache_key: cacheKey1 })
// 3. Pull again (should get fresh data, new cache key)
const pullResult2 = await pullCrowdsecPreset('bot-mitigation-essentials')
expect(pullResult2.cache_key).not.toBe(cacheKey1)
})
```
#### 🐛 Focus Area 4: Console Enrollment Retry Logic
**Files to Test:** `useConsoleEnrollment.ts`, `api/consoleEnrollment.ts`
**Test Strategy:**
- Test enrollment failure followed by retry
- Verify that transient errors don't mark enrollment as permanently failed
- Check that correlation_id is tracked for debugging
- Ensure enrollment key is never logged or exposed
**Specific Test:**
```typescript
it('should allow retry after transient enrollment failure', async () => {
const { result } = renderHook(() => useEnrollConsole(), { wrapper })
// First attempt fails
vi.mocked(consoleEnrollmentApi.enrollConsole).mockRejectedValueOnce(
new Error('Network timeout')
)
result.current.mutate({ enrollment_key: 'key', agent_name: 'agent' })
await waitFor(() => expect(result.current.isError).toBe(true))
// Second attempt succeeds
vi.mocked(consoleEnrollmentApi.enrollConsole).mockResolvedValueOnce({
status: 'enrolled',
key_present: true
})
result.current.mutate({ enrollment_key: 'key', agent_name: 'agent' })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
})
```
---
### MOCK DATA STRUCTURES
**Comprehensive Mock Library:**
```typescript
// frontend/src/__tests__/mocks/crowdsecMocks.ts
export const mockPresets = {
list: {
presets: [
{
slug: 'bot-mitigation-essentials',
title: 'Bot Mitigation Essentials',
summary: 'Core HTTP parsers and scenarios',
source: 'hub',
tags: ['bots', 'web', 'auth'],
requires_hub: true,
available: true,
cached: false
},
{
slug: 'honeypot-friendly-defaults',
title: 'Honeypot Friendly Defaults',
summary: 'Lightweight defaults for honeypots',
source: 'builtin',
tags: ['low-noise', 'ssh'],
requires_hub: false,
available: true,
cached: true,
cache_key: 'builtin-honeypot-123'
},
{
slug: 'geolocation-aware',
title: 'Geolocation Aware',
summary: 'Geo-enrichment and region-aware scenarios',
source: 'hub',
tags: ['geo', 'access-control'],
requires_hub: true,
available: false, // Not available (requires GeoIP DB)
cached: false
}
]
},
pullResponse: {
success: {
status: 'success',
slug: 'bot-mitigation-essentials',
preview: 'configs:\n collections:\n - crowdsecurity/base-http-scenarios',
cache_key: 'hub-bot-mitigation-abc123',
etag: '"w/12345-abcdef"',
retrieved_at: '2025-12-15T10:00:00Z',
source: 'hub'
},
failure: {
status: 'error',
slug: 'invalid-preset',
error: 'Preset not found in CrowdSec Hub'
}
},
applyResponse: {
success: {
status: 'success',
backup: '/data/charon/data/backups/preset-backup-20251215-100000.tar.gz',
reload_hint: true,
used_cscli: true,
cache_key: 'hub-bot-mitigation-abc123',
slug: 'bot-mitigation-essentials'
},
failureCrowdSecNotRunning: {
status: 'error',
error: 'CrowdSec is not running. Start CrowdSec before applying presets.'
}
}
}
export const mockConsoleEnrollment = {
statusNone: {
status: 'none',
key_present: false
},
statusPending: {
status: 'pending',
tenant: 'test-org',
agent_name: 'charon-prod',
key_present: true,
last_attempt_at: '2025-12-15T09:00:00Z',
correlation_id: 'req-abc123'
},
statusEnrolled: {
status: 'enrolled',
tenant: 'test-org',
agent_name: 'charon-prod',
key_present: true,
enrolled_at: '2025-12-14T10:00:00Z',
last_heartbeat_at: '2025-12-15T09:55:00Z'
},
statusFailed: {
status: 'failed',
tenant: 'test-org',
agent_name: 'charon-prod',
key_present: false,
last_error: 'Invalid enrollment key',
last_attempt_at: '2025-12-15T09:00:00Z',
correlation_id: 'err-xyz789'
}
}
export const mockCrowdSecStatus = {
stopped: {
running: false,
pid: 0,
lapi_available: false
},
runningLAPIReady: {
running: true,
pid: 12345,
lapi_available: true,
lapi_url: 'http://127.0.0.1:8085'
},
runningLAPINotReady: {
running: true,
pid: 12345,
lapi_available: false,
lapi_url: 'http://127.0.0.1:8085',
lapi_error: 'Connection refused'
}
}
```
---
### SUCCESS CRITERIA
#### Test Coverage Metrics
- [ ] All API clients have 100% function coverage
- [ ] All hooks have 100% branch coverage
- [ ] All utility functions have edge case tests
- [ ] Integration tests cover full workflows
#### Bug Detection
- [ ] Tests FAIL when toggle state mismatch occurs
- [ ] Tests FAIL when LAPI availability isn't checked
- [ ] Tests FAIL when cache keys are stale
- [ ] Tests FAIL when enrollment retry logic breaks
#### Code Quality
- [ ] All tests follow existing patterns (vitest, @testing-library/react)
- [ ] Mock data structures are reusable
- [ ] Tests are isolated (no shared state)
- [ ] Error messages are descriptive
---
**End of Inventory**