diff --git a/backend/integration/coraza_integration_test.go b/backend/integration/coraza_integration_test.go index 30d96d3c..cb22df8a 100644 --- a/backend/integration/coraza_integration_test.go +++ b/backend/integration/coraza_integration_test.go @@ -4,31 +4,31 @@ package integration import ( - "context" - "os/exec" - "strings" - "testing" - "time" + "context" + "os/exec" + "strings" + "testing" + "time" ) // TestCorazaIntegration runs the scripts/coraza_integration.sh and ensures it completes successfully. // This test requires Docker and docker compose access locally; it is gated behind build tag `integration`. func TestCorazaIntegration(t *testing.T) { - t.Parallel() + t.Parallel() - // Ensure the script exists - cmd := exec.CommandContext(context.Background(), "bash", "./scripts/coraza_integration.sh") - // set a timeout in case something hangs - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) - defer cancel() - cmd = exec.CommandContext(ctx, "bash", "./scripts/coraza_integration.sh") + // Ensure the script exists + cmd := exec.CommandContext(context.Background(), "bash", "./scripts/coraza_integration.sh") + // set a timeout in case something hangs + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + cmd = exec.CommandContext(ctx, "bash", "./scripts/coraza_integration.sh") - out, err := cmd.CombinedOutput() - t.Logf("coraza_integration script output:\n%s", string(out)) - if err != nil { - t.Fatalf("coraza integration failed: %v", err) - } - if !strings.Contains(string(out), "Coraza WAF blocked payload as expected") { - t.Fatalf("unexpected script output, expected blocking assertion not found") - } + out, err := cmd.CombinedOutput() + t.Logf("coraza_integration script output:\n%s", string(out)) + if err != nil { + t.Fatalf("coraza integration failed: %v", err) + } + if !strings.Contains(string(out), "Coraza WAF blocked payload as expected") { + t.Fatalf("unexpected script output, expected blocking assertion not found") + } } diff --git a/backend/integration/crowdsec_integration_test.go b/backend/integration/crowdsec_integration_test.go index a0a1351a..d6ddd29a 100644 --- a/backend/integration/crowdsec_integration_test.go +++ b/backend/integration/crowdsec_integration_test.go @@ -13,22 +13,22 @@ import ( // TestCrowdsecIntegration runs scripts/crowdsec_integration.sh and ensures it completes successfully. func TestCrowdsecIntegration(t *testing.T) { - t.Parallel() + t.Parallel() - cmd := exec.CommandContext(context.Background(), "bash", "./scripts/crowdsec_integration.sh") - // Ensure script runs from repo root so relative paths in scripts work reliably - cmd.Dir = "../../" - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) - defer cancel() - cmd = exec.CommandContext(ctx, "bash", "./scripts/crowdsec_integration.sh") - cmd.Dir = "../../" + cmd := exec.CommandContext(context.Background(), "bash", "./scripts/crowdsec_integration.sh") + // Ensure script runs from repo root so relative paths in scripts work reliably + cmd.Dir = "../../" + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + cmd = exec.CommandContext(ctx, "bash", "./scripts/crowdsec_integration.sh") + cmd.Dir = "../../" - out, err := cmd.CombinedOutput() - t.Logf("crowdsec_integration script output:\n%s", string(out)) - if err != nil { - t.Fatalf("crowdsec integration failed: %v", err) - } - if !strings.Contains(string(out), "Apply response: ") { - t.Fatalf("unexpected script output, expected Apply response in output") - } + out, err := cmd.CombinedOutput() + t.Logf("crowdsec_integration script output:\n%s", string(out)) + if err != nil { + t.Fatalf("crowdsec integration failed: %v", err) + } + if !strings.Contains(string(out), "Apply response: ") { + t.Fatalf("unexpected script output, expected Apply response in output") + } } diff --git a/backend/internal/api/handlers/crowdsec_handler.go b/backend/internal/api/handlers/crowdsec_handler.go index f4a5332c..a7f89282 100644 --- a/backend/internal/api/handlers/crowdsec_handler.go +++ b/backend/internal/api/handlers/crowdsec_handler.go @@ -55,6 +55,19 @@ type CrowdsecHandler struct { Hub *crowdsec.HubService } +func ttlRemainingSeconds(now time.Time, retrievedAt time.Time, ttl time.Duration) *int64 { + if retrievedAt.IsZero() || ttl <= 0 { + return nil + } + remaining := retrievedAt.Add(ttl).Sub(now) + if remaining < 0 { + var zero int64 + return &zero + } + secs := int64(remaining.Seconds()) + return &secs +} + func mapCrowdsecStatus(err error, defaultCode int) int { if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { return http.StatusGatewayTimeout @@ -381,11 +394,12 @@ func (h *CrowdsecHandler) ListPresets(c *gin.Context) { type presetInfo struct { crowdsec.Preset - Available bool `json:"available"` - Cached bool `json:"cached"` - CacheKey string `json:"cache_key,omitempty"` - Etag string `json:"etag,omitempty"` - RetrievedAt *time.Time `json:"retrieved_at,omitempty"` + Available bool `json:"available"` + Cached bool `json:"cached"` + CacheKey string `json:"cache_key,omitempty"` + Etag string `json:"etag,omitempty"` + RetrievedAt *time.Time `json:"retrieved_at,omitempty"` + TTLRemainingSeconds *int64 `json:"ttl_remaining_seconds,omitempty"` } result := map[string]*presetInfo{} @@ -425,6 +439,8 @@ func (h *CrowdsecHandler) ListPresets(c *gin.Context) { if h.Hub != nil && h.Hub.Cache != nil { ctx := c.Request.Context() if cached, err := h.Hub.Cache.List(ctx); err == nil { + cacheTTL := h.Hub.Cache.TTL() + now := time.Now().UTC() for _, entry := range cached { if _, ok := result[entry.Slug]; !ok { result[entry.Slug] = &presetInfo{Preset: crowdsec.Preset{Slug: entry.Slug, Title: entry.Slug, Summary: "cached preset", Source: "hub", RequiresHub: true}} @@ -436,6 +452,7 @@ func (h *CrowdsecHandler) ListPresets(c *gin.Context) { val := entry.RetrievedAt result[entry.Slug].RetrievedAt = &val } + result[entry.Slug].TTLRemainingSeconds = ttlRemainingSeconds(now, entry.RetrievedAt, cacheTTL) } } else { logger.Log().WithError(err).Warn("crowdsec hub cache list failed") @@ -581,7 +598,9 @@ func (h *CrowdsecHandler) ApplyPreset(c *gin.Context) { // Build detailed error response errorMsg := err.Error() // Add actionable guidance based on error type - if strings.Contains(errorMsg, "cscli unavailable") && strings.Contains(errorMsg, "no cached preset") { + if errors.Is(err, crowdsec.ErrCacheMiss) || strings.Contains(errorMsg, "cache miss") { + errorMsg = "Preset cache missing or expired. Pull the preset again, then retry apply." + } else if strings.Contains(errorMsg, "cscli unavailable") && strings.Contains(errorMsg, "no cached preset") { errorMsg = "CrowdSec preset not cached. Pull the preset first by clicking 'Pull Preview', then try applying again." } errorResponse := gin.H{"error": errorMsg} @@ -642,8 +661,20 @@ func (h *CrowdsecHandler) GetCachedPreset(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - meta, _ := h.Hub.Cache.Load(ctx, slug) - c.JSON(http.StatusOK, gin.H{"preview": preview, "cache_key": meta.CacheKey, "etag": meta.Etag}) + meta, metaErr := h.Hub.Cache.Load(ctx, slug) + if metaErr != nil && !errors.Is(metaErr, crowdsec.ErrCacheMiss) && !errors.Is(metaErr, crowdsec.ErrCacheExpired) { + c.JSON(http.StatusInternalServerError, gin.H{"error": metaErr.Error()}) + return + } + cacheTTL := h.Hub.Cache.TTL() + now := time.Now().UTC() + c.JSON(http.StatusOK, gin.H{ + "preview": preview, + "cache_key": meta.CacheKey, + "etag": meta.Etag, + "retrieved_at": meta.RetrievedAt, + "ttl_remaining_seconds": ttlRemainingSeconds(now, meta.RetrievedAt, cacheTTL), + }) } // CrowdSecDecision represents a ban decision from CrowdSec diff --git a/backend/internal/api/handlers/crowdsec_presets_handler_test.go b/backend/internal/api/handlers/crowdsec_presets_handler_test.go index 90159729..96c92972 100644 --- a/backend/internal/api/handlers/crowdsec_presets_handler_test.go +++ b/backend/internal/api/handlers/crowdsec_presets_handler_test.go @@ -302,23 +302,22 @@ func TestApplyPresetHandlerBackupFailure(t *testing.T) { require.Equal(t, http.StatusInternalServerError, w.Code) - // Verify response doesn't include backup field when no backup was created + // Verify response includes backup path for traceability var response map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) _, hasBackup := response["backup"] - require.False(t, hasBackup, "Response should not include 'backup' field when no backup was created") + require.True(t, hasBackup, "Response should include 'backup' field for diagnostics") - // Verify improved error message guides user to pull preset first + // Verify error message is present errorMsg, ok := response["error"].(string) require.True(t, ok, "error field should be a string") - require.Contains(t, errorMsg, "Pull the preset first", "error should guide user to pull preset") - require.Contains(t, errorMsg, "not cached", "error should indicate preset is not cached") + 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.Empty(t, events[0].BackupPath) + require.NotEmpty(t, events[0].BackupPath) content, readErr := os.ReadFile(filepath.Join(dataDir, "keep.txt")) require.NoError(t, readErr) diff --git a/backend/internal/api/handlers/crowdsec_pull_apply_integration_test.go b/backend/internal/api/handlers/crowdsec_pull_apply_integration_test.go index b870147f..c059a9de 100644 --- a/backend/internal/api/handlers/crowdsec_pull_apply_integration_test.go +++ b/backend/internal/api/handlers/crowdsec_pull_apply_integration_test.go @@ -9,6 +9,8 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "time" @@ -119,6 +121,10 @@ func TestApplyWithoutPullReturnsProperError(t *testing.T) { // Empty cache, no cscli hub := crowdsec.NewHubService(nil, cache, dataDir) + hub.HubBaseURL = "http://test.hub" + hub.HTTPClient = &http.Client{Transport: testRoundTripper(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil + })} db := OpenTestDB(t) handler := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir) @@ -143,11 +149,58 @@ func TestApplyWithoutPullReturnsProperError(t *testing.T) { require.NoError(t, err) errorMsg := errorResult["error"].(string) - require.Contains(t, errorMsg, "not cached", "Error should mention preset not cached") - require.Contains(t, errorMsg, "Pull", "Error should guide user to pull first") + require.Contains(t, errorMsg, "Preset cache missing", "Error should mention preset not cached") + require.Contains(t, errorMsg, "Pull the preset", "Error should guide user to pull first") t.Log("Proper error message returned:", errorMsg) } +func TestApplyRollbackWhenCacheMissingAndRepullFails(t *testing.T) { + gin.SetMode(gin.TestMode) + + cacheDir := t.TempDir() + dataRoot := t.TempDir() + dataDir := filepath.Join(dataRoot, "crowdsec") + require.NoError(t, os.MkdirAll(dataDir, 0o755)) + originalFile := filepath.Join(dataDir, "config.yaml") + require.NoError(t, os.WriteFile(originalFile, []byte("original"), 0o644)) + + cache, err := crowdsec.NewHubCache(cacheDir, time.Hour) + require.NoError(t, err) + + hub := crowdsec.NewHubService(nil, cache, dataDir) + hub.HubBaseURL = "http://test.hub" + hub.HTTPClient = &http.Client{Transport: testRoundTripper(func(req *http.Request) (*http.Response, error) { + // Force repull failure + return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil + })} + + db := OpenTestDB(t) + handler := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir) + handler.Hub = hub + + r := gin.New() + g := r.Group("/api/v1") + handler.RegisterRoutes(g) + + applyPayload, _ := json.Marshal(map[string]string{"slug": "missing/preset"}) + applyReq := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(applyPayload)) + applyReq.Header.Set("Content-Type", "application/json") + applyResp := httptest.NewRecorder() + r.ServeHTTP(applyResp, applyReq) + + require.Equal(t, http.StatusInternalServerError, applyResp.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(applyResp.Body.Bytes(), &body)) + require.NotEmpty(t, body["backup"], "backup path should be returned for rollback traceability") + require.Contains(t, body["error"], "Preset cache missing", "error should guide user to repull") + + // Original file should remain after rollback + data, readErr := os.ReadFile(originalFile) + require.NoError(t, readErr) + require.Equal(t, "original", string(data)) +} + func makePresetTarGz(t *testing.T, files map[string]string) []byte { t.Helper() buf := &bytes.Buffer{} diff --git a/backend/internal/cerberus/cerberus.go b/backend/internal/cerberus/cerberus.go index 4ac5bca3..42477bcb 100644 --- a/backend/internal/cerberus/cerberus.go +++ b/backend/internal/cerberus/cerberus.go @@ -19,10 +19,10 @@ import ( // Cerberus provides a lightweight facade for security checks (WAF, CrowdSec, ACL). type Cerberus struct { - cfg config.SecurityConfig - db *gorm.DB - accessSvc *services.AccessListService - securityNotifySvc *services.SecurityNotificationService + cfg config.SecurityConfig + db *gorm.DB + accessSvc *services.AccessListService + securityNotifySvc *services.SecurityNotificationService } // New creates a new Cerberus instance diff --git a/backend/internal/crowdsec/device_busy_test.go b/backend/internal/crowdsec/device_busy_test.go index b6877149..ed2e8211 100644 --- a/backend/internal/crowdsec/device_busy_test.go +++ b/backend/internal/crowdsec/device_busy_test.go @@ -86,7 +86,7 @@ func TestBackupPathOnlySetAfterSuccessfulBackup(t *testing.T) { // Try to apply a preset that doesn't exist in cache (no cscli available) res, err := svc.Apply(context.Background(), "nonexistent/preset") require.Error(t, err) - require.Empty(t, res.BackupPath, "BackupPath should NOT be set when backup never attempted") + require.NotEmpty(t, res.BackupPath, "BackupPath should be set when backup attempt is performed for rollback") }) t.Run("backup path set only after successful backup", func(t *testing.T) { diff --git a/backend/internal/crowdsec/hub_cache.go b/backend/internal/crowdsec/hub_cache.go index 4c7ee48a..c6d30200 100644 --- a/backend/internal/crowdsec/hub_cache.go +++ b/backend/internal/crowdsec/hub_cache.go @@ -52,6 +52,11 @@ func NewHubCache(baseDir string, ttl time.Duration) (*HubCache, error) { return &HubCache{baseDir: baseDir, ttl: ttl, nowFn: time.Now}, nil } +// TTL returns the configured time-to-live for cached entries. +func (c *HubCache) TTL() time.Duration { + return c.ttl +} + // Store writes the bundle archive and preview to disk and returns the cache metadata. func (c *HubCache) Store(ctx context.Context, slug, etag, source, preview string, archive []byte) (CachedPreset, error) { if err := ctx.Err(); err != nil { diff --git a/backend/internal/crowdsec/hub_pull_apply_test.go b/backend/internal/crowdsec/hub_pull_apply_test.go index 410dbfb3..68c7f15b 100644 --- a/backend/internal/crowdsec/hub_pull_apply_test.go +++ b/backend/internal/crowdsec/hub_pull_apply_test.go @@ -5,6 +5,7 @@ import ( "bytes" "compress/gzip" "context" + "fmt" "io" "net/http" "os" @@ -16,6 +17,22 @@ import ( "github.com/stretchr/testify/require" ) +type stubExec struct { + responses map[string]error + calls []string +} + +func (s *stubExec) Execute(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := strings.Join(append([]string{name}, args...), " ") + s.calls = append(s.calls, cmd) + for key, err := range s.responses { + if strings.Contains(cmd, key) { + return nil, err + } + } + return []byte("ok"), nil +} + // TestPullThenApplyFlow verifies that pulling a preset and then applying it works correctly. func TestPullThenApplyFlow(t *testing.T) { // Create temp directories for cache and data @@ -28,7 +45,7 @@ func TestPullThenApplyFlow(t *testing.T) { // Create a test archive archive := makeTestArchive(t, map[string]string{ - "config.yaml": "test: config\nvalue: 123", + "config.yaml": "test: config\nvalue: 123", "profiles.yaml": "name: test", }) @@ -113,6 +130,117 @@ func TestPullThenApplyFlow(t *testing.T) { require.Contains(t, string(content), "test: config") } +func TestApplyRepullsOnCacheMissAfterCSCLIFailure(t *testing.T) { + cacheDir := t.TempDir() + dataDir := filepath.Join(t.TempDir(), "data") + cache, err := NewHubCache(cacheDir, time.Hour) + require.NoError(t, err) + + archive := makeTestArchive(t, map[string]string{"config.yaml": "test: repull"}) + + exec := &stubExec{responses: map[string]error{"install": fmt.Errorf("install failed")}} + hub := NewHubService(exec, cache, dataDir) + hub.HubBaseURL = "http://test.example.com" + hub.HTTPClient = &http.Client{Transport: mockTransport(func(req *http.Request) (*http.Response, error) { + switch req.URL.String() { + case "http://test.example.com/api/index.json": + body := `{"items":[{"name":"test/preset","title":"Test","etag":"e1"}]}` + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + case "http://test.example.com/test/preset.yaml": + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("preview")), Header: make(http.Header)}, nil + case "http://test.example.com/test/preset.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 + } + })} + + ctx := context.Background() + res, err := hub.Apply(ctx, "test/preset") + require.NoError(t, err) + require.Equal(t, "applied", res.Status) + require.False(t, res.UsedCSCLI) + require.NotEmpty(t, res.CacheKey) + + meta, loadErr := cache.Load(ctx, "test/preset") + require.NoError(t, loadErr) + require.Equal(t, res.CacheKey, meta.CacheKey) + require.FileExists(t, filepath.Join(dataDir, "config.yaml")) +} + +func TestApplyRepullsOnCacheExpired(t *testing.T) { + cacheDir := t.TempDir() + dataDir := filepath.Join(t.TempDir(), "data") + cache, err := NewHubCache(cacheDir, 5*time.Millisecond) + require.NoError(t, err) + + archive := makeTestArchive(t, map[string]string{"config.yaml": "test: expired"}) + ctx := context.Background() + _, err = cache.Store(ctx, "expired/preset", "etag-old", "hub", "old", archive) + require.NoError(t, err) + + // wait for expiration + time.Sleep(10 * time.Millisecond) + + hub := NewHubService(nil, cache, dataDir) + hub.HubBaseURL = "http://test.example.com" + hub.HTTPClient = &http.Client{Transport: mockTransport(func(req *http.Request) (*http.Response, error) { + switch req.URL.String() { + case "http://test.example.com/api/index.json": + body := `{"items":[{"name":"expired/preset","title":"Expired","etag":"e2"}]}` + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + case "http://test.example.com/expired/preset.yaml": + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("preview new")), Header: make(http.Header)}, nil + case "http://test.example.com/expired/preset.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 + } + })} + + res, err := hub.Apply(ctx, "expired/preset") + require.NoError(t, err) + require.Equal(t, "applied", res.Status) + require.False(t, res.UsedCSCLI) + + meta, loadErr := cache.Load(ctx, "expired/preset") + require.NoError(t, loadErr) + require.Equal(t, "e2", meta.Etag) + require.FileExists(t, filepath.Join(dataDir, "config.yaml")) +} + +func TestPullAcceptsNamespacedIndexEntry(t *testing.T) { + cacheDir := t.TempDir() + dataDir := filepath.Join(t.TempDir(), "data") + cache, err := NewHubCache(cacheDir, time.Hour) + require.NoError(t, err) + + archive := makeTestArchive(t, map[string]string{"config.yaml": "test: namespaced"}) + + hub := NewHubService(nil, cache, dataDir) + hub.HubBaseURL = "http://test.example.com" + hub.HTTPClient = &http.Client{Transport: mockTransport(func(req *http.Request) (*http.Response, error) { + switch req.URL.String() { + case "http://test.example.com/api/index.json": + body := `{"items":[{"name":"crowdsecurity/bot-mitigation-essentials","title":"Bot Mitigation Essentials","etag":"etag-bme"}]}` + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + case "http://test.example.com/crowdsecurity/bot-mitigation-essentials.yaml": + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("namespaced preview")), Header: make(http.Header)}, nil + case "http://test.example.com/crowdsecurity/bot-mitigation-essentials.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 + } + })} + + ctx := context.Background() + res, err := hub.Pull(ctx, "bot-mitigation-essentials") + require.NoError(t, err) + require.Equal(t, "bot-mitigation-essentials", res.Meta.Slug) + require.Equal(t, "etag-bme", res.Meta.Etag) + require.Contains(t, res.Preview, "namespaced preview") +} + // TestApplyWithoutPullFails verifies that applying without pulling first fails with proper error. func TestApplyWithoutPullFails(t *testing.T) { cacheDir := t.TempDir() @@ -123,14 +251,18 @@ func TestApplyWithoutPullFails(t *testing.T) { // Create hub service without cscli (nil executor) and empty cache hub := NewHubService(nil, cache, dataDir) + hub.HubBaseURL = "http://test.example.com" + hub.HTTPClient = &http.Client{Transport: mockTransport(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil + })} ctx := context.Background() // Try to apply without pulling first _, err = hub.Apply(ctx, "nonexistent/preset") require.Error(t, err, "Apply should fail without cache and without cscli") - require.Contains(t, err.Error(), "cscli unavailable", "Error should mention cscli unavailable") - require.Contains(t, err.Error(), "no cached preset", "Error should mention missing cache") + require.ErrorIs(t, err, ErrCacheMiss, "Error should expose cache miss for guidance") + require.Contains(t, err.Error(), "refresh cache", "Error should surface repull failure context") } // TestCacheExpiration verifies that expired cache is not used. diff --git a/backend/internal/crowdsec/hub_sync.go b/backend/internal/crowdsec/hub_sync.go index e773aca9..be6ed304 100644 --- a/backend/internal/crowdsec/hub_sync.go +++ b/backend/internal/crowdsec/hub_sync.go @@ -341,13 +341,15 @@ func (s *HubService) Pull(ctx context.Context, slug string) (PullResult, error) return PullResult{}, fmt.Errorf("preset not found in hub") } + entrySlug := firstNonEmpty(entry.Name, cleanSlug) + archiveURL := entry.DownloadURL if archiveURL == "" { - archiveURL = fmt.Sprintf(strings.TrimRight(s.HubBaseURL, "/")+defaultHubArchivePath, cleanSlug) + archiveURL = fmt.Sprintf(strings.TrimRight(s.HubBaseURL, "/")+defaultHubArchivePath, entrySlug) } previewURL := entry.PreviewURL if previewURL == "" { - previewURL = fmt.Sprintf(strings.TrimRight(s.HubBaseURL, "/")+defaultHubPreviewPath, cleanSlug) + previewURL = fmt.Sprintf(strings.TrimRight(s.HubBaseURL, "/")+defaultHubPreviewPath, entrySlug) } archiveBytes, err := s.fetchWithLimit(pullCtx, archiveURL) @@ -389,11 +391,6 @@ func (s *HubService) Apply(ctx context.Context, slug string) (ApplyResult, error result.CacheKey = meta.CacheKey } hasCS := s.hasCSCLI(applyCtx) - if !hasCS && metaErr != nil { - msg := "cscli unavailable and no cached preset; pull the preset or install cscli" - result.ErrorMessage = msg - return result, errors.New(msg) - } backupPath := filepath.Clean(s.DataDir) + ".backup." + time.Now().Format("20060102-150405") if err := s.backupExisting(backupPath); err != nil { @@ -416,10 +413,16 @@ func (s *HubService) Apply(ctx context.Context, slug string) (ApplyResult, error } if metaErr != nil { - _ = s.rollback(backupPath) - msg := fmt.Sprintf("load cache: %v", metaErr) - result.ErrorMessage = msg - return result, errors.New(msg) + refreshed, refreshErr := s.refreshCache(applyCtx, cleanSlug, metaErr) + if refreshErr != nil { + _ = s.rollback(backupPath) + logger.Log().WithError(refreshErr).WithField("slug", cleanSlug).WithField("backup_path", backupPath).Warn("cache refresh failed; rolled back backup") + msg := fmt.Sprintf("load cache for %s: %v", cleanSlug, refreshErr) + result.ErrorMessage = msg + return result, fmt.Errorf("load cache for %s: %w", cleanSlug, refreshErr) + } + meta = refreshed + result.CacheKey = meta.CacheKey } archive, err := os.ReadFile(meta.ArchivePath) @@ -518,12 +521,69 @@ func (s *HubService) loadCacheMeta(ctx context.Context, slug string) (CachedPres return meta, nil } +func (s *HubService) refreshCache(ctx context.Context, slug string, metaErr error) (CachedPreset, error) { + if !errors.Is(metaErr, ErrCacheMiss) && !errors.Is(metaErr, ErrCacheExpired) { + return CachedPreset{}, metaErr + } + if errors.Is(metaErr, ErrCacheExpired) && s.Cache != nil { + if err := s.Cache.Evict(ctx, slug); err != nil { + logger.Log().WithError(err).WithField("slug", slug).Warn("failed to evict expired cache before refresh") + } + } + logger.Log().WithError(metaErr).WithField("slug", slug).Info("attempting to repull preset after cache load failure") + refreshed, pullErr := s.Pull(ctx, slug) + if pullErr != nil { + return CachedPreset{}, fmt.Errorf("%w: refresh cache: %v", metaErr, pullErr) + } + return refreshed.Meta, nil +} + func findIndexEntry(idx HubIndex, slug string) (HubIndexEntry, bool) { for _, i := range idx.Items { if i.Name == slug || i.Title == slug { return i, true } } + + normalized := strings.TrimSpace(slug) + if normalized == "" { + return HubIndexEntry{}, false + } + + if !strings.Contains(normalized, "/") { + namespaced := "crowdsecurity/" + normalized + var candidate HubIndexEntry + found := false + for _, i := range idx.Items { + if i.Name == namespaced || i.Title == namespaced || strings.HasSuffix(i.Name, "/"+normalized) || strings.HasSuffix(i.Title, "/"+normalized) { + if found { + return HubIndexEntry{}, false + } + candidate = i + found = true + } + } + if found { + return candidate, true + } + } + + var suffixCandidate HubIndexEntry + foundSuffix := false + for _, i := range idx.Items { + if strings.HasSuffix(i.Name, "/"+normalized) || strings.HasSuffix(i.Title, "/"+normalized) { + if foundSuffix { + return HubIndexEntry{}, false + } + suffixCandidate = i + foundSuffix = true + } + } + + if foundSuffix { + return suffixCandidate, true + } + return HubIndexEntry{}, false } diff --git a/backend/internal/crowdsec/hub_sync_test.go b/backend/internal/crowdsec/hub_sync_test.go index a746d26b..26713171 100644 --- a/backend/internal/crowdsec/hub_sync_test.go +++ b/backend/internal/crowdsec/hub_sync_test.go @@ -468,8 +468,8 @@ func TestApplyRollsBackWhenCacheMissing(t *testing.T) { svc := NewHubService(nil, nil, dataDir) res, err := svc.Apply(context.Background(), "crowdsecurity/demo") require.Error(t, err) - require.Contains(t, err.Error(), "cscli unavailable") - require.Empty(t, res.BackupPath) + require.Contains(t, err.Error(), "cache unavailable") + require.NotEmpty(t, res.BackupPath) require.Equal(t, "failed", res.Status) content, readErr := os.ReadFile(filepath.Join(dataDir, "keep.txt")) @@ -501,9 +501,9 @@ func TestNormalizeHubBaseURL(t *testing.T) { func TestBuildIndexURL(t *testing.T) { tests := []struct { - name string - base string - want string + name string + base string + want string }{ {"empty base uses default", "", defaultHubBaseURL + defaultHubIndexPath}, {"standard base appends path", "https://hub.crowdsec.net", "https://hub.crowdsec.net" + defaultHubIndexPath}, diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index 8bd0428d..e791a614 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -51,7 +51,7 @@ func Connect(dbPath string) (*gorm.DB, error) { func configurePool(sqlDB *sql.DB) { // SQLite is file-based, so we limit connections // but keep some idle for reuse - sqlDB.SetMaxOpenConns(1) // SQLite only allows one writer at a time - sqlDB.SetMaxIdleConns(1) // Keep one connection ready + sqlDB.SetMaxOpenConns(1) // SQLite only allows one writer at a time + sqlDB.SetMaxIdleConns(1) // Keep one connection ready sqlDB.SetConnMaxLifetime(0) // Don't close idle connections } diff --git a/backend/internal/models/notification_config.go b/backend/internal/models/notification_config.go index 2291c87a..71c61db5 100644 --- a/backend/internal/models/notification_config.go +++ b/backend/internal/models/notification_config.go @@ -29,11 +29,11 @@ func (nc *NotificationConfig) BeforeCreate(tx *gorm.DB) error { // SecurityEvent represents a security event for notification dispatch. type SecurityEvent struct { - EventType string `json:"event_type"` // waf_block, acl_deny, etc. - Severity string `json:"severity"` // error, warn, info - Message string `json:"message"` - ClientIP string `json:"client_ip"` - Path string `json:"path"` - Timestamp time.Time `json:"timestamp"` - Metadata map[string]interface{} `json:"metadata"` + EventType string `json:"event_type"` // waf_block, acl_deny, etc. + Severity string `json:"severity"` // error, warn, info + Message string `json:"message"` + ClientIP string `json:"client_ip"` + Path string `json:"path"` + Timestamp time.Time `json:"timestamp"` + Metadata map[string]interface{} `json:"metadata"` } diff --git a/backend/internal/services/backup_service_disk_test.go b/backend/internal/services/backup_service_disk_test.go index 2f91693a..3e77b40b 100644 --- a/backend/internal/services/backup_service_disk_test.go +++ b/backend/internal/services/backup_service_disk_test.go @@ -9,27 +9,27 @@ import ( ) func TestBackupService_GetAvailableSpace(t *testing.T) { - t.Parallel() + t.Parallel() - t.Run("returns space for existing directory", func(t *testing.T) { - t.Parallel() + t.Run("returns space for existing directory", func(t *testing.T) { + t.Parallel() - tmpDir := t.TempDir() - svc := &BackupService{BackupDir: tmpDir} + tmpDir := t.TempDir() + svc := &BackupService{BackupDir: tmpDir} - space, err := svc.GetAvailableSpace() - require.NoError(t, err) - assert.Greater(t, space, int64(0)) - }) + space, err := svc.GetAvailableSpace() + require.NoError(t, err) + assert.Greater(t, space, int64(0)) + }) - t.Run("errors for missing directory", func(t *testing.T) { - t.Parallel() + t.Run("errors for missing directory", func(t *testing.T) { + t.Parallel() - tmpDir := t.TempDir() - missingDir := filepath.Join(tmpDir, "does-not-exist") - svc := &BackupService{BackupDir: missingDir} + tmpDir := t.TempDir() + missingDir := filepath.Join(tmpDir, "does-not-exist") + svc := &BackupService{BackupDir: missingDir} - _, err := svc.GetAvailableSpace() - require.Error(t, err) - }) + _, err := svc.GetAvailableSpace() + require.Error(t, err) + }) } diff --git a/backend/internal/services/notification_service_template_test.go b/backend/internal/services/notification_service_template_test.go index 8668c979..c49a4c9f 100644 --- a/backend/internal/services/notification_service_template_test.go +++ b/backend/internal/services/notification_service_template_test.go @@ -12,39 +12,39 @@ import ( ) func TestNotificationService_TemplateCRUD(t *testing.T) { - t.Parallel() + t.Parallel() - db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) + db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) - svc := NewNotificationService(db) + svc := NewNotificationService(db) - tmpl := &models.NotificationTemplate{ - Name: "Custom", - Description: "initial description", - Config: `{"message":"hello"}`, - Template: "custom", - } + tmpl := &models.NotificationTemplate{ + Name: "Custom", + Description: "initial description", + Config: `{"message":"hello"}`, + Template: "custom", + } - require.NoError(t, svc.CreateTemplate(tmpl)) - require.NotEmpty(t, tmpl.ID) + require.NoError(t, svc.CreateTemplate(tmpl)) + require.NotEmpty(t, tmpl.ID) - fetched, err := svc.GetTemplate(tmpl.ID) - require.NoError(t, err) - assert.Equal(t, tmpl.Name, fetched.Name) - assert.Equal(t, tmpl.Description, fetched.Description) + fetched, err := svc.GetTemplate(tmpl.ID) + require.NoError(t, err) + assert.Equal(t, tmpl.Name, fetched.Name) + assert.Equal(t, tmpl.Description, fetched.Description) - tmpl.Description = "updated description" - require.NoError(t, svc.UpdateTemplate(tmpl)) + tmpl.Description = "updated description" + require.NoError(t, svc.UpdateTemplate(tmpl)) - list, err := svc.ListTemplates() - require.NoError(t, err) - require.Len(t, list, 1) - assert.Equal(t, "updated description", list[0].Description) + list, err := svc.ListTemplates() + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, "updated description", list[0].Description) - require.NoError(t, svc.DeleteTemplate(tmpl.ID)) - list, err = svc.ListTemplates() - require.NoError(t, err) - assert.Empty(t, list) + require.NoError(t, svc.DeleteTemplate(tmpl.ID)) + list, err = svc.ListTemplates() + require.NoError(t, err) + assert.Empty(t, list) } diff --git a/backend/internal/services/uptime_service_notification_test.go b/backend/internal/services/uptime_service_notification_test.go index 51f274bf..cb4712f2 100644 --- a/backend/internal/services/uptime_service_notification_test.go +++ b/backend/internal/services/uptime_service_notification_test.go @@ -12,24 +12,24 @@ import ( ) func TestUptimeService_sendRecoveryNotification(t *testing.T) { - t.Parallel() + t.Parallel() - db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.Notification{}, &models.NotificationProvider{})) + db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.Notification{}, &models.NotificationProvider{})) - ns := NewNotificationService(db) - svc := NewUptimeService(db, ns) + ns := NewNotificationService(db) + svc := NewUptimeService(db, ns) - monitor := models.UptimeMonitor{Name: "API Server", URL: "https://api.example.com"} + monitor := models.UptimeMonitor{Name: "API Server", URL: "https://api.example.com"} - svc.sendRecoveryNotification(monitor, "5m") + svc.sendRecoveryNotification(monitor, "5m") - var notifications []models.Notification - require.NoError(t, db.Find(¬ifications).Error) + var notifications []models.Notification + require.NoError(t, db.Find(¬ifications).Error) - require.Len(t, notifications, 1) - assert.Contains(t, notifications[0].Title, "API Server") - assert.Contains(t, notifications[0].Message, "Downtime: 5m") - assert.Equal(t, models.NotificationTypeSuccess, notifications[0].Type) + require.Len(t, notifications, 1) + assert.Contains(t, notifications[0].Title, "API Server") + assert.Contains(t, notifications[0].Message, "Downtime: 5m") + assert.Equal(t, models.NotificationTypeSuccess, notifications[0].Type) } diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md index fcd2fa71..a3afed79 100644 --- a/docs/reports/qa_report.md +++ b/docs/reports/qa_report.md @@ -2,6 +2,28 @@ Note: This report documents a QA audit of the history-rewrite scripts. The scripts and tests live in `scripts/history-rewrite/` and the maintainer-facing plan and checklist are in `docs/plans/history_rewrite.md`. +# QA Report: Backend Verification (Dec 11, 2025) + +- **Date:** 2025-12-11 +- **QA Agent:** QA_Automation +- **Scope:** Backend regression verification per request (gofmt + full Go tests + targeted CrowdSec apply/pull tests). + +## Commands Executed +- `cd backend && gofmt -w .` +- `cd backend && go test ./... -v` +- `cd backend && go test ./internal/crowdsec/... -v` + +## Results +- `gofmt` completed without errors. +- `go test ./... -v` **Passed**. All packages succeeded; noisy but expected SQLite "record not found" logs appeared during in-memory test setup. Longest runtime segment was `internal/services` (~28s) due to uptime checks. +- `go test ./internal/crowdsec/... -v` **Passed**. All CrowdSec pull/apply/cache tests green; cache refresh and rollback paths covered. + +## Observations +- The full suite emits informational logs (certificate and uptime services) and expected skips for SMTP integration; no assertion failures. +- CrowdSec tests exercised backup rollback, cache-miss repull, and apply-from-cache flows; no regressions observed. + +**Status:** ✅ PASS — Backend formatting and regression tests completed successfully. + - **Date**: 2025-12-09 - **Author**: QA_Security (Automated checks)