chore: enforce local patch coverage as a blocking DoD gate

- Added ~40 backend tests covering uncovered branches in CrowdSec
  dashboard handlers (error paths, validation, export edge cases)
- Patch coverage improved from 81.5% to 98.3%, exceeding 90% threshold
- Fixed DoD ordering: coverage tests now run before the patch report
  (the report requires coverage artifacts as input)
- Rewrote the local patch coverage DoD step in both the Management agent
  and testing instructions to clarify purpose, prerequisites, required
  action on findings, and blocking gate semantics
- Eliminated ambiguous "advisory" language that allowed agents to skip
  acting on uncovered lines
This commit is contained in:
GitHub Actions
2026-03-25 19:33:19 +00:00
parent 1fe69c2a15
commit 3336aae2a0
3 changed files with 917 additions and 11 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"])
}