diff --git a/.github/agents/Management.agent.md b/.github/agents/Management.agent.md index 9d1be657..d01d687d 100644 --- a/.github/agents/Management.agent.md +++ b/.github/agents/Management.agent.md @@ -167,23 +167,27 @@ The task is not complete until ALL of the following pass with zero issues: - **Base URL**: Uses `PLAYWRIGHT_BASE_URL` or default from `playwright.config.js` - All E2E tests must pass before proceeding to unit tests -2. **Local Patch Coverage Preflight (MANDATORY - Before Unit/Coverage Tests)**: - - Ensure the local patch report is run first via VS Code task `Test: Local Patch Report` or `bash scripts/local-patch-report.sh`. - - Verify both artifacts exist: `test-results/local-patch-report.md` and `test-results/local-patch-report.json`. - - Use this report to identify changed files needing coverage before running backend/frontend coverage suites. - -3. **Coverage Tests (MANDATORY - Verify Explicitly)**: +2. **Coverage Tests (MANDATORY - Verify Explicitly)**: - **Backend**: Ensure `Backend_Dev` ran VS Code task "Test: Backend with Coverage" or `scripts/go-test-coverage.sh` - **Frontend**: Ensure `Frontend_Dev` ran VS Code task "Test: Frontend with Coverage" or `scripts/frontend-test-coverage.sh` - **Why**: These are in manual stage of pre-commit for performance. Subagents MUST run them via VS Code tasks or scripts. - Minimum coverage: 85% for both backend and frontend. - All tests must pass with zero failures. + - **Outputs**: `backend/coverage.txt` and `frontend/coverage/lcov.info` — these are required inputs for step 3. + +3. **Local Patch Coverage Report (MANDATORY - After Coverage Tests)**: + - **Purpose**: Identify uncovered lines in files modified by this task so missing tests are written before declaring Done. This is the bridge between "overall coverage is fine" and "the actual lines I changed are tested." + - **Prerequisites**: `backend/coverage.txt` and `frontend/coverage/lcov.info` must exist (generated by step 2). If missing, run coverage tests first. + - **Run**: VS Code task `Test: Local Patch Report` or `bash scripts/local-patch-report.sh`. + - **Verify artifacts**: Both `test-results/local-patch-report.md` and `test-results/local-patch-report.json` must exist with non-empty results. + - **Act on findings**: If patch coverage for any changed file is below **90%**, delegate to the responsible agent (`Backend_Dev` or `Frontend_Dev`) to add targeted tests covering the uncovered lines. Re-run coverage (step 2) and this report until the threshold is met. + - **Blocking gate**: 90% overall patch coverage. Do not proceed to pre-commit or security scans until resolved or explicitly waived by the user. 4. **Type Safety (Frontend)**: - Ensure `Frontend_Dev` ran VS Code task "Lint: TypeScript Check" or `npm run type-check` - **Why**: This check is in manual stage of pre-commit for performance. Subagents MUST run it explicitly. -5. **Pre-commit Hooks**: Ensure `QA_Security` ran `pre-commit run --all-files` (fast hooks only; coverage was verified in step 3) +5. **Pre-commit Hooks**: Ensure `QA_Security` ran `pre-commit run --all-files` (fast hooks only; coverage was verified in step 2) 6. **Security Scans**: Ensure `QA_Security` ran the following with zero Critical or High severity issues: - **Trivy Filesystem Scan**: Fast scan of source code and dependencies diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index 9878a0d4..7a9dde5e 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -12,9 +12,19 @@ instruction files take precedence over agent files and operator documentation. **MANDATORY**: Before running unit tests, verify the application UI/UX functions correctly end-to-end. -## 0.5 Local Patch Coverage Preflight (Before Unit Tests) +## 0.5 Local Patch Coverage Report (After Coverage Tests) -**MANDATORY**: After E2E and before backend/frontend unit coverage runs, generate a local patch report so uncovered changed lines are visible early. +**MANDATORY**: After running backend and frontend coverage tests (which generate +`backend/coverage.txt` and `frontend/coverage/lcov.info`), run the local patch +report to identify uncovered lines in changed files. + +**Purpose**: Overall coverage can be healthy while the specific lines you changed +are untested. This step catches that gap. If uncovered lines are found in +feature code, add targeted tests before completing the task. + +**Prerequisites**: Coverage artifacts must exist before running the report: +- `backend/coverage.txt` — generated by `scripts/go-test-coverage.sh` +- `frontend/coverage/lcov.info` — generated by `scripts/frontend-test-coverage.sh` Run one of the following from `/projects/Charon`: @@ -26,11 +36,14 @@ Test: Local Patch Report bash scripts/local-patch-report.sh ``` -Required artifacts: +Required output artifacts: - `test-results/local-patch-report.md` - `test-results/local-patch-report.json` -This preflight is advisory for thresholds during rollout, but artifact generation is required in DoD. +**Action on results**: If patch coverage for any changed file is below 90%, add +tests targeting the uncovered changed lines. Re-run coverage and this report to +verify improvement. Artifact generation is required for DoD regardless of +threshold results. ### PREREQUISITE: Start E2E Environment diff --git a/backend/internal/api/handlers/crowdsec_dashboard_test.go b/backend/internal/api/handlers/crowdsec_dashboard_test.go index ab76f5df..a34bbb19 100644 --- a/backend/internal/api/handlers/crowdsec_dashboard_test.go +++ b/backend/internal/api/handlers/crowdsec_dashboard_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "testing" "time" @@ -484,3 +485,891 @@ func TestExportDecisions_SourceFilter(t *testing.T) { assert.Equal(t, "waf", d.Source) } } + +// --------------------------------------------------------------------------- +// Helper functions unit tests +// --------------------------------------------------------------------------- + +func TestNormalizeRange(t *testing.T) { + t.Parallel() + assert.Equal(t, "24h", normalizeRange("")) + assert.Equal(t, "1h", normalizeRange("1h")) + assert.Equal(t, "7d", normalizeRange("7d")) + assert.Equal(t, "30d", normalizeRange("30d")) +} + +func TestIntervalForRange_AllBranches(t *testing.T) { + t.Parallel() + tests := []struct { + rangeStr string + expected string + }{ + {"1h", "5m"}, + {"6h", "15m"}, + {"24h", "1h"}, + {"", "1h"}, + {"7d", "6h"}, + {"30d", "1d"}, + {"unknown", "1h"}, + } + for _, tc := range tests { + assert.Equal(t, tc.expected, intervalForRange(tc.rangeStr), "intervalForRange(%q)", tc.rangeStr) + } +} + +func TestIntervalToStrftime_AllBranches(t *testing.T) { + t.Parallel() + tests := []struct { + interval string + contains string + }{ + {"5m", "/ 5) * 5"}, + {"15m", "/ 15) * 15"}, + {"1h", "%H:00:00Z"}, + {"6h", "/ 6) * 6"}, + {"1d", "T00:00:00Z"}, + {"unknown", "%H:00:00Z"}, + } + for _, tc := range tests { + result := intervalToStrftime(tc.interval) + assert.Contains(t, result, tc.contains, "intervalToStrftime(%q)", tc.interval) + } +} + +func TestValidInterval_AllBranches(t *testing.T) { + t.Parallel() + for _, v := range []string{"5m", "15m", "1h", "6h", "1d"} { + assert.True(t, validInterval(v), "validInterval(%q)", v) + } + assert.False(t, validInterval("10m")) + assert.False(t, validInterval("")) +} + +// --------------------------------------------------------------------------- +// DashboardSummary edge cases +// --------------------------------------------------------------------------- + +func TestDashboardSummary_EmptyRange(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/summary", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, "24h", body["range"]) +} + +func TestDashboardSummary_7dRange(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/summary?range=7d", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, "7d", body["range"]) + // 7d includes the 48h-old record + total := body["total_decisions"].(float64) + assert.Equal(t, float64(6), total) +} + +func TestDashboardSummary_TrendNegative100(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + db := OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityConfig{}, &models.Setting{})) + + h := &CrowdsecHandler{ + DB: db, + Executor: &fakeExec{}, + CmdExec: &fastCmdExec{}, + BinPath: "/bin/false", + DataDir: t.TempDir(), + dashCache: newDashboardCache(), + } + + now := time.Now().UTC() + // Only seed decisions in the PREVIOUS 1h period (nothing in current) + for i := 0; i < 3; i++ { + require.NoError(t, db.Create(&models.SecurityDecision{ + UUID: uuid.NewString(), Source: "crowdsec", Action: "block", + IP: "192.168.1.1", Scenario: "crowdsecurity/http-probing", + CreatedAt: now.Add(-1*time.Hour - time.Duration(i+1)*time.Minute), + }).Error) + } + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/summary?range=1h", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, -100.0, body["decisions_trend"]) +} + +// --------------------------------------------------------------------------- +// DashboardTimeline edge cases +// --------------------------------------------------------------------------- + +func TestDashboardTimeline_Cached(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + // First call populates cache + w1 := httptest.NewRecorder() + req1 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/timeline?range=24h", http.NoBody) + r.ServeHTTP(w1, req1) + assert.Equal(t, http.StatusOK, w1.Code) + + // Second call hits cache + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/timeline?range=24h", http.NoBody) + r.ServeHTTP(w2, req2) + assert.Equal(t, http.StatusOK, w2.Code) +} + +func TestDashboardTimeline_InvalidRange(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/timeline?range=99z&interval=1h", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestDashboardTimeline_AllRangeIntervals(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + ranges := []struct { + rangeStr string + interval string + }{ + {"1h", "5m"}, + {"6h", "15m"}, + {"7d", "6h"}, + {"30d", "1d"}, + } + + for _, tc := range ranges { + t.Run(tc.rangeStr, func(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, + fmt.Sprintf("/api/v1/admin/crowdsec/dashboard/timeline?range=%s", tc.rangeStr), http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, tc.interval, body["interval"]) + }) + } +} + +// --------------------------------------------------------------------------- +// DashboardTopIPs edge cases +// --------------------------------------------------------------------------- + +func TestDashboardTopIPs_InvalidRange(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/top-ips?range=bad", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestDashboardTopIPs_BadLimit(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/top-ips?limit=abc", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestDashboardTopIPs_NegativeLimit(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/top-ips?limit=-5", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestDashboardTopIPs_Cached(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w1 := httptest.NewRecorder() + req1 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/top-ips?range=24h&limit=10", http.NoBody) + r.ServeHTTP(w1, req1) + assert.Equal(t, http.StatusOK, w1.Code) + + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/top-ips?range=24h&limit=10", http.NoBody) + r.ServeHTTP(w2, req2) + assert.Equal(t, http.StatusOK, w2.Code) +} + +// --------------------------------------------------------------------------- +// DashboardScenarios edge cases +// --------------------------------------------------------------------------- + +func TestDashboardScenarios_InvalidRange(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/scenarios?range=bad", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestDashboardScenarios_Cached(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w1 := httptest.NewRecorder() + req1 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/scenarios?range=24h", http.NoBody) + r.ServeHTTP(w1, req1) + assert.Equal(t, http.StatusOK, w1.Code) + + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/scenarios?range=24h", http.NoBody) + r.ServeHTTP(w2, req2) + assert.Equal(t, http.StatusOK, w2.Code) +} + +// --------------------------------------------------------------------------- +// ListAlerts edge cases +// --------------------------------------------------------------------------- + +func TestListAlerts_BadLimit(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?limit=abc", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestListAlerts_LimitCap(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?limit=999", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestListAlerts_NegativeLimit(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?limit=-1", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestListAlerts_BadOffset(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?offset=abc", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestListAlerts_NegativeOffset(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?offset=-5", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestListAlerts_Cached(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w1 := httptest.NewRecorder() + req1 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?range=24h", http.NoBody) + r.ServeHTTP(w1, req1) + assert.Equal(t, http.StatusOK, w1.Code) + + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?range=24h", http.NoBody) + r.ServeHTTP(w2, req2) + assert.Equal(t, http.StatusOK, w2.Code) +} + +func TestListAlerts_ScenarioFilter(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?scenario=crowdsecurity/http-probing", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Contains(t, body, "source") +} + +// --------------------------------------------------------------------------- +// ExportDecisions edge cases +// --------------------------------------------------------------------------- + +func TestExportDecisions_InvalidRange(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions/export?range=bad", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestExportDecisions_EmptyRange(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions/export?format=json", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestExportDecisions_CSVWithSourceFilter(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions/export?format=csv&source=crowdsec&range=7d", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "text/csv") + body := w.Body.String() + assert.Contains(t, body, "uuid,ip,action,source,scenario") + // Verify all rows are crowdsec source + assert.NotContains(t, body, ",waf,") +} + +func TestExportDecisions_AllSources(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions/export?format=json&source=all&range=7d", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var decisions []models.SecurityDecision + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &decisions)) + // Should include both crowdsec and waf sources + assert.GreaterOrEqual(t, len(decisions), 2) +} + +// --------------------------------------------------------------------------- +// LAPI integration paths via httptest server +// --------------------------------------------------------------------------- + +func TestDashboardSummary_ActiveDecisions_LAPIReachable(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"1"},{"id":"2"},{"id":"3"}]`)) + })) + t.Cleanup(server.Close) + + original := validateCrowdsecLAPIBaseURLFunc + validateCrowdsecLAPIBaseURLFunc = func(raw string) (*url.URL, error) { + return url.Parse(raw) + } + t.Cleanup(func() { validateCrowdsecLAPIBaseURLFunc = original }) + + db := OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityConfig{}, &models.Setting{})) + + require.NoError(t, db.Create(&models.SecurityConfig{ + UUID: "default", + Name: "default", + CrowdSecAPIURL: server.URL, + }).Error) + + h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir()) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + now := time.Now().UTC() + require.NoError(t, db.Create(&models.SecurityDecision{ + UUID: uuid.NewString(), Source: "crowdsec", Action: "block", + IP: "10.0.0.1", Scenario: "test", CreatedAt: now.Add(-30 * time.Minute), + }).Error) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/summary?range=1h", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, float64(3), body["active_decisions"]) +} + +func TestDashboardSummary_ActiveDecisions_LAPIBadStatus(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + + original := validateCrowdsecLAPIBaseURLFunc + validateCrowdsecLAPIBaseURLFunc = func(raw string) (*url.URL, error) { + return url.Parse(raw) + } + t.Cleanup(func() { validateCrowdsecLAPIBaseURLFunc = original }) + + db := OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityConfig{}, &models.Setting{})) + require.NoError(t, db.Create(&models.SecurityConfig{ + UUID: "default", Name: "default", CrowdSecAPIURL: server.URL, + }).Error) + + h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir()) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/summary?range=1h", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, float64(-1), body["active_decisions"]) +} + +func TestDashboardSummary_ActiveDecisions_LAPIBadJSON(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`not-json`)) + })) + t.Cleanup(server.Close) + + original := validateCrowdsecLAPIBaseURLFunc + validateCrowdsecLAPIBaseURLFunc = func(raw string) (*url.URL, error) { + return url.Parse(raw) + } + t.Cleanup(func() { validateCrowdsecLAPIBaseURLFunc = original }) + + db := OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityConfig{}, &models.Setting{})) + require.NoError(t, db.Create(&models.SecurityConfig{ + UUID: "default", Name: "default", CrowdSecAPIURL: server.URL, + }).Error) + + h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir()) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/summary?range=1h", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, float64(-1), body["active_decisions"]) +} + +func TestListAlerts_LAPISuccess(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"a1"},{"id":"a2"},{"id":"a3"},{"id":"a4"},{"id":"a5"}]`)) + })) + t.Cleanup(server.Close) + + original := validateCrowdsecLAPIBaseURLFunc + validateCrowdsecLAPIBaseURLFunc = func(raw string) (*url.URL, error) { + return url.Parse(raw) + } + t.Cleanup(func() { validateCrowdsecLAPIBaseURLFunc = original }) + + db := OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityConfig{}, &models.Setting{})) + require.NoError(t, db.Create(&models.SecurityConfig{ + UUID: "default", Name: "default", CrowdSecAPIURL: server.URL, + }).Error) + + h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir()) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?range=24h", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, "lapi", body["source"]) + assert.Equal(t, float64(5), body["total"]) +} + +func TestListAlerts_LAPISuccessWithOffset(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"a1"},{"id":"a2"},{"id":"a3"},{"id":"a4"},{"id":"a5"}]`)) + })) + t.Cleanup(server.Close) + + original := validateCrowdsecLAPIBaseURLFunc + validateCrowdsecLAPIBaseURLFunc = func(raw string) (*url.URL, error) { + return url.Parse(raw) + } + t.Cleanup(func() { validateCrowdsecLAPIBaseURLFunc = original }) + + db := OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityConfig{}, &models.Setting{})) + require.NoError(t, db.Create(&models.SecurityConfig{ + UUID: "default", Name: "default", CrowdSecAPIURL: server.URL, + }).Error) + + h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir()) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?range=24h&offset=3&limit=10", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, "lapi", body["source"]) + assert.Equal(t, float64(5), body["total"]) + alerts := body["alerts"].([]interface{}) + assert.Equal(t, 2, len(alerts)) +} + +func TestListAlerts_LAPISuccessWithLargeOffset(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"a1"},{"id":"a2"}]`)) + })) + t.Cleanup(server.Close) + + original := validateCrowdsecLAPIBaseURLFunc + validateCrowdsecLAPIBaseURLFunc = func(raw string) (*url.URL, error) { + return url.Parse(raw) + } + t.Cleanup(func() { validateCrowdsecLAPIBaseURLFunc = original }) + + db := OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityConfig{}, &models.Setting{})) + require.NoError(t, db.Create(&models.SecurityConfig{ + UUID: "default", Name: "default", CrowdSecAPIURL: server.URL, + }).Error) + + h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir()) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?range=24h&offset=100", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, "lapi", body["source"]) + // offset >= len(rawAlerts) returns nil, which marshals as JSON null + alerts := body["alerts"] + if alerts != nil { + assert.Equal(t, 0, len(alerts.([]interface{}))) + } +} + +func TestListAlerts_LAPISuccessWithLimitSlicing(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"a1"},{"id":"a2"},{"id":"a3"},{"id":"a4"},{"id":"a5"}]`)) + })) + t.Cleanup(server.Close) + + original := validateCrowdsecLAPIBaseURLFunc + validateCrowdsecLAPIBaseURLFunc = func(raw string) (*url.URL, error) { + return url.Parse(raw) + } + t.Cleanup(func() { validateCrowdsecLAPIBaseURLFunc = original }) + + db := OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityConfig{}, &models.Setting{})) + require.NoError(t, db.Create(&models.SecurityConfig{ + UUID: "default", Name: "default", CrowdSecAPIURL: server.URL, + }).Error) + + h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir()) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?range=24h&limit=2", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, "lapi", body["source"]) + assert.Equal(t, float64(5), body["total"]) + alerts := body["alerts"].([]interface{}) + assert.Equal(t, 2, len(alerts)) +} + +func TestListAlerts_LAPIBadJSON(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`not-json`)) + })) + t.Cleanup(server.Close) + + original := validateCrowdsecLAPIBaseURLFunc + validateCrowdsecLAPIBaseURLFunc = func(raw string) (*url.URL, error) { + return url.Parse(raw) + } + t.Cleanup(func() { validateCrowdsecLAPIBaseURLFunc = original }) + + db := OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityConfig{}, &models.Setting{})) + require.NoError(t, db.Create(&models.SecurityConfig{ + UUID: "default", Name: "default", CrowdSecAPIURL: server.URL, + }).Error) + + h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir()) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?range=24h", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + // Falls back to cscli + assert.Equal(t, "cscli", body["source"]) +} + +func TestListAlerts_LAPIBadStatus(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + t.Cleanup(server.Close) + + original := validateCrowdsecLAPIBaseURLFunc + validateCrowdsecLAPIBaseURLFunc = func(raw string) (*url.URL, error) { + return url.Parse(raw) + } + t.Cleanup(func() { validateCrowdsecLAPIBaseURLFunc = original }) + + db := OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityConfig{}, &models.Setting{})) + require.NoError(t, db.Create(&models.SecurityConfig{ + UUID: "default", Name: "default", CrowdSecAPIURL: server.URL, + }).Error) + + h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir()) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?range=24h", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, "cscli", body["source"]) +} + +func TestListAlerts_LAPIWithScenarioFilter(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + var capturedQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"a1"}]`)) + })) + t.Cleanup(server.Close) + + original := validateCrowdsecLAPIBaseURLFunc + validateCrowdsecLAPIBaseURLFunc = func(raw string) (*url.URL, error) { + return url.Parse(raw) + } + t.Cleanup(func() { validateCrowdsecLAPIBaseURLFunc = original }) + + db := OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityConfig{}, &models.Setting{})) + require.NoError(t, db.Create(&models.SecurityConfig{ + UUID: "default", Name: "default", CrowdSecAPIURL: server.URL, + }).Error) + + h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir()) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?range=24h&scenario=crowdsecurity/http-probing", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, capturedQuery, "scenario=crowdsecurity") +} + +func TestFetchAlertsCscli_ErrorExec(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + db := OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityConfig{}, &models.Setting{})) + + h := &CrowdsecHandler{ + DB: db, + Executor: &fakeExec{}, + CmdExec: &mockCmdExecutor{output: nil, err: fmt.Errorf("cscli not found")}, + BinPath: "/bin/false", + DataDir: t.TempDir(), + dashCache: newDashboardCache(), + } + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?range=24h", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, "cscli", body["source"]) + assert.Equal(t, float64(0), body["total"]) +} + +func TestFetchAlertsCscli_ValidJSON(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + db := OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityConfig{}, &models.Setting{})) + + h := &CrowdsecHandler{ + DB: db, + Executor: &fakeExec{}, + CmdExec: &mockCmdExecutor{output: []byte(`[{"id":"1"},{"id":"2"}]`), err: nil}, + BinPath: "/bin/false", + DataDir: t.TempDir(), + dashCache: newDashboardCache(), + } + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?range=24h", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, "cscli", body["source"]) + assert.Equal(t, float64(2), body["total"]) +}