1847 lines
60 KiB
Markdown
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**
|