chore: enhance coverage with new unit tests for various handlers and services

This commit is contained in:
GitHub Actions
2026-02-16 06:06:45 +00:00
parent 3a25782a11
commit 21b0f7908f
12 changed files with 570 additions and 7 deletions

1
.gitignore vendored
View File

@@ -309,3 +309,4 @@ frontend/temp**
playwright-output/**
validation-evidence/**
.github/agents/# Tools Configuration.md
docs/plans/codecove_patch_report.md

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<mail-m1>-L<mail-m2>`, `L<mail-m3>-L<mail-m4>` | `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<hub-m1>-L<hub-m2>`; Partial: `L<hub-p1>-L<hub-p2>` | `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<auth-m1>-L<auth-m2>`, `L<auth-m3>-L<auth-m4>` | `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<backup-m1>-L<backup-m2>`; Partial: `L<backup-p1>-L<backup-p2>` | `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<proxyhost-m1>-L<proxyhost-m2>`; Partial: `L<proxyhost-p1>-L<proxyhost-p2>` | `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<crowdsec-handler-m1>-L<crowdsec-handler-m2>`; Partial: `L<crowdsec-handler-p1>-L<crowdsec-handler-p2>` | `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<user-handler-m1>-L<user-handler-m2>`; Partial: `L<user-handler-p1>-L<user-handler-p2>` | `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<log-service-m1>-L<log-service-m2>`; Partial: `L<log-service-p1>-L<log-service-p2>` | `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<import-handler-m1>-L<import-handler-m2>`; Partial: `L<import-handler-p1>-L<import-handler-p2>` | `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<rate-limit-m1>-L<rate-limit-m2>`; Partial: `L<rate-limit-p1>-L<rate-limit-p2>` | `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.