package handlers import ( "archive/tar" "bytes" "compress/gzip" "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/Wikid82/charon/backend/internal/crowdsec" "github.com/Wikid82/charon/backend/internal/models" ) type presetRoundTripper func(*http.Request) (*http.Response, error) func (p presetRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { return p(req) } func makePresetTar(t *testing.T, files map[string]string) []byte { t.Helper() buf := &bytes.Buffer{} gw := gzip.NewWriter(buf) tw := tar.NewWriter(gw) for name, content := range files { hdr := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(content))} require.NoError(t, tw.WriteHeader(hdr)) _, err := tw.Write([]byte(content)) require.NoError(t, err) } require.NoError(t, tw.Close()) require.NoError(t, gw.Close()) return buf.Bytes() } func TestListPresetsIncludesCacheAndIndex(t *testing.T) { gin.SetMode(gin.TestMode) cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour) require.NoError(t, err) _, err = cache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "preview", []byte("archive")) require.NoError(t, err) hub := crowdsec.NewHubService(nil, cache, t.TempDir()) hub.HubBaseURL = "http://example.com" hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) { if req.URL.String() == "http://example.com/api/index.json" { return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","type":"collection"}]}`)), Header: make(http.Header)}, nil } return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil })} db := OpenTestDB(t) handler := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", t.TempDir()) handler.Hub = hub r := gin.New() g := r.Group("/api/v1") handler.RegisterRoutes(g) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody) r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var payload struct { Presets []struct { Slug string `json:"slug"` Cached bool `json:"cached"` } `json:"presets"` } require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) found := false for _, p := range payload.Presets { if p.Slug == "crowdsecurity/demo" { found = true require.True(t, p.Cached) } } require.True(t, found) } func TestPullPresetHandlerSuccess(t *testing.T) { gin.SetMode(gin.TestMode) cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour) require.NoError(t, err) dataDir := filepath.Join(t.TempDir(), "crowdsec") archive := makePresetTar(t, map[string]string{"config.yaml": "key: value"}) hub := crowdsec.NewHubService(nil, cache, dataDir) hub.HubBaseURL = "http://example.com" hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) { switch req.URL.String() { case "http://example.com/api/index.json": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","etag":"e1","download_url":"http://example.com/demo.tgz","preview_url":"http://example.com/demo.yaml"}]}`)), Header: make(http.Header)}, nil case "http://example.com/demo.yaml": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("preview")), Header: make(http.Header)}, nil case "http://example.com/demo.tgz": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archive)), Header: make(http.Header)}, nil default: return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil } })} handler := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", dataDir) handler.Hub = hub r := gin.New() g := r.Group("/api/v1") handler.RegisterRoutes(g) body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/demo"}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) require.Contains(t, w.Body.String(), "cache_key") require.Contains(t, w.Body.String(), "preview") } func TestApplyPresetHandlerAudits(t *testing.T) { gin.SetMode(gin.TestMode) db := OpenTestDB(t) require.NoError(t, db.AutoMigrate(&models.CrowdsecPresetEvent{})) cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour) require.NoError(t, err) dataDir := filepath.Join(t.TempDir(), "crowdsec") archive := makePresetTar(t, map[string]string{"conf.yaml": "v: 1"}) _, err = cache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "preview", archive) require.NoError(t, err) hub := crowdsec.NewHubService(nil, cache, dataDir) handler := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir) handler.Hub = hub r := gin.New() g := r.Group("/api/v1") handler.RegisterRoutes(g) body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/demo"}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var events []models.CrowdsecPresetEvent require.NoError(t, db.Find(&events).Error) require.Len(t, events, 1) require.Equal(t, "applied", events[0].Status) // Failure path badCache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour) require.NoError(t, err) badArchive := makePresetTar(t, map[string]string{"../bad.txt": "x"}) _, err = badCache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "preview", badArchive) require.NoError(t, err) badHub := crowdsec.NewHubService(nil, badCache, filepath.Join(t.TempDir(), "crowdsec2")) handler.Hub = badHub w2 := httptest.NewRecorder() req2 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(body)) req2.Header.Set("Content-Type", "application/json") r.ServeHTTP(w2, req2) require.Equal(t, http.StatusInternalServerError, w2.Code) require.NoError(t, db.Find(&events).Error) require.Len(t, events, 2) require.Equal(t, "failed", events[1].Status) } func TestPullPresetHandlerHubError(t *testing.T) { gin.SetMode(gin.TestMode) cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour) require.NoError(t, err) hub := crowdsec.NewHubService(nil, cache, t.TempDir()) hub.HubBaseURL = "http://example.com" hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) { return &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil })} h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) h.Hub = hub r := gin.New() g := r.Group("/api/v1") h.RegisterRoutes(g) body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/missing"}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) require.Equal(t, http.StatusBadGateway, w.Code) } func TestPullPresetHandlerTimeout(t *testing.T) { gin.SetMode(gin.TestMode) cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour) require.NoError(t, err) hub := crowdsec.NewHubService(nil, cache, t.TempDir()) hub.HubBaseURL = "http://example.com" hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) { return nil, context.DeadlineExceeded })} h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) h.Hub = hub r := gin.New() g := r.Group("/api/v1") h.RegisterRoutes(g) body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/demo"}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) require.Equal(t, http.StatusGatewayTimeout, w.Code) require.Contains(t, w.Body.String(), "deadline") } func TestGetCachedPresetNotFound(t *testing.T) { gin.SetMode(gin.TestMode) cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour) require.NoError(t, err) h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) h.Hub = crowdsec.NewHubService(nil, cache, 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/presets/cache/unknown", http.NoBody) r.ServeHTTP(w, req) require.Equal(t, http.StatusNotFound, w.Code) } func TestGetCachedPresetServiceUnavailable(t *testing.T) { gin.SetMode(gin.TestMode) h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) h.Hub = &crowdsec.HubService{} r := gin.New() g := r.Group("/api/v1") h.RegisterRoutes(g) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/demo", http.NoBody) r.ServeHTTP(w, req) require.Equal(t, http.StatusServiceUnavailable, w.Code) } func TestApplyPresetHandlerBackupFailure(t *testing.T) { gin.SetMode(gin.TestMode) db := OpenTestDB(t) require.NoError(t, db.AutoMigrate(&models.CrowdsecPresetEvent{})) baseDir := t.TempDir() dataDir := filepath.Join(baseDir, "crowdsec") require.NoError(t, os.MkdirAll(dataDir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(dataDir, "keep.txt"), []byte("before"), 0o644)) hub := crowdsec.NewHubService(nil, nil, dataDir) h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir) h.Hub = hub r := gin.New() g := r.Group("/api/v1") h.RegisterRoutes(g) body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/demo"}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) require.Equal(t, http.StatusInternalServerError, w.Code) // Verify response includes backup path for traceability var response map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) _, hasBackup := response["backup"] require.True(t, hasBackup, "Response should include 'backup' field for diagnostics") // Verify error message is present errorMsg, ok := response["error"].(string) require.True(t, ok, "error field should be a string") require.Contains(t, errorMsg, "cache", "error should indicate cache is unavailable") var events []models.CrowdsecPresetEvent require.NoError(t, db.Find(&events).Error) require.Len(t, events, 1) require.Equal(t, "failed", events[0].Status) require.NotEmpty(t, events[0].BackupPath) content, readErr := os.ReadFile(filepath.Join(dataDir, "keep.txt")) require.NoError(t, readErr) require.Equal(t, "before", string(content)) } func TestListPresetsMergesCuratedAndHub(t *testing.T) { gin.SetMode(gin.TestMode) hub := crowdsec.NewHubService(nil, nil, t.TempDir()) hub.HubBaseURL = "http://hub.example" hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) { if req.URL.String() == "http://hub.example/api/index.json" { return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"items":[{"name":"crowdsecurity/custom","title":"Custom","description":"d","type":"collection"}]}`)), Header: make(http.Header)}, nil } return nil, errors.New("unexpected request") })} h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) h.Hub = hub r := gin.New() g := r.Group("/api/v1") h.RegisterRoutes(g) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody) r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var payload struct { Presets []struct { Slug string `json:"slug"` Source string `json:"source"` Tags []string `json:"tags"` } `json:"presets"` } require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) foundCurated := false foundHub := false for _, p := range payload.Presets { if p.Slug == "honeypot-friendly-defaults" { foundCurated = true } if p.Slug == "crowdsecurity/custom" { foundHub = true require.Equal(t, []string{"collection"}, p.Tags) } } require.True(t, foundCurated) require.True(t, foundHub) } func TestGetCachedPresetSuccess(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("FEATURE_CERBERUS_ENABLED", "true") cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour) require.NoError(t, err) const slug = "demo" _, err = cache.Store(context.Background(), slug, "etag123", "hub", "preview-body", []byte("tgz")) require.NoError(t, err) h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) h.Hub = crowdsec.NewHubService(nil, cache, t.TempDir()) require.True(t, h.isCerberusEnabled()) preview, err := h.Hub.Cache.LoadPreview(context.Background(), slug) require.NoError(t, err) require.Equal(t, "preview-body", preview) r := gin.New() g := r.Group("/api/v1") h.RegisterRoutes(g) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/"+slug, http.NoBody) r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) require.Contains(t, w.Body.String(), "preview-body") require.Contains(t, w.Body.String(), "etag123") } func TestGetCachedPresetSlugRequired(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("FEATURE_CERBERUS_ENABLED", "true") cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour) require.NoError(t, err) h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) h.Hub = crowdsec.NewHubService(nil, cache, 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/presets/cache/%20", http.NoBody) r.ServeHTTP(w, req) require.Equal(t, http.StatusBadRequest, w.Code) require.Contains(t, w.Body.String(), "slug required") } func TestGetCachedPresetPreviewError(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("FEATURE_CERBERUS_ENABLED", "true") cacheDir := t.TempDir() cache, err := crowdsec.NewHubCache(cacheDir, time.Hour) require.NoError(t, err) const slug = "broken" meta, err := cache.Store(context.Background(), slug, "etag999", "hub", "will-remove", []byte("tgz")) require.NoError(t, err) // Remove preview to force LoadPreview read error. require.NoError(t, os.Remove(meta.PreviewPath)) h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) h.Hub = crowdsec.NewHubService(nil, cache, 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/presets/cache/"+slug, http.NoBody) r.ServeHTTP(w, req) require.Equal(t, http.StatusInternalServerError, w.Code) require.Contains(t, w.Body.String(), "no such file") } func TestPullCuratedPresetSkipsHub(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("FEATURE_CERBERUS_ENABLED", "true") // Setup handler with a hub service that would fail if called cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour) require.NoError(t, err) // We don't set HTTPClient, so any network call would panic or fail if not handled hub := crowdsec.NewHubService(nil, cache, t.TempDir()) h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) h.Hub = hub r := gin.New() g := r.Group("/api/v1") h.RegisterRoutes(g) // Use a known curated preset that doesn't require hub slug := "honeypot-friendly-defaults" body, _ := json.Marshal(map[string]string{"slug": slug}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var resp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.Equal(t, "pulled", resp["status"]) require.Equal(t, slug, resp["slug"]) require.Equal(t, "charon-curated", resp["source"]) require.Contains(t, resp["preview"], "Curated preset") } func TestApplyCuratedPresetSkipsHub(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("FEATURE_CERBERUS_ENABLED", "true") db := OpenTestDB(t) require.NoError(t, db.AutoMigrate(&models.CrowdsecPresetEvent{})) // Setup handler with a hub service that would fail if called // We intentionally don't put anything in cache to prove we don't check it cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour) require.NoError(t, err) hub := crowdsec.NewHubService(nil, cache, t.TempDir()) h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", t.TempDir()) h.Hub = hub r := gin.New() g := r.Group("/api/v1") h.RegisterRoutes(g) // Use a known curated preset that doesn't require hub slug := "honeypot-friendly-defaults" body, _ := json.Marshal(map[string]string{"slug": slug}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var resp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.Equal(t, "applied", resp["status"]) require.Equal(t, slug, resp["slug"]) // Verify event was logged var events []models.CrowdsecPresetEvent require.NoError(t, db.Find(&events).Error) require.Len(t, events, 1) require.Equal(t, slug, events[0].Slug) require.Equal(t, "applied", events[0].Status) }