diff --git a/.gitignore b/.gitignore index f340690c..27a2e460 100644 --- a/.gitignore +++ b/.gitignore @@ -309,3 +309,4 @@ frontend/temp** playwright-output/** validation-evidence/** .github/agents/# Tools Configuration.md +docs/plans/codecove_patch_report.md diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go index 644e502d..ea95acaf 100644 --- a/backend/internal/api/handlers/auth_handler_test.go +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -8,8 +8,8 @@ import ( "os" "testing" - "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/api/middleware" + "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" "github.com/gin-gonic/gin" @@ -1027,3 +1027,81 @@ func TestAuthHandler_Me_RequiresUserContext(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, res.Code) } + +func TestAuthHandler_HelperFunctions(t *testing.T) { + t.Parallel() + + t.Run("requestScheme prefers forwarded proto", func(t *testing.T) { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodGet, "http://example.com", http.NoBody) + req.Header.Set("X-Forwarded-Proto", "HTTPS, http") + ctx.Request = req + assert.Equal(t, "https", requestScheme(ctx)) + }) + + t.Run("normalizeHost strips brackets and port", func(t *testing.T) { + assert.Equal(t, "::1", normalizeHost("[::1]:443")) + assert.Equal(t, "example.com", normalizeHost("example.com:8080")) + }) + + t.Run("originHost returns empty for invalid url", func(t *testing.T) { + assert.Equal(t, "", originHost("://bad")) + assert.Equal(t, "example.com", originHost("https://example.com/path")) + }) + + t.Run("isLocalHost and isLocalRequest", func(t *testing.T) { + assert.True(t, isLocalHost("localhost")) + assert.True(t, isLocalHost("127.0.0.1")) + assert.False(t, isLocalHost("example.com")) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodGet, "http://service.internal", http.NoBody) + req.Host = "service.internal:8080" + req.Header.Set("X-Forwarded-Host", "example.com, localhost:8080") + ctx.Request = req + assert.True(t, isLocalRequest(ctx)) + }) +} + +func TestAuthHandler_Refresh(t *testing.T) { + t.Parallel() + + handler, db := setupAuthHandler(t) + + user := &models.User{UUID: uuid.NewString(), Email: "refresh@example.com", Name: "Refresh User", Role: "user", Enabled: true} + require.NoError(t, user.SetPassword("password123")) + require.NoError(t, db.Create(user).Error) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/refresh", func(c *gin.Context) { + c.Set("userID", user.ID) + handler.Refresh(c) + }) + + req := httptest.NewRequest(http.MethodPost, "/refresh", http.NoBody) + res := httptest.NewRecorder() + r.ServeHTTP(res, req) + + assert.Equal(t, http.StatusOK, res.Code) + assert.Contains(t, res.Body.String(), "token") + cookies := res.Result().Cookies() + assert.NotEmpty(t, cookies) +} + +func TestAuthHandler_Refresh_Unauthorized(t *testing.T) { + t.Parallel() + + handler, _ := setupAuthHandler(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/refresh", handler.Refresh) + + req := httptest.NewRequest(http.MethodPost, "/refresh", http.NoBody) + res := httptest.NewRecorder() + r.ServeHTTP(res, req) + + assert.Equal(t, http.StatusUnauthorized, res.Code) +} diff --git a/backend/internal/api/handlers/crowdsec_handler_comprehensive_test.go b/backend/internal/api/handlers/crowdsec_handler_comprehensive_test.go index 69d6bcd1..ca10aada 100644 --- a/backend/internal/api/handlers/crowdsec_handler_comprehensive_test.go +++ b/backend/internal/api/handlers/crowdsec_handler_comprehensive_test.go @@ -440,11 +440,22 @@ func TestUpdateAcquisitionConfig(t *testing.T) { // TestGetLAPIKey tests the getLAPIKey helper func TestGetLAPIKey(t *testing.T) { - // getLAPIKey is a package-level function that reads from environment/global state - // For now, just exercise the function - key := getLAPIKey() - // Key will be empty in test environment, but function is exercised - _ = key + t.Setenv("CROWDSEC_API_KEY", "") + t.Setenv("CROWDSEC_BOUNCER_API_KEY", "") + t.Setenv("CERBERUS_SECURITY_CROWDSEC_API_KEY", "") + t.Setenv("CHARON_SECURITY_CROWDSEC_API_KEY", "") + t.Setenv("CPM_SECURITY_CROWDSEC_API_KEY", "") + + assert.Equal(t, "", getLAPIKey()) + + t.Setenv("CERBERUS_SECURITY_CROWDSEC_API_KEY", "fallback-key") + assert.Equal(t, "fallback-key", getLAPIKey()) + + t.Setenv("CROWDSEC_BOUNCER_API_KEY", "priority-key") + assert.Equal(t, "priority-key", getLAPIKey()) + + t.Setenv("CROWDSEC_API_KEY", "top-priority-key") + assert.Equal(t, "top-priority-key", getLAPIKey()) } // NOTE: Removed duplicate TestIsCerberusEnabled - covered by existing test files diff --git a/backend/internal/api/handlers/import_handler_test.go b/backend/internal/api/handlers/import_handler_test.go index c125cca1..bcb2e625 100644 --- a/backend/internal/api/handlers/import_handler_test.go +++ b/backend/internal/api/handlers/import_handler_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/Wikid82/charon/backend/internal/caddy" "github.com/Wikid82/charon/backend/internal/models" @@ -113,6 +114,80 @@ func addAdminMiddleware(router *gin.Engine) { }) } +func TestImportHandler_GetStatus_MountCommittedUnchanged(t *testing.T) { + t.Parallel() + + testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { + mountDir := t.TempDir() + mountPath := filepath.Join(mountDir, "mounted.caddyfile") + require.NoError(t, os.WriteFile(mountPath, []byte("example.com { respond \"ok\" }"), 0o600)) + + committedAt := time.Now() + require.NoError(t, tx.Create(&models.ImportSession{ + UUID: "committed-1", + SourceFile: mountPath, + Status: "committed", + CommittedAt: &committedAt, + }).Error) + + require.NoError(t, os.Chtimes(mountPath, committedAt.Add(-1*time.Minute), committedAt.Add(-1*time.Minute))) + + handler, _, _ := setupTestHandler(t, tx) + handler.mountPath = mountPath + + gin.SetMode(gin.TestMode) + router := gin.New() + addAdminMiddleware(router) + handler.RegisterRoutes(router.Group("/api/v1")) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/import/status", http.NoBody) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, false, body["has_pending"]) + }) +} + +func TestImportHandler_GetStatus_MountModifiedAfterCommit(t *testing.T) { + t.Parallel() + + testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { + mountDir := t.TempDir() + mountPath := filepath.Join(mountDir, "mounted.caddyfile") + require.NoError(t, os.WriteFile(mountPath, []byte("example.com { respond \"ok\" }"), 0o600)) + + committedAt := time.Now().Add(-10 * time.Minute) + require.NoError(t, tx.Create(&models.ImportSession{ + UUID: "committed-2", + SourceFile: mountPath, + Status: "committed", + CommittedAt: &committedAt, + }).Error) + + require.NoError(t, os.Chtimes(mountPath, time.Now(), time.Now())) + + handler, _, _ := setupTestHandler(t, tx) + handler.mountPath = mountPath + + gin.SetMode(gin.TestMode) + router := gin.New() + addAdminMiddleware(router) + handler.RegisterRoutes(router.Group("/api/v1")) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/import/status", http.NoBody) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, true, body["has_pending"]) + }) +} + // TestUpload_NormalizationSuccess verifies single-line Caddyfile formatting func TestUpload_NormalizationSuccess(t *testing.T) { testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { diff --git a/backend/internal/api/handlers/user_handler_test.go b/backend/internal/api/handlers/user_handler_test.go index 181ff237..49b53995 100644 --- a/backend/internal/api/handlers/user_handler_test.go +++ b/backend/internal/api/handlers/user_handler_test.go @@ -28,6 +28,52 @@ func setupUserHandler(t *testing.T) (*UserHandler, *gorm.DB) { return NewUserHandler(db), db } +func TestMapsKeys(t *testing.T) { + t.Parallel() + + keys := mapsKeys(map[string]any{"email": "a@example.com", "name": "Alice", "enabled": true}) + assert.Len(t, keys, 3) + assert.Contains(t, keys, "email") + assert.Contains(t, keys, "name") + assert.Contains(t, keys, "enabled") +} + +func TestUserHandler_actorFromContext(t *testing.T) { + t.Parallel() + + handler, _ := setupUserHandler(t) + + rec1 := httptest.NewRecorder() + ctx1, _ := gin.CreateTestContext(rec1) + req1 := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + req1.RemoteAddr = "198.51.100.10:1234" + ctx1.Request = req1 + assert.Equal(t, "198.51.100.10", handler.actorFromContext(ctx1)) + + rec2 := httptest.NewRecorder() + ctx2, _ := gin.CreateTestContext(rec2) + req2 := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + ctx2.Request = req2 + ctx2.Set("userID", uint(42)) + assert.Equal(t, "42", handler.actorFromContext(ctx2)) +} + +func TestUserHandler_logUserAudit_NoOpBranches(t *testing.T) { + t.Parallel() + + handler, _ := setupUserHandler(t) + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodGet, "/", http.NoBody) + + // nil user should be a no-op + handler.logUserAudit(ctx, "noop", nil, map[string]any{"x": 1}) + + // nil security service should be a no-op + handler.securitySvc = nil + handler.logUserAudit(ctx, "noop", &models.User{UUID: uuid.NewString(), Email: "user@example.com"}, map[string]any{"x": 1}) +} + func TestUserHandler_GetSetupStatus(t *testing.T) { handler, db := setupUserHandler(t) gin.SetMode(gin.TestMode) diff --git a/backend/internal/cerberus/rate_limit_test.go b/backend/internal/cerberus/rate_limit_test.go index dd60ca9a..5ad8952d 100644 --- a/backend/internal/cerberus/rate_limit_test.go +++ b/backend/internal/cerberus/rate_limit_test.go @@ -392,6 +392,37 @@ func TestCerberusRateLimitMiddleware_AdminSecurityControlPlaneBypass(t *testing. } } +func TestIsAdminSecurityControlPlaneRequest(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + + t.Run("admin role bypasses control plane", func(t *testing.T) { + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodGet, "/api/v1/security/rules", http.NoBody) + ctx.Set("role", "admin") + assert.True(t, isAdminSecurityControlPlaneRequest(ctx)) + }) + + t.Run("bearer token bypasses control plane", func(t *testing.T) { + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", http.NoBody) + req.Header.Set("Authorization", "Bearer token") + ctx.Request = req + assert.True(t, isAdminSecurityControlPlaneRequest(ctx)) + }) + + t.Run("non control plane path is not bypassed", func(t *testing.T) { + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts", http.NoBody) + ctx.Set("role", "admin") + assert.False(t, isAdminSecurityControlPlaneRequest(ctx)) + }) +} + func TestCerberusRateLimitMiddleware_AdminSettingsBypass(t *testing.T) { cfg := config.SecurityConfig{ RateLimitMode: "enabled", diff --git a/backend/internal/crowdsec/hub_sync_test.go b/backend/internal/crowdsec/hub_sync_test.go index 28f6bf27..f2e2f47d 100644 --- a/backend/internal/crowdsec/hub_sync_test.go +++ b/backend/internal/crowdsec/hub_sync_test.go @@ -817,11 +817,39 @@ func TestApplyWithCopyBasedBackup(t *testing.T) { // Verify backup was created with copy-based approach require.FileExists(t, filepath.Join(res.BackupPath, "existing.txt")) require.FileExists(t, filepath.Join(res.BackupPath, "subdir", "nested.txt")) - // Verify new config was applied require.FileExists(t, filepath.Join(dataDir, "new", "config.yaml")) } +func TestIndexURLCandidates_GitHubMirror(t *testing.T) { + t.Parallel() + + candidates := indexURLCandidates("https://raw.githubusercontent.com/crowdsecurity/hub/master") + require.Len(t, candidates, 2) + require.Contains(t, candidates, "https://raw.githubusercontent.com/crowdsecurity/hub/master/.index.json") + require.Contains(t, candidates, "https://raw.githubusercontent.com/crowdsecurity/hub/master/api/index.json") +} + +func TestBuildResourceURLs_DeduplicatesExplicitAndBases(t *testing.T) { + t.Parallel() + + urls := buildResourceURLs("https://hub.example/preset.tgz", "crowdsecurity/demo", "/%s.tgz", []string{"https://hub.example", "https://hub.example"}) + require.NotEmpty(t, urls) + require.Equal(t, "https://hub.example/preset.tgz", urls[0]) + require.Len(t, urls, 2) +} + +func TestHubHTTPErrorMethods(t *testing.T) { + t.Parallel() + + inner := errors.New("inner") + err := hubHTTPError{url: "https://hub.example", statusCode: 404, inner: inner, fallback: true} + + require.Contains(t, err.Error(), "https://hub.example") + require.ErrorIs(t, err, inner) + require.True(t, err.CanFallback()) +} + func TestBackupExistingHandlesDeviceBusy(t *testing.T) { t.Parallel() dataDir := filepath.Join(t.TempDir(), "data") diff --git a/backend/internal/services/backup_service_test.go b/backend/internal/services/backup_service_test.go index 8434bde6..f628979e 100644 --- a/backend/internal/services/backup_service_test.go +++ b/backend/internal/services/backup_service_test.go @@ -694,6 +694,52 @@ func TestBackupService_Start(t *testing.T) { service.Stop() } +func TestQuoteSQLiteIdentifier(t *testing.T) { + t.Parallel() + + quoted, err := quoteSQLiteIdentifier("security_audit") + require.NoError(t, err) + require.Equal(t, `"security_audit"`, quoted) + + _, err = quoteSQLiteIdentifier("") + require.Error(t, err) + + _, err = quoteSQLiteIdentifier("bad-name") + require.Error(t, err) +} + +func TestSafeJoinPath_Validation(t *testing.T) { + t.Parallel() + + base := t.TempDir() + + joined, err := SafeJoinPath(base, "backup/file.zip") + require.NoError(t, err) + require.Equal(t, filepath.Join(base, "backup", "file.zip"), joined) + + _, err = SafeJoinPath(base, "../etc/passwd") + require.Error(t, err) + + _, err = SafeJoinPath(base, "/abs/path") + require.Error(t, err) +} + +func TestSQLiteSnapshotAndCheckpoint(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "snapshot.db") + createSQLiteTestDB(t, dbPath) + + require.NoError(t, checkpointSQLiteDatabase(dbPath)) + + snapshotPath, cleanup, err := createSQLiteSnapshot(dbPath) + require.NoError(t, err) + require.FileExists(t, snapshotPath) + cleanup() + require.NoFileExists(t, snapshotPath) +} + func TestRunScheduledBackup_CleanupSucceedsWithDeletions(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") diff --git a/backend/internal/services/log_service_test.go b/backend/internal/services/log_service_test.go index 703ba7b6..1074b041 100644 --- a/backend/internal/services/log_service_test.go +++ b/backend/internal/services/log_service_test.go @@ -166,3 +166,29 @@ func TestLogService(t *testing.T) { assert.Equal(t, int64(1), total) assert.Equal(t, "5.6.7.8", results[0].Request.RemoteIP) } + +func TestLogService_logDirsAndSymlinkDedup(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + logsDir := filepath.Join(dataDir, "logs") + caddyLogsDir := filepath.Join(dataDir, "caddy-logs") + require.NoError(t, os.MkdirAll(logsDir, 0o750)) + require.NoError(t, os.MkdirAll(caddyLogsDir, 0o750)) + + cfg := &config.Config{DatabasePath: filepath.Join(dataDir, "charon.db"), CaddyLogDir: caddyLogsDir} + service := NewLogService(cfg) + + accessPath := filepath.Join(logsDir, "access.log") + require.NoError(t, os.WriteFile(accessPath, []byte("{}\n"), 0o600)) + require.NoError(t, os.Symlink(accessPath, filepath.Join(logsDir, "cpmp.log"))) + + t.Setenv("CHARON_CADDY_ACCESS_LOG", filepath.Join(caddyLogsDir, "access-caddy.log")) + dirs := service.logDirs() + assert.Contains(t, dirs, logsDir) + assert.Contains(t, dirs, caddyLogsDir) + + logs, err := service.ListLogs() + require.NoError(t, err) + assert.Len(t, logs, 1) + assert.Equal(t, "access.log", logs[0].Name) +} diff --git a/backend/internal/services/mail_service_test.go b/backend/internal/services/mail_service_test.go index d76a7458..26674839 100644 --- a/backend/internal/services/mail_service_test.go +++ b/backend/internal/services/mail_service_test.go @@ -710,3 +710,53 @@ func TestMailService_SendInvite_CRLFInjection(t *testing.T) { }) } } + +func TestRejectCRLF(t *testing.T) { + t.Parallel() + + require.NoError(t, rejectCRLF("normal-value")) + require.ErrorIs(t, rejectCRLF("bad\r\nvalue"), errEmailHeaderInjection) +} + +func TestNormalizeBaseURLForInvite(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + want string + wantErr bool + }{ + {name: "valid https", raw: "https://example.com", want: "https://example.com", wantErr: false}, + {name: "valid http with slash path", raw: "http://example.com/", want: "http://example.com", wantErr: false}, + {name: "empty", raw: "", wantErr: true}, + {name: "invalid scheme", raw: "ftp://example.com", wantErr: true}, + {name: "with path", raw: "https://example.com/path", wantErr: true}, + {name: "with query", raw: "https://example.com?x=1", wantErr: true}, + {name: "with fragment", raw: "https://example.com#frag", wantErr: true}, + {name: "with user info", raw: "https://user@example.com", wantErr: true}, + {name: "with header injection", raw: "https://example.com\r\nX-Test: 1", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeBaseURLForInvite(tt.raw) + if tt.wantErr { + require.Error(t, err) + require.ErrorIs(t, err, errInvalidBaseURLForInvite) + return + } + + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestEncodeSubject_RejectsCRLF(t *testing.T) { + t.Parallel() + + _, err := encodeSubject("Hello\r\nWorld") + require.Error(t, err) + require.ErrorIs(t, err, errEmailHeaderInjection) +} diff --git a/backend/internal/services/proxyhost_service_test.go b/backend/internal/services/proxyhost_service_test.go index 3de97a99..c221c33c 100644 --- a/backend/internal/services/proxyhost_service_test.go +++ b/backend/internal/services/proxyhost_service_test.go @@ -265,3 +265,37 @@ func TestProxyHostService_EmptyDomain(t *testing.T) { err := service.ValidateUniqueDomain("", 0) assert.NoError(t, err) } + +func TestProxyHostService_DBAccessorAndLookupErrors(t *testing.T) { + t.Parallel() + + db := setupProxyHostTestDB(t) + service := NewProxyHostService(db) + + assert.Equal(t, db, service.DB()) + + _, err := service.GetByID(999999) + assert.Error(t, err) + + _, err = service.GetByUUID("missing-uuid") + assert.Error(t, err) +} + +func TestProxyHostService_validateProxyHost_ValidationErrors(t *testing.T) { + t.Parallel() + + db := setupProxyHostTestDB(t) + service := NewProxyHostService(db) + + err := service.validateProxyHost(&models.ProxyHost{DomainNames: "", ForwardHost: "127.0.0.1"}) + assert.ErrorContains(t, err, "domain names is required") + + err = service.validateProxyHost(&models.ProxyHost{DomainNames: "example.com", ForwardHost: ""}) + assert.ErrorContains(t, err, "forward host is required") + + err = service.validateProxyHost(&models.ProxyHost{DomainNames: "example.com", ForwardHost: "invalid$host"}) + assert.ErrorContains(t, err, "forward host must be a valid IP address or hostname") + + err = service.validateProxyHost(&models.ProxyHost{DomainNames: "example.com", ForwardHost: "127.0.0.1", UseDNSChallenge: true}) + assert.ErrorContains(t, err, "dns provider is required") +} diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index dafcbdd8..42942c1d 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -263,3 +263,140 @@ Done is achieved only when all are true: 4. No changes were made that destabilize or alter flaky E2E CI validation scope. 5. Codecov patch coverage expectations remain satisfiable (100% for modified lines). 6. Baseline/post-change metrics and final threshold status are documented in the task handoff. + +## 12) Backend Patch-Coverage Remediation (Additive, Frontend Plan Intact) + +Date: 2026-02-16 +Owner: Planning Agent +Scope: Add backend-only remediation to recover Codecov patch coverage for changed backend lines while preserving existing frontend unit-test coverage work. + +### 12.1 Objective and Constraints + +- Raise backend patch coverage by targeting missing/partial changed lines from Codecov Patch view. +- Preserve all existing frontend coverage plan content and execution order; this section is additive only. +- No E2E requirement for this backend remediation section. +- Source of truth for prioritization and totals is the provided Codecov patch report. + +### 12.2 Codecov Patch Snapshot (Source of Truth) + +Current patch coverage: **58.78378%** + +Files with missing/partial changed lines: + +| Priority | File | Patch % | Missing | Partial | +|---|---|---:|---:|---:| +| 1 | `backend/internal/services/mail_service.go` | 0.00% | 22 | 0 | +| 2 | `backend/internal/crowdsec/hub_sync.go` | 0.00% | 10 | 6 | +| 3 | `backend/internal/api/handlers/auth_handler.go` | 0.00% | 15 | 0 | +| 4 | `backend/internal/services/backup_service.go` | 0.00% | 5 | 3 | +| 5 | `backend/internal/services/proxyhost_service.go` | 55.88% | 14 | 1 | +| 6 | `backend/internal/api/handlers/crowdsec_handler.go` | 30.00% | 9 | 5 | +| 7 | `backend/internal/api/handlers/user_handler.go` | 72.09% | 6 | 6 | +| 8 | `backend/internal/services/log_service.go` | 73.91% | 6 | 6 | +| 9 | `backend/internal/api/handlers/import_handler.go` | 67.85% | 3 | 6 | +| 10 | `backend/internal/cerberus/rate_limit.go` | 93.33% | 3 | 3 | + +Execution rule: **zero-coverage files are first-pass mandatory** for fastest patch gain. + +### 12.3 Explicit Patch-Triage Table (Exact Range Placeholders + Test Targets) + +Populate exact ranges from local Codecov Patch output before/while implementing tests. + +| File | Codecov exact missing/partial range placeholders (fill from local output) | Mapped backend test file target(s) | Planned test focus for those exact ranges | Status | +|---|---|---|---|---| +| `backend/internal/services/mail_service.go` | Missing: `L-L`, `L-L` | `backend/internal/services/mail_service_test.go` | happy path send/build; SMTP/auth error path; boundary for empty recipient/template vars | Open | +| `backend/internal/crowdsec/hub_sync.go` | Missing: `L-L`; Partial: `L-L` | `backend/internal/crowdsec/hub_sync_test.go` | happy sync success; HTTP/non-200 + decode/network errors; boundary/partial branch on optional fields and empty decisions list | Open | +| `backend/internal/api/handlers/auth_handler.go` | Missing: `L-L`, `L-L` | `backend/internal/api/handlers/auth_handler_test.go` | happy login/refresh/logout response; invalid payload/credentials error path; boundary on missing token/cookie/header branches | Open | +| `backend/internal/services/backup_service.go` | Missing: `L-L`; Partial: `L-L` | `backend/internal/services/backup_service_test.go` | happy backup/restore flow; fs/io/sql error path; boundary/partial for empty backup set and retention edge | Open | +| `backend/internal/services/proxyhost_service.go` | Missing: `L-L`; Partial: `L-L` | `backend/internal/services/proxyhost_service_test.go` | happy create/update/delete/list; validation/duplicate/not-found error path; boundary/partial for optional TLS/security toggles | Open | +| `backend/internal/api/handlers/crowdsec_handler.go` | Missing: `L-L`; Partial: `L-L` | `backend/internal/api/handlers/crowdsec_handler_test.go` | happy config/get/update actions; bind/service error path; boundary/partial on query params and empty payload behavior | Open | +| `backend/internal/api/handlers/user_handler.go` | Missing: `L-L`; Partial: `L-L` | `backend/internal/api/handlers/user_handler_test.go` | happy list/create/update/delete; validation/permission/not-found errors; boundary/partial for pagination/filter defaults | Open | +| `backend/internal/services/log_service.go` | Missing: `L-L`; Partial: `L-L` | `backend/internal/services/log_service_test.go` | happy read/stream/filter; source/read failure path; boundary/partial for empty logs and limit/offset branches | Open | +| `backend/internal/api/handlers/import_handler.go` | Missing: `L-L`; Partial: `L-L` | `backend/internal/api/handlers/import_handler_test.go` | happy import start/status; bind/parse/service failure path; boundary/partial for unsupported type/empty payload | Open | +| `backend/internal/cerberus/rate_limit.go` | Missing: `L-L`; Partial: `L-L` | `backend/internal/cerberus/rate_limit_test.go` | happy allow path; blocked/over-limit error path; boundary/partial for burst/window thresholds | Open | + +Patch triage completion rule: +- Replace placeholders with exact Codecov ranges and keep one-to-one mapping between each range and at least one concrete test case. +- Do not close this remediation until every listed placeholder range is replaced and verified as covered. + +### 12.4 Backend Test Strategy by File (Happy + Error + Boundary/Partial) + +#### Wave 1 — Zero-Coverage First (fastest patch gain) + +1. `backend/internal/services/mail_service.go` (0.00%, 22 missing) + - Happy: successful send/build with valid config. + - Error: transport/auth/template failures. + - Boundary/partial: empty optional fields, nil config branches. + +2. `backend/internal/crowdsec/hub_sync.go` (0.00%, 10 missing + 6 partial) + - Happy: successful hub sync and update path. + - Error: HTTP error, non-2xx response, malformed payload. + - Boundary/partial: empty decision set, optional/legacy field branches. + +3. `backend/internal/api/handlers/auth_handler.go` (0.00%, 15 missing) + - Happy: valid auth request returns expected status/body. + - Error: bind/validation/service auth failures. + - Boundary/partial: missing header/cookie/token branches. + +4. `backend/internal/services/backup_service.go` (0.00%, 5 missing + 3 partial) + - Happy: backup create/list/restore success branch. + - Error: filesystem/database operation failures. + - Boundary/partial: empty backup inventory and retention-window edges. + +#### Wave 2 — Mid-Coverage Expansion + +5. `backend/internal/services/proxyhost_service.go` (55.88%, 14 missing + 1 partial) +6. `backend/internal/api/handlers/crowdsec_handler.go` (30.00%, 9 missing + 5 partial) + +For both: +- Happy path operation success. +- Error path for validation/bind/service failures. +- Boundary/partial branches for optional flags/default values. + +#### Wave 3 — High-Coverage Tail Cleanup + +7. `backend/internal/api/handlers/user_handler.go` (72.09%, 6 missing + 6 partial) +8. `backend/internal/services/log_service.go` (73.91%, 6 missing + 6 partial) +9. `backend/internal/api/handlers/import_handler.go` (67.85%, 3 missing + 6 partial) +10. `backend/internal/cerberus/rate_limit.go` (93.33%, 3 missing + 3 partial) + +For all: +- Close remaining missing branches first. +- Then resolve partials with targeted boundary tests matching exact triaged ranges. + +### 12.5 Concrete Run Sequence (Backend Coverage + Targeted Tests + Re-run) + +Use this execution order for each wave: + +1. Baseline backend coverage (project-approved): + - VS Code task: `Test: Backend with Coverage` (if present) + - Script: `scripts/go-test-coverage.sh` + +2. Targeted package-level test runs for quick feedback: + - `cd /projects/Charon/backend && go test -cover ./internal/services -run 'Mail|Backup|ProxyHost|Log'` + - `cd /projects/Charon/backend && go test -cover ./internal/crowdsec -run 'HubSync'` + - `cd /projects/Charon/backend && go test -cover ./internal/api/handlers -run 'Auth|CrowdSec|User|Import'` + - `cd /projects/Charon/backend && go test -cover ./internal/cerberus -run 'RateLimit'` + +3. Full backend coverage re-run after each wave: + - `scripts/go-test-coverage.sh` + +4. Patch verification loop: + - Re-open Codecov Patch view. + - Replace remaining placeholders with exact unresolved ranges. + - Add next targeted tests for those exact ranges. + - Re-run Step 2 and Step 3 until all patch ranges are covered. + +5. Final validation: + - `cd /projects/Charon/backend && go test ./...` + - `scripts/go-test-coverage.sh` + - Confirm Codecov patch coverage for backend modified lines reaches 100%. + +### 12.6 Acceptance Criteria (Backend Remediation Section) + +- Patch-triage table is fully populated with exact Codecov ranges (no placeholders left). +- Zero-coverage files (`mail_service.go`, `hub_sync.go`, `auth_handler.go`, `backup_service.go`) are covered first. +- Each file has tests for happy path, error path, and boundary/partial branches. +- Concrete run sequence executed: baseline -> targeted go test -> coverage re-run -> patch verify loop. +- Existing frontend unit-test coverage plan remains unchanged and intact. +- No E2E requirement is introduced in this backend remediation section.