From ffe23273afb04b6524720be0fb98d451973c458c Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 10 Oct 2026 19:47:33 +0000 Subject: [PATCH 1/6] test: add a unique in-memory database helper for backend tests --- backend/internal/api/handlers/testdb.go | 13 +------ backend/internal/testutil/dsn.go | 51 +++++++++++++++++++++++++ backend/internal/testutil/dsn_test.go | 51 +++++++++++++++++++++++++ codecov.yml | 1 + 4 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 backend/internal/testutil/dsn.go create mode 100644 backend/internal/testutil/dsn_test.go diff --git a/backend/internal/api/handlers/testdb.go b/backend/internal/api/handlers/testdb.go index 14fb564c5..938d0dd5d 100644 --- a/backend/internal/api/handlers/testdb.go +++ b/backend/internal/api/handlers/testdb.go @@ -1,15 +1,11 @@ package handlers import ( - crand "crypto/rand" - "fmt" - "math/big" - "strings" "sync" "testing" - "time" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/testutil" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -74,12 +70,7 @@ func GetTemplateDB() (*gorm.DB, error) { // a busy timeout and WAL journal mode to reduce SQLITE locking during parallel tests. func OpenTestDB(t *testing.T) *gorm.DB { t.Helper() - // Append a timestamp/random suffix to ensure uniqueness even across parallel runs - dsnName := strings.ReplaceAll(t.Name(), "/", "_") - // Use crypto/rand for suffix generation in tests to avoid static analysis warnings - n, _ := crand.Int(crand.Reader, big.NewInt(10000)) - uniqueSuffix := fmt.Sprintf("%d%d", time.Now().UnixNano(), n.Int64()) - dsn := fmt.Sprintf("file:%s_%s?mode=memory&cache=shared&_journal_mode=WAL&_busy_timeout=5000", dsnName, uniqueSuffix) + dsn := testutil.MemoryDSN(t, "_journal_mode=WAL", "_busy_timeout=5000") db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) diff --git a/backend/internal/testutil/dsn.go b/backend/internal/testutil/dsn.go new file mode 100644 index 000000000..d0321543a --- /dev/null +++ b/backend/internal/testutil/dsn.go @@ -0,0 +1,51 @@ +package testutil + +import ( + "fmt" + "regexp" + "strings" + "sync/atomic" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var ( + dsnCounter atomic.Uint64 + dsnUnsafeRun = regexp.MustCompile(`[^A-Za-z0-9_]+`) +) + +// MemoryDSN returns a shared-cache in-memory SQLite DSN whose name has never +// been returned before in this process. The name combines the sanitized test +// name with a process-wide counter, so it stays unique across -count, shuffling, +// parallel tests and subtests. Optional params (for example +// "_busy_timeout=5000") are appended as query parameters. +func MemoryDSN(t *testing.T, params ...string) string { + t.Helper() + name := dsnUnsafeRun.ReplaceAllString(strings.ReplaceAll(t.Name(), "/", "_"), "_") + dsn := fmt.Sprintf("file:%s_%d?mode=memory&cache=shared", name, dsnCounter.Add(1)) + for _, p := range params { + dsn += "&" + p + } + return dsn +} + +// OpenMemoryDB opens a unique in-memory SQLite database with a silent logger +// and a busy timeout. The connection pool is closed when the test finishes. +func OpenMemoryDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(MemoryDSN(t, "_busy_timeout=5000")), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("failed to open in-memory db: %v", err) + } + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + }) + return db +} diff --git a/backend/internal/testutil/dsn_test.go b/backend/internal/testutil/dsn_test.go new file mode 100644 index 000000000..602d5f610 --- /dev/null +++ b/backend/internal/testutil/dsn_test.go @@ -0,0 +1,51 @@ +package testutil + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMemoryDSN_UniqueWithinTest(t *testing.T) { + a, b := MemoryDSN(t), MemoryDSN(t) + require.NotEqual(t, a, b) + require.True(t, strings.HasPrefix(a, "file:TestMemoryDSN_UniqueWithinTest_")) + require.Contains(t, a, "?mode=memory&cache=shared") +} + +func TestMemoryDSN_Subtests(t *testing.T) { + seen := map[string]bool{MemoryDSN(t): true} + for _, name := range []string{"a/b", "a b", "same", "same"} { + t.Run(name, func(t *testing.T) { + dsn := MemoryDSN(t) + require.False(t, seen[dsn]) + seen[dsn] = true + // Only the characters of the sanitized name may appear before the query. + base := strings.SplitN(strings.TrimPrefix(dsn, "file:"), "?", 2)[0] + require.NotContains(t, base, "/") + require.NotContains(t, base, " ") + }) + } +} + +func TestMemoryDSN_AppendsParams(t *testing.T) { + dsn := MemoryDSN(t, "_journal_mode=WAL", "_busy_timeout=5000") + require.True(t, strings.HasSuffix(dsn, "&_journal_mode=WAL&_busy_timeout=5000")) +} + +func TestOpenMemoryDB_IsolatedAndClosedOnCleanup(t *testing.T) { + var closedProbe func() error + t.Run("inner", func(t *testing.T) { + db := OpenMemoryDB(t) + require.NoError(t, db.Exec("CREATE TABLE x (id INTEGER)").Error) + sqlDB, err := db.DB() + require.NoError(t, err) + closedProbe = sqlDB.Ping + require.NoError(t, closedProbe()) + + other := OpenMemoryDB(t) + require.Error(t, other.Exec("SELECT * FROM x").Error, "databases must not share state") + }) + require.Error(t, closedProbe(), "pool must be closed after the test") +} diff --git a/codecov.yml b/codecov.yml index 2bd1ec3d8..a02662f21 100644 --- a/codecov.yml +++ b/codecov.yml @@ -155,6 +155,7 @@ ignore: # These files contain testing helpers that take *testing.T and are only # callable from *_test.go files - they cannot be covered by production code - "backend/internal/api/handlers/testdb.go" + - "backend/internal/testutil/dsn.go" - "backend/internal/api/handlers/test_helpers.go" # DNS provider implementations (tested via integration tests, not unit tests) From 20872c98284d4d86dcdb7f247dca26132996f6e2 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 10 Oct 2026 19:57:16 +0000 Subject: [PATCH 2/6] test: make the CrowdSec tests repeatable in one process --- backend/internal/crowdsec/console_enroll_test.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/backend/internal/crowdsec/console_enroll_test.go b/backend/internal/crowdsec/console_enroll_test.go index e1bade857..ee247297e 100644 --- a/backend/internal/crowdsec/console_enroll_test.go +++ b/backend/internal/crowdsec/console_enroll_test.go @@ -13,10 +13,10 @@ import ( "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" "gorm.io/gorm" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/testutil" ) type stubEnvExecutor struct { @@ -62,9 +62,7 @@ func (s *stubEnvExecutor) lastArgs() []string { func openConsoleTestDB(t *testing.T) *gorm.DB { t.Helper() - dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) - require.NoError(t, err) + db := testutil.OpenMemoryDB(t) require.NoError(t, db.AutoMigrate(&models.CrowdsecConsoleEnrollment{})) return db } From 4d7b38a2a27b49352e64ca4ff2e9dc76b3cc853f Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 10 Oct 2026 21:29:21 +0000 Subject: [PATCH 3/6] test: make the handler tests repeatable in one process --- .../api/handlers/auth_handler_test.go | 5 ++- .../api/handlers/banner_handler_test.go | 3 +- .../internal/api/handlers/benchmark_test.go | 4 +- .../certificate_handler_export_auth_test.go | 4 +- ...certificate_handler_patch_coverage_test.go | 45 ++++++++++--------- .../certificate_handler_security_test.go | 9 ++-- .../api/handlers/certificate_handler_test.go | 29 ++++++------ .../certificate_handler_upload_export_test.go | 12 ++--- .../api/handlers/credential_handler_test.go | 3 +- .../api/handlers/custom_theme_handler_test.go | 4 +- .../api/handlers/db_health_handler_test.go | 15 ++++--- .../api/handlers/dns_provider_handler_test.go | 3 +- .../api/handlers/domain_handler_test.go | 3 +- .../api/handlers/emergency_handler_test.go | 3 +- .../api/handlers/handlers_blackbox_test.go | 3 +- .../api/handlers/logo_handler_test.go | 3 +- .../handlers/manual_challenge_scoping_test.go | 3 +- .../notification_template_handler_test.go | 17 +++---- .../api/handlers/password_guard_test.go | 4 +- .../internal/api/handlers/perf_assert_test.go | 3 +- .../api/handlers/permission_helpers_test.go | 5 ++- .../internal/api/handlers/pr_coverage_test.go | 15 ++++--- ...roxy_host_handler_security_headers_test.go | 3 +- .../proxy_host_handler_update_test.go | 5 ++- .../security_handler_settings_test.go | 3 +- .../api/handlers/settings_handler_test.go | 5 ++- .../system_permissions_handler_test.go | 5 ++- .../api/handlers/user_integration_test.go | 3 +- 28 files changed, 122 insertions(+), 97 deletions(-) diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go index 7b42c3205..13d7e02f6 100644 --- a/backend/internal/api/handlers/auth_handler_test.go +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -13,6 +13,7 @@ import ( "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/security" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/testutil" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -22,7 +23,7 @@ import ( ) func setupAuthHandler(t *testing.T) (*AuthHandler, *gorm.DB) { - dbName := "file:" + t.Name() + "?mode=memory&cache=shared" + dbName := testutil.MemoryDSN(t) db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) require.NoError(t, err) _ = db.AutoMigrate(&models.User{}, &models.Setting{}) @@ -910,7 +911,7 @@ func TestAuthHandler_ChangePassword_Errors(t *testing.T) { // setupAuthHandlerWithDB creates an AuthHandler with DB access for forward auth tests func setupAuthHandlerWithDB(t *testing.T) (*AuthHandler, *gorm.DB) { - dbName := "file:" + t.Name() + "?mode=memory&cache=shared" + dbName := testutil.MemoryDSN(t) db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) require.NoError(t, err) _ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.ProxyHost{}) diff --git a/backend/internal/api/handlers/banner_handler_test.go b/backend/internal/api/handlers/banner_handler_test.go index 94ffe232c..3fccb9b73 100644 --- a/backend/internal/api/handlers/banner_handler_test.go +++ b/backend/internal/api/handlers/banner_handler_test.go @@ -19,6 +19,7 @@ import ( "gorm.io/gorm/logger" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/testutil" ) // minimalWebP is a 1×1 pixel valid WebP (RIFF header). @@ -38,7 +39,7 @@ var minimalWebP = []byte{ func setupBannerTestDB(t *testing.T) *gorm.DB { t.Helper() - dsn := fmt.Sprintf("file:banner_test_%s?mode=memory&cache=shared", t.Name()) + dsn := testutil.MemoryDSN(t) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) diff --git a/backend/internal/api/handlers/benchmark_test.go b/backend/internal/api/handlers/benchmark_test.go index 437451ec2..14be87561 100644 --- a/backend/internal/api/handlers/benchmark_test.go +++ b/backend/internal/api/handlers/benchmark_test.go @@ -3,9 +3,11 @@ package handlers import ( "bytes" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" + "time" "github.com/gin-gonic/gin" "gorm.io/driver/sqlite" @@ -345,7 +347,7 @@ func BenchmarkSecurityHandler_GetStatus_Parallel(b *testing.B) { func BenchmarkSecurityHandler_ListDecisions_Parallel(b *testing.B) { gin.SetMode(gin.ReleaseMode) // Use file-based SQLite with WAL mode for parallel testing - db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_journal_mode=WAL"), &gorm.Config{ + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s_%d?mode=memory&cache=shared&_journal_mode=WAL", b.Name(), time.Now().UnixNano())), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { diff --git a/backend/internal/api/handlers/certificate_handler_export_auth_test.go b/backend/internal/api/handlers/certificate_handler_export_auth_test.go index 3b69f357c..5ed2baf6e 100644 --- a/backend/internal/api/handlers/certificate_handler_export_auth_test.go +++ b/backend/internal/api/handlers/certificate_handler_export_auth_test.go @@ -3,7 +3,6 @@ package handlers import ( "bytes" "encoding/json" - "fmt" "net/http" "net/http/httptest" "testing" @@ -19,6 +18,7 @@ import ( "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/testutil" ) const exportTestPassword = "correct-password" @@ -37,7 +37,7 @@ type exportHarness struct { func newExportHarness(t *testing.T, guard PasswordAttemptGuard, preMiddleware ...gin.HandlerFunc) *exportHarness { t.Helper() gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{}, &models.Setting{})) diff --git a/backend/internal/api/handlers/certificate_handler_patch_coverage_test.go b/backend/internal/api/handlers/certificate_handler_patch_coverage_test.go index 5ab0ffb25..455d6ba4f 100644 --- a/backend/internal/api/handlers/certificate_handler_patch_coverage_test.go +++ b/backend/internal/api/handlers/certificate_handler_patch_coverage_test.go @@ -18,13 +18,14 @@ import ( "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/testutil" ) // --- Delete UUID path with backup service --- func TestDelete_UUID_WithBackup_Success(t *testing.T) { tmpDir := t.TempDir() - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -50,7 +51,7 @@ func TestDelete_UUID_WithBackup_Success(t *testing.T) { func TestDelete_UUID_NotFound(t *testing.T) { tmpDir := t.TempDir() - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -70,7 +71,7 @@ func TestDelete_UUID_NotFound(t *testing.T) { func TestDelete_UUID_InUse(t *testing.T) { tmpDir := t.TempDir() - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -94,7 +95,7 @@ func TestDelete_UUID_InUse(t *testing.T) { func TestDelete_UUID_BackupLowSpace(t *testing.T) { tmpDir := t.TempDir() - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -119,7 +120,7 @@ func TestDelete_UUID_BackupLowSpace(t *testing.T) { func TestDelete_UUID_BackupSpaceCheckError(t *testing.T) { tmpDir := t.TempDir() - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -146,7 +147,7 @@ func TestDelete_UUID_BackupSpaceCheckError(t *testing.T) { func TestDelete_UUID_BackupCreateError(t *testing.T) { tmpDir := t.TempDir() - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -174,7 +175,7 @@ func TestDelete_UUID_BackupCreateError(t *testing.T) { func TestDelete_UUID_WithNotification(t *testing.T) { tmpDir := t.TempDir() - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.Setting{}, &models.Notification{}, &models.NotificationProvider{})) @@ -199,7 +200,7 @@ func TestDelete_UUID_WithNotification(t *testing.T) { func TestValidate_Success(t *testing.T) { tmpDir := t.TempDir() - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -230,7 +231,7 @@ func TestValidate_Success(t *testing.T) { func TestValidate_InvalidCert(t *testing.T) { tmpDir := t.TempDir() - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -259,7 +260,7 @@ func TestValidate_InvalidCert(t *testing.T) { func TestValidate_NoCertFile(t *testing.T) { tmpDir := t.TempDir() - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -279,7 +280,7 @@ func TestValidate_NoCertFile(t *testing.T) { func TestValidate_WithKeyAndChain(t *testing.T) { tmpDir := t.TempDir() - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -323,7 +324,7 @@ func TestValidate_WithKeyAndChain(t *testing.T) { // --- Get handler DB error (non-NotFound) --- func TestGet_DBError(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) // Deliberately don't migrate - any query will fail with "no such table" @@ -345,7 +346,7 @@ func TestGet_DBError(t *testing.T) { func TestExport_IncludeKey_MissingPassword(t *testing.T) { tmpDir := t.TempDir() - dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + dsn := testutil.MemoryDSN(t) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{})) @@ -367,7 +368,7 @@ func TestExport_IncludeKey_MissingPassword(t *testing.T) { func TestExport_CertNotFound(t *testing.T) { tmpDir := t.TempDir() - dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + dsn := testutil.MemoryDSN(t) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -388,7 +389,7 @@ func TestExport_CertNotFound(t *testing.T) { func TestExport_ServiceError(t *testing.T) { tmpDir := t.TempDir() - dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + dsn := testutil.MemoryDSN(t) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -416,7 +417,7 @@ func TestExport_ServiceError(t *testing.T) { func TestDelete_NumericID_UsageCheckError(t *testing.T) { tmpDir := t.TempDir() - dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + dsn := testutil.MemoryDSN(t) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) // no ProxyHost → IsCertificateInUse fails @@ -436,7 +437,7 @@ func TestDelete_NumericID_UsageCheckError(t *testing.T) { func TestDelete_NumericID_LowDiskSpace(t *testing.T) { tmpDir := t.TempDir() - dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + dsn := testutil.MemoryDSN(t) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -463,7 +464,7 @@ func TestDelete_NumericID_LowDiskSpace(t *testing.T) { func TestDelete_NumericID_BackupError(t *testing.T) { tmpDir := t.TempDir() - dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + dsn := testutil.MemoryDSN(t) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -490,7 +491,7 @@ func TestDelete_NumericID_BackupError(t *testing.T) { func TestDelete_NumericID_DeleteError(t *testing.T) { tmpDir := t.TempDir() - dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + dsn := testutil.MemoryDSN(t) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.SSLCertificate{})) @@ -513,7 +514,7 @@ func TestDelete_NumericID_DeleteError(t *testing.T) { func TestDelete_UUID_UsageCheckInternalError(t *testing.T) { tmpDir := t.TempDir() - dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + dsn := testutil.MemoryDSN(t) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) // no ProxyHost → IsCertificateInUse fails @@ -538,7 +539,7 @@ func TestDelete_UUID_UsageCheckInternalError(t *testing.T) { // --- sendDeleteNotification: rate limit --- func TestSendDeleteNotification_RateLimit(t *testing.T) { - dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + dsn := testutil.MemoryDSN(t) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -574,7 +575,7 @@ func TestUpdate_EmptyUUID(t *testing.T) { // --- Update: DB error (non-ErrCertNotFound) → lines 223-225 --- func TestUpdate_DBError(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) // Deliberately no AutoMigrate → ssl_certificates table absent → "no such table" error diff --git a/backend/internal/api/handlers/certificate_handler_security_test.go b/backend/internal/api/handlers/certificate_handler_security_test.go index d28757cd8..5adde5138 100644 --- a/backend/internal/api/handlers/certificate_handler_security_test.go +++ b/backend/internal/api/handlers/certificate_handler_security_test.go @@ -12,11 +12,12 @@ import ( "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/testutil" ) // TestCertificateHandler_Delete_RequiresAuth tests that delete requires authentication func TestCertificateHandler_Delete_RequiresAuth(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } @@ -45,7 +46,7 @@ func TestCertificateHandler_Delete_RequiresAuth(t *testing.T) { // TestCertificateHandler_List_RequiresAuth tests that list requires authentication func TestCertificateHandler_List_RequiresAuth(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } @@ -74,7 +75,7 @@ func TestCertificateHandler_List_RequiresAuth(t *testing.T) { // TestCertificateHandler_Upload_RequiresAuth tests that upload requires authentication func TestCertificateHandler_Upload_RequiresAuth(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } @@ -103,7 +104,7 @@ func TestCertificateHandler_Upload_RequiresAuth(t *testing.T) { // TestCertificateHandler_Delete_DiskSpaceCheck tests the disk space check before backup func TestCertificateHandler_Delete_DiskSpaceCheck(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } diff --git a/backend/internal/api/handlers/certificate_handler_test.go b/backend/internal/api/handlers/certificate_handler_test.go index e15e5ce09..688996481 100644 --- a/backend/internal/api/handlers/certificate_handler_test.go +++ b/backend/internal/api/handlers/certificate_handler_test.go @@ -25,6 +25,7 @@ import ( "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/testutil" ) // mockAuthMiddleware adds an admin session (real context key shapes) to the context for testing @@ -71,7 +72,7 @@ func openCertHandlerTestDB(t *testing.T, filename string) *gorm.DB { } func TestDeleteCertificate_InUse(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } @@ -161,7 +162,7 @@ func TestDeleteCertificate_CreatesBackup(t *testing.T) { // Test that backup failure prevents deletion func TestDeleteCertificate_BackupFailure(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } @@ -208,7 +209,7 @@ func TestDeleteCertificate_BackupFailure(t *testing.T) { // Test that in-use check does not create a backup func TestDeleteCertificate_InUse_NoBackup(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } @@ -297,7 +298,7 @@ func (m *mockBackupService) GetAvailableSpace() (int64, error) { // Test List handler func TestCertificateHandler_List(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } @@ -324,7 +325,7 @@ func TestCertificateHandler_List(t *testing.T) { // Test Upload handler with missing name func TestCertificateHandler_Upload_MissingName(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } @@ -352,7 +353,7 @@ func TestCertificateHandler_Upload_MissingName(t *testing.T) { // Test Upload handler missing certificate_file func TestCertificateHandler_Upload_MissingCertFile(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } @@ -382,7 +383,7 @@ func TestCertificateHandler_Upload_MissingCertFile(t *testing.T) { // Test Upload handler missing key_file func TestCertificateHandler_Upload_MissingKeyFile(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } @@ -408,7 +409,7 @@ func TestCertificateHandler_Upload_MissingKeyFile(t *testing.T) { } func TestCertificateHandler_Upload_MissingKeyFile_MultipartWithCert(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } @@ -452,7 +453,7 @@ func TestCertificateHandler_Upload_MissingKeyFile_MultipartWithCert(t *testing.T // Test Upload handler success path using a mock CertificateService func TestCertificateHandler_Upload_Success(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } @@ -530,7 +531,7 @@ func generateSelfSignedCertPEM() (certPEM, keyPEM string, err error) { // TestCertificateHandler_Upload_WithNotificationService verifies that the notification // path is exercised when a non-nil NotificationService is provided. func TestCertificateHandler_Upload_WithNotificationService(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.Setting{}, &models.NotificationProvider{})) @@ -564,7 +565,7 @@ func TestCertificateHandler_Upload_WithNotificationService(t *testing.T) { // Test Delete with invalid ID format func TestDeleteCertificate_InvalidID(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } @@ -589,7 +590,7 @@ func TestDeleteCertificate_InvalidID(t *testing.T) { // Test Delete with ID = 0 func TestDeleteCertificate_ZeroID(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } @@ -614,7 +615,7 @@ func TestDeleteCertificate_ZeroID(t *testing.T) { // Test Delete with low disk space func TestDeleteCertificate_LowDiskSpace(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } @@ -791,7 +792,7 @@ func TestDeleteCertificate_ValidLetsEncrypt_NotInUse(t *testing.T) { // Test Delete when IsCertificateInUse fails func TestDeleteCertificate_UsageCheckError(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("failed to open db: %v", err) } diff --git a/backend/internal/api/handlers/certificate_handler_upload_export_test.go b/backend/internal/api/handlers/certificate_handler_upload_export_test.go index d830373f5..474c7832c 100644 --- a/backend/internal/api/handlers/certificate_handler_upload_export_test.go +++ b/backend/internal/api/handlers/certificate_handler_upload_export_test.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/base64" "encoding/json" - "fmt" "mime/multipart" "net/http" "net/http/httptest" @@ -19,12 +18,13 @@ import ( "github.com/Wikid82/charon/backend/internal/crypto" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/testutil" ) // --- Upload: with chain file (covers chain_file multipart branch) --- func TestCertificateHandler_Upload_WithChainFile(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -61,7 +61,7 @@ func TestCertificateHandler_Upload_WithChainFile(t *testing.T) { // --- Upload: invalid cert data --- func TestCertificateHandler_Upload_InvalidCertData(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -106,7 +106,7 @@ func newTestEncSvc(t *testing.T) *crypto.EncryptionService { // --- Validate handler with key and chain --- func TestCertificateHandler_Validate_WithKeyAndChain(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -140,7 +140,7 @@ func TestCertificateHandler_Validate_WithKeyAndChain(t *testing.T) { } func TestCertificateHandler_Validate_InvalidCert(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) @@ -172,7 +172,7 @@ func TestCertificateHandler_Validate_InvalidCert(t *testing.T) { } func TestCertificateHandler_Validate_MissingCertFile(t *testing.T) { - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) diff --git a/backend/internal/api/handlers/credential_handler_test.go b/backend/internal/api/handlers/credential_handler_test.go index c045d30d0..6ae1f17e7 100644 --- a/backend/internal/api/handlers/credential_handler_test.go +++ b/backend/internal/api/handlers/credential_handler_test.go @@ -14,6 +14,7 @@ import ( "github.com/Wikid82/charon/backend/internal/crypto" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/testutil" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -34,7 +35,7 @@ func setupCredentialHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB, *models.DN router := gin.New() // Use test name for unique database with WAL mode to avoid locking issues - dbName := fmt.Sprintf("file:%s?mode=memory&cache=shared&_journal_mode=WAL", t.Name()) + dbName := testutil.MemoryDSN(t, "_journal_mode=WAL") db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) require.NoError(t, err) diff --git a/backend/internal/api/handlers/custom_theme_handler_test.go b/backend/internal/api/handlers/custom_theme_handler_test.go index 16d0ef7fb..477f41fcd 100644 --- a/backend/internal/api/handlers/custom_theme_handler_test.go +++ b/backend/internal/api/handlers/custom_theme_handler_test.go @@ -3,7 +3,6 @@ package handlers import ( "bytes" "encoding/json" - "fmt" "net/http" "net/http/httptest" "testing" @@ -16,11 +15,12 @@ import ( "gorm.io/gorm/logger" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/testutil" ) func setupCustomThemeHandlerDB(t *testing.T) *gorm.DB { t.Helper() - dsn := fmt.Sprintf("file:custom_theme_handler_test_%s?mode=memory&cache=shared", t.Name()) + dsn := testutil.MemoryDSN(t) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) diff --git a/backend/internal/api/handlers/db_health_handler_test.go b/backend/internal/api/handlers/db_health_handler_test.go index b4e4f2342..9a6aec2b1 100644 --- a/backend/internal/api/handlers/db_health_handler_test.go +++ b/backend/internal/api/handlers/db_health_handler_test.go @@ -12,6 +12,7 @@ import ( "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/database" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/testutil" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -38,7 +39,7 @@ func createTestSQLiteDB(dbPath string) error { func TestDBHealthHandler_Check_Healthy(t *testing.T) { // Create in-memory database - dbPath := "file::memory:?cache=shared" + dbPath := testutil.MemoryDSN(t) db, err := database.Connect(dbPath) require.NoError(t, err) @@ -86,7 +87,7 @@ func TestDBHealthHandler_Check_WithBackupService(t *testing.T) { require.NoError(t, err) // Create in-memory database for handler - handlerDBPath := "file::memory:?cache=shared" + handlerDBPath := testutil.MemoryDSN(t) db, err := database.Connect(handlerDBPath) require.NoError(t, err) @@ -148,7 +149,7 @@ func TestDBHealthHandler_Check_WALMode(t *testing.T) { func TestDBHealthHandler_ResponseJSONTags(t *testing.T) { - dbPath := "file::memory:?cache=shared" + dbPath := testutil.MemoryDSN(t) db, err := database.Connect(dbPath) require.NoError(t, err) @@ -178,7 +179,7 @@ func TestDBHealthHandler_ResponseJSONTags(t *testing.T) { } func TestNewDBHealthHandler(t *testing.T) { - memDBPath := "file::memory:?cache=shared" + memDBPath := testutil.MemoryDSN(t) db, err := database.Connect(memDBPath) require.NoError(t, err) @@ -273,7 +274,7 @@ func TestDBHealthHandler_Check_UsesDedicatedConnection_NotSharedDB(t *testing.T) // h.db: a healthy, unrelated in-memory connection — if Check ever fell // back to checking this instead of dbPath, the test would observe a // false "healthy" result. - sharedDB, err := database.Connect("file::memory:?cache=shared") + sharedDB, err := database.Connect(testutil.MemoryDSN(t)) require.NoError(t, err) // dbPath: a genuinely corrupted on-disk file, deliberately unrelated to @@ -318,7 +319,7 @@ func TestDBHealthHandler_Check_UsesDedicatedConnection_NotSharedDB(t *testing.T) func TestDBHealthHandler_Check_BackupServiceError(t *testing.T) { // Create database - memDBPath := "file::memory:?cache=shared" + memDBPath := testutil.MemoryDSN(t) db, err := database.Connect(memDBPath) require.NoError(t, err) @@ -360,7 +361,7 @@ func TestDBHealthHandler_Check_BackupServiceError(t *testing.T) { func TestDBHealthHandler_Check_BackupTimeZero(t *testing.T) { // Create database - memDBPath := "file::memory:?cache=shared" + memDBPath := testutil.MemoryDSN(t) db, err := database.Connect(memDBPath) require.NoError(t, err) diff --git a/backend/internal/api/handlers/dns_provider_handler_test.go b/backend/internal/api/handlers/dns_provider_handler_test.go index e300021d2..78de45d3f 100644 --- a/backend/internal/api/handlers/dns_provider_handler_test.go +++ b/backend/internal/api/handlers/dns_provider_handler_test.go @@ -17,6 +17,7 @@ import ( "github.com/Wikid82/charon/backend/internal/crypto" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/testutil" "github.com/Wikid82/charon/backend/pkg/dnsprovider" _ "github.com/Wikid82/charon/backend/pkg/dnsprovider/builtin" // Auto-register DNS providers _ "github.com/Wikid82/charon/backend/pkg/dnsprovider/custom" // Auto-register custom providers (manual) @@ -1179,7 +1180,7 @@ func (endpointPolicyFakeProvider) TestCredentials(creds map[string]string) error func TestDNSProviderHandler_TestCredentials_EndpointPolicyAndRoleGate(t *testing.T) { gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{}) require.NoError(t, err) sqlDB, err := db.DB() require.NoError(t, err) diff --git a/backend/internal/api/handlers/domain_handler_test.go b/backend/internal/api/handlers/domain_handler_test.go index eff88c6c2..26bf103f9 100644 --- a/backend/internal/api/handlers/domain_handler_test.go +++ b/backend/internal/api/handlers/domain_handler_test.go @@ -14,12 +14,13 @@ import ( "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/testutil" ) func setupDomainTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { t.Helper() - dsn := "file:" + t.Name() + "?mode=memory&cache=shared" + dsn := testutil.MemoryDSN(t) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.Domain{}, &models.Notification{}, &models.NotificationProvider{})) diff --git a/backend/internal/api/handlers/emergency_handler_test.go b/backend/internal/api/handlers/emergency_handler_test.go index f67278642..79ba609da 100644 --- a/backend/internal/api/handlers/emergency_handler_test.go +++ b/backend/internal/api/handlers/emergency_handler_test.go @@ -21,6 +21,7 @@ import ( "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/testutil" ) func TestIsTransientSQLiteError(t *testing.T) { @@ -75,7 +76,7 @@ func jsonReader(data interface{}) io.Reader { } func setupEmergencyTestDB(t *testing.T) *gorm.DB { - dsn := "file:" + t.Name() + "?mode=memory&cache=shared" + dsn := testutil.MemoryDSN(t) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) diff --git a/backend/internal/api/handlers/handlers_blackbox_test.go b/backend/internal/api/handlers/handlers_blackbox_test.go index f67b85544..cb202623f 100644 --- a/backend/internal/api/handlers/handlers_blackbox_test.go +++ b/backend/internal/api/handlers/handlers_blackbox_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/testutil" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -25,7 +26,7 @@ import ( ) func setupImportTestDB(t *testing.T) *gorm.DB { - dsn := "file:" + t.Name() + "?mode=memory&cache=shared" + dsn := testutil.MemoryDSN(t) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) if err != nil { panic("failed to connect to test database") diff --git a/backend/internal/api/handlers/logo_handler_test.go b/backend/internal/api/handlers/logo_handler_test.go index 7ceef7d72..58296b310 100644 --- a/backend/internal/api/handlers/logo_handler_test.go +++ b/backend/internal/api/handlers/logo_handler_test.go @@ -19,6 +19,7 @@ import ( "gorm.io/gorm/logger" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/testutil" ) // minimalPNG is a 1×1 pixel valid PNG (67 bytes). @@ -39,7 +40,7 @@ var minimalSVG = []byte(` Date: Sat, 10 Oct 2026 21:32:27 +0000 Subject: [PATCH 4/6] test: guard against shared database names in the repeatable packages --- .../internal/api/handlers/benchmark_test.go | 5 +- backend/internal/testutil/dsn.go | 4 +- backend/internal/testutil/dsn_guard_test.go | 100 ++++++++++++++++++ backend/internal/testutil/dsn_test.go | 17 +++ 4 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 backend/internal/testutil/dsn_guard_test.go diff --git a/backend/internal/api/handlers/benchmark_test.go b/backend/internal/api/handlers/benchmark_test.go index 14be87561..bc4c43068 100644 --- a/backend/internal/api/handlers/benchmark_test.go +++ b/backend/internal/api/handlers/benchmark_test.go @@ -3,11 +3,9 @@ package handlers import ( "bytes" "encoding/json" - "fmt" "net/http" "net/http/httptest" "testing" - "time" "github.com/gin-gonic/gin" "gorm.io/driver/sqlite" @@ -16,6 +14,7 @@ import ( "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/testutil" ) // setupBenchmarkDB creates an in-memory SQLite database for benchmarks @@ -347,7 +346,7 @@ func BenchmarkSecurityHandler_GetStatus_Parallel(b *testing.B) { func BenchmarkSecurityHandler_ListDecisions_Parallel(b *testing.B) { gin.SetMode(gin.ReleaseMode) // Use file-based SQLite with WAL mode for parallel testing - db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s_%d?mode=memory&cache=shared&_journal_mode=WAL", b.Name(), time.Now().UnixNano())), &gorm.Config{ + db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(b, "_journal_mode=WAL")), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { diff --git a/backend/internal/testutil/dsn.go b/backend/internal/testutil/dsn.go index d0321543a..c7d96e934 100644 --- a/backend/internal/testutil/dsn.go +++ b/backend/internal/testutil/dsn.go @@ -22,7 +22,7 @@ var ( // name with a process-wide counter, so it stays unique across -count, shuffling, // parallel tests and subtests. Optional params (for example // "_busy_timeout=5000") are appended as query parameters. -func MemoryDSN(t *testing.T, params ...string) string { +func MemoryDSN(t testing.TB, params ...string) string { t.Helper() name := dsnUnsafeRun.ReplaceAllString(strings.ReplaceAll(t.Name(), "/", "_"), "_") dsn := fmt.Sprintf("file:%s_%d?mode=memory&cache=shared", name, dsnCounter.Add(1)) @@ -34,7 +34,7 @@ func MemoryDSN(t *testing.T, params ...string) string { // OpenMemoryDB opens a unique in-memory SQLite database with a silent logger // and a busy timeout. The connection pool is closed when the test finishes. -func OpenMemoryDB(t *testing.T) *gorm.DB { +func OpenMemoryDB(t testing.TB) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(MemoryDSN(t, "_busy_timeout=5000")), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), diff --git a/backend/internal/testutil/dsn_guard_test.go b/backend/internal/testutil/dsn_guard_test.go new file mode 100644 index 000000000..2ef442580 --- /dev/null +++ b/backend/internal/testutil/dsn_guard_test.go @@ -0,0 +1,100 @@ +package testutil + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// dsnGuardPackages lists the package directories (relative to the backend +// module root) whose tests are repeatable in one process (-count=N, -shuffle). +// Their tests must obtain in-memory databases from MemoryDSN/OpenMemoryDB and +// never spell a shared-cache DSN themselves: a hand-built name derived from +// t.Name() collides across -count runs. Add a package here once it is clean. +var dsnGuardPackages = []string{ + "internal/crowdsec", + "internal/api/handlers", +} + +// sharedCacheMarker is split so this file does not contain the literal itself. +const sharedCacheMarker = "cache=" + "shared" + +// backendRoot locates the backend module root (the directory holding go.mod) +// by walking up from this source file. +func backendRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + require.True(t, ok, "cannot locate source file") + dir := filepath.Dir(file) + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + require.NotEqual(t, dir, parent, "go.mod not found above %s", file) + dir = parent + } +} + +// findSharedCacheLiterals returns "path:line" for every line of every +// *_test.go file under dir (recursively) containing the shared-cache marker. +func findSharedCacheLiterals(t *testing.T, root, dir string) []string { + t.Helper() + var hits []string + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, "_test.go") { + return nil + } + data, err := os.ReadFile(path) // #nosec G304 G122 -- test-only walk of a fixed in-repo directory + if err != nil { + return err + } + if !bytes.Contains(data, []byte(sharedCacheMarker)) { + return nil + } + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + rel = path + } + for i, line := range strings.Split(string(data), "\n") { + if strings.Contains(line, sharedCacheMarker) { + hits = append(hits, rel+":"+strconv.Itoa(i+1)) + } + } + return nil + }) + require.NoError(t, err) + return hits +} + +func TestNoSharedCacheLiteralsInRepeatablePackages(t *testing.T) { + root := backendRoot(t) + for _, pkg := range dsnGuardPackages { + dir := filepath.Join(root, filepath.FromSlash(pkg)) + _, err := os.Stat(dir) + require.NoError(t, err, "guarded package directory %s is missing; update dsnGuardPackages", pkg) + if hits := findSharedCacheLiterals(t, root, dir); len(hits) > 0 { + t.Errorf("%s: tests must use testutil.MemoryDSN/OpenMemoryDB instead of a hand-written %q DSN "+ + "(shared names collide under -count/-shuffle); offending locations:\n %s", + pkg, sharedCacheMarker, strings.Join(hits, "\n ")) + } + } +} + +func TestFindSharedCacheLiterals_ReportsFileAndLine(t *testing.T) { + root := t.TempDir() + body := "package x\n\nvar a = 1\nvar dsn = \"file:x?mode=memory&" + sharedCacheMarker + "\"\n" + require.NoError(t, os.WriteFile(filepath.Join(root, "bad_test.go"), []byte(body), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "clean_test.go"), []byte("package x\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "notest.go"), []byte(body), 0o600)) + require.Equal(t, []string{"bad_test.go:4"}, findSharedCacheLiterals(t, root, root)) +} diff --git a/backend/internal/testutil/dsn_test.go b/backend/internal/testutil/dsn_test.go index 602d5f610..52d94cc12 100644 --- a/backend/internal/testutil/dsn_test.go +++ b/backend/internal/testutil/dsn_test.go @@ -49,3 +49,20 @@ func TestOpenMemoryDB_IsolatedAndClosedOnCleanup(t *testing.T) { }) require.Error(t, closedProbe(), "pool must be closed after the test") } + +func BenchmarkMemoryDSN_AcceptsB(b *testing.B) { + first := MemoryDSN(b) + require.NotEqual(b, first, MemoryDSN(b)) + require.True(b, strings.HasPrefix(first, "file:BenchmarkMemoryDSN_AcceptsB_")) + db := OpenMemoryDB(b) + require.NoError(b, db.Exec("CREATE TABLE y (id INTEGER)").Error) +} + +func TestMemoryDSN_AcceptsTB(t *testing.T) { + var tb testing.TB = t + require.Contains(t, MemoryDSN(tb), "cache=shared") + res := testing.Benchmark(func(b *testing.B) { + require.Contains(b, MemoryDSN(b, "_journal_mode=WAL"), "&_journal_mode=WAL") + }) + require.NotNil(t, res) +} From 5d7b2f732b1fe82d292fb55f7f8661b859e9e4af Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 10 Oct 2026 21:36:47 +0000 Subject: [PATCH 5/6] ci: run the backend repeatability check weekly --- .github/workflows/backend-repeatability.yml | 52 +++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/backend-repeatability.yml diff --git a/.github/workflows/backend-repeatability.yml b/.github/workflows/backend-repeatability.yml new file mode 100644 index 000000000..bfd61a006 --- /dev/null +++ b/.github/workflows/backend-repeatability.yml @@ -0,0 +1,52 @@ +name: Backend Repeatability + +# Weekly proof that the backend tests of the repeatable packages still pass +# when run twice in one process in a random order under the race detector. +# Catches shared in-memory database names and other order/repeat coupling that +# a single `go test` run cannot see. Not a required check: a failure just +# fails the run. Companion guard: backend/internal/testutil/dsn_guard_test.go. + +on: + schedule: + - cron: '0 5 * * 4' # Thursdays at 05:00 UTC (no other weekly job runs then) + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + GOTOOLCHAIN: auto + +jobs: + repeatability: + name: Race + repeat + shuffle + runs-on: ubuntu-latest + timeout-minutes: 75 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + + - name: Run repeatability check + working-directory: backend + env: + CGO_ENABLED: 1 + run: | + # Derive the shuffle seed from the run id so a failure is replayable: + # re-run locally with the same -shuffle value. + SEED=$(( GITHUB_RUN_ID % 2147483646 + 1 )) + echo "Shuffle seed: ${SEED}" + echo "Shuffle seed: \`${SEED}\`" >> "$GITHUB_STEP_SUMMARY" + export TMPDIR="${RUNNER_TEMP}" + go test -race -count=2 -shuffle="${SEED}" -timeout=60m \ + ./internal/crowdsec/... ./internal/api/handlers/... From 7bded382c0fb67f45a94d3ef508458800d37fa13 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 10 Oct 2026 22:38:08 +0000 Subject: [PATCH 6/6] docs: update QA report for the repeatable backend tests --- docs/reports/qa_report.md | 63 +++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md index a1b905418..9e77ac973 100644 --- a/docs/reports/qa_report.md +++ b/docs/reports/qa_report.md @@ -1,41 +1,48 @@ -# QA and Security Report: fix/crowdsec-export-hub-state +# QA and Security Report: chore/repeatable-backend-tests Date: 2026-10-10 -Scope: full diff `origin/development...HEAD` (HEAD 8f1cb516, 5 commits, 16 files) against `SECURITY.md`, `CLAUDE.md` (Definition of Done) and the local plan for the CrowdSec hub-link, export-safety and import-state follow-ups. Parts: (A) an import keeps the running system's own state (installed hub items and their links, runtime state, the proxy/bouncer registration) and rollback never touches it; (B) the export is built in a temporary file under the data lock, checked against the import limits, and only then sent with a Content-Length, so a failure is reported before any byte of the download. +Scope: full diff `origin/development...HEAD` (HEAD 5d7b2f73, 5 commits, 35 files, tests/CI/config only) against `SECURITY.md`, `CLAUDE.md` (Definition of Done) and PR 1 of the local batch-3 plan (issue #1548). The branch adds `testutil.MemoryDSN`/`OpenMemoryDB`, moves the CrowdSec and handler tests onto them so `-count=N` and `-shuffle` work in one process, adds a guard test that forbids hand-written shared-cache DSNs in those two packages, and adds a weekly workflow that runs the repeat check. ## Verdict: PASS (no blocking findings) -No critical, high or medium findings. Two low hardening observations (F1, F2) and four informational items (F3-F6) are listed for the maintainer; none blocks merge. All Definition of Done gates pass. +No critical, high or medium findings. Four low or informational observations (F1-F4) are listed; none blocks merge. No application code changed (only `_test.go` files, `testutil/dsn.go`, `testdb.go` (a test helper), one workflow and `codecov.yml`), no model or migration file is in the diff, and no security-sensitive finding was found. ## Definition of Done evidence | Check | Command | Result | |---|---|---| -| Backend build and vet | `cd backend && go build ./... && go vet ./...` | pass, no output | +| Build and vet | `cd backend && go build ./... && go vet ./...` | pass, no output | | golangci-lint, backend | `golangci-lint run --config .golangci.yml ./...` | 0 issues | | golangci-lint, agent | `cd agent && golangci-lint run --config ../backend/.golangci.yml ./...` | 0 issues | -| Race tests, handlers | `go test -race -count=3 -run 'Export\|Keep\|Import\|Rollback' ./internal/api/handlers/...` | only `TestCertificateHandler_Export_PasswordGuard*` fail on the repeat runs; see below, pre-existing on the base branch. No data race reported. | -| Race tests, crowdsec | `go test -race -count=3 ./internal/crowdsec/... -run 'Export\|Keep\|Hub\|Secret'` | ok | -| Repeatability claim | the same tests on an export of `origin/development`, `-count=2` | `TestConsoleEnroll*`, `TestHeartbeatPoller_CheckHeartbeat` and `TestCertificateHandler_Export_PasswordGuard*` fail on the base branch too (state shared between runs), so the claim holds; none of them is in files this branch touches | -| GORM scan | not run | no `backend/internal/models/**`, migration or GORM query files in the diff (0 matches) | -| Lefthook | `lefthook run pre-commit --all-files` | all 17 hooks pass, including semgrep (367 rules, 1919 files, 0 findings). Working tree stayed clean. | +| Lefthook | `lefthook run pre-commit --all-files` | all 17 hooks pass, including actionlint, semgrep (367 rules, 1924 files, 0 findings), frontend type-check and lint. Working tree stayed clean (only the three untracked plan files). | | govulncheck | skill `security-scan-go-vuln` | 0 affected; 1 module-level advisory in a required module that Charon code does not call | -| Trivy | `trivy fs --scanners vuln,misconfig --severity HIGH,CRITICAL` on go.mod/go.sum (backend, agent), package files and Dockerfile | no HIGH/CRITICAL. The snap trivy cannot read `/projects`, so the manifests were copied under `$HOME` and removed afterwards. No container-image scan locally. | -| CodeQL | `bash scripts/install-codeql.sh`, then `lefthook run codeql` (CLI 2.27.2) | Go: 4 results, 4 suppressed (documented entries in unrelated files), 0 blocking. JS: 0 results. "All CodeQL checks passed". Run because the branch adds new protective code paths. | -| E2E | `npx playwright test --project=security-tests` against the existing `charon-e2e` container | 92 passed, 0 failed (3.3 min). The container was built from the last application-code commit; the later commits only touch tests, docs and the spec (verified with `git diff f9184d11..HEAD --stat`), so no rebuild was needed. | -| Coverage (given, not rerun) | prior runs | backend 91.3% stmt / 90.3% line, frontend 91.49% lines (no frontend changes), patch coverage 93.6% | -| Manual evidence (given) | throwaway container, HEAD 9d048912 | hub links, collections, runtime state file and bouncer registration unchanged across export then import; offline restart started CrowdSec; the export had none of those entries and a Content-Length | +| Trivy | `trivy fs --scanners vuln,misconfig --severity HIGH,CRITICAL` on go.mod/go.sum (backend, agent) and the frontend package files, copied under `$HOME` (the snap trivy cannot read `/projects`) and removed afterwards | no HIGH/CRITICAL | +| CodeQL | `lefthook run codeql` (CLI 2.27.2, already current) | Go and JS scans completed; JS 0 results; findings gate "0 blocking"; "All CodeQL checks passed"; parity check passed | +| GORM scan | not run | no `backend/internal/models/**`, migration or GORM query files in the diff (0 matches) | +| E2E | not run | no application code changed; the E2E image does not need a rebuild | +| Coverage (given, not rerun) | prior runs | backend 91.3% stmt / 90.3% line, frontend 91.49% lines (no frontend change), patch coverage 90.0% (the 2 uncovered lines are in the codecov-ignored `dsn.go`) | +| codecov.yml | `curl -s -m 30 --data-binary @codecov.yml https://codecov.io/validate` | `Valid!`; only `backend/internal/testutil/dsn.go` was added to `ignore` (1 line); `scripts/ci/check-codecov-trigger-parity.sh` passes | + +## Repeatability proof (run during this audit) + +| Run | Result | +|---|---| +| `go test -race -count=2 -shuffle=on -timeout=30m ./internal/crowdsec/...` | ok, 213 s | +| `go test -race -count=2 -timeout=40m -run 'TestAuthHandler\|TestSettingsHandler\|TestCertificateHandler_Export_PasswordGuard\|TestDeleteCertificate' ./internal/api/handlers/` | ok, 23 s | +| `go test -race -count=2 -run 'TestConsoleEnroll\|TestHeartbeatPoller_\|TestCertificateHandler_Export_PasswordGuard\|TestAuthHandler_GetAccessibleHosts_PermittedHosts' ./internal/crowdsec/ ./internal/api/handlers/` (the tests named on #1548) | ok, ok | +| `go test -count=1 ./internal/testutil/` | ok | +| Guard red/green: temporary `internal/crowdsec/zz_guard_probe_test.go` containing a shared-cache literal | guard failed and named `internal/crowdsec/zz_guard_probe_test.go:3`; probe file removed | +| Full handlers repetition | not rerun (14+ min); recorded in commit 1.3 as green: `-count=2` 829 s, `-count=3` 1268 s, `-shuffle=on -count=2` 840 s | ## Check-by-check results -1. **Keep rules on import.** `ImportKeeper` keeps preserved state, the hub tree (`hub/`, `config/hub/`) and every live symlink. A throwaway probe (removed afterwards) confirmed: a link to a directory outside the tree, a dangling link and a two-link loop are all kept, never followed and never removed by the clear step, and the file behind the outside link stayed untouched. `isLiveSymlink` rejects an empty path, a NUL byte and any path that is not `filepath.IsLocal` before it calls `Lstat` (`..`, absolute and empty paths all return false). In `extractArchive` the order is containment check first, then `skipArchiveEntry`, which tests `KeptOrBeneathKept` (shortest ancestor first) before the live-directory rule, so an entry beneath a kept link or kept directory is skipped before any filesystem write. A non-directory entry whose path is a surviving live directory is skipped, so an upload cannot replace a directory that holds kept items; a directory entry over a live directory is a no-op. Tar symlink and hardlink entries are ignored by the extractor, so an upload can never create or retarget a link, and regular files still go through `CreateFileNoSymlink`. Tests: `TestImportConfigKeepsLiveLinksAndHubAgainstArchive`, `TestImportConfigLinkToOutsideTreeIsKeptNotFollowed`, `TestSkipArchiveEntryAndLiveDirSet`, `TestImportKeeper`. See F3 and F4 for two robustness notes. -2. **Rollback and runtime state.** `RestoreKeeping` clears with the same predicate and `copyTree` skips kept entries, so a kept entry is neither deleted nor re-copied; covered by `TestClearAndRestoreKeepingHubLinks`, `TestImportConfigRollbackKeepsLiveState` and the earlier partial-failure tests (restore copy failure keeps live state). The runtime pid file is engine-owned (`IsEngineOwnedPath`), so it survives import, rollback and the preset apply snapshot/restore (`TestClearConfigKeepsPidFile`, `TestIsEngineOwnedPathPidFile`). The bouncer directory is treated as a stored secret (top level and under `config/`) and the editor still hides it and the hub tree (`TestEditorHidesPidBouncersAndHub`, `TestEditorHidesSecretsUsingSharedRule`); the editor's writability rule now uses the shared `IsHubPath`. -3. **Export.** The temporary file comes from `os.CreateTemp` (mode 0600) and is closed and removed by a `defer`, which also runs on a panic, an abort and a cancelled request; the unwritable-temp-directory path returns the fixed JSON error (`TestExportConfigTempFileFailureReturnsJSONError`). The data lock is held only inside `writeExportLocked` (build); streaming happens after it is released (`TestExportConfigDownloadDoesNotHoldDataLock`), and lock order is data lock then hub-service lock, with the export taking only the first, so no new ordering cycle exists. `DataFromReader` sends the exact size as Content-Length (E2E and unit test). The gzip route exclusion is untouched (`precompressedPaths`, `routes.go:1260`). Non-regular entries (pipes, sockets, devices, links) are skipped at listing time and re-checked with `Lstat` before open. Size checks reuse the import constants (50 MiB compressed, 500 MiB uncompressed, ratio 100), so a successful export can always be imported again; failure messages are fixed strings and no path or file name reaches the client (relative names go to the log only, sanitized). See F1 and F2. -4. **Concurrency.** `TestExportConfigConcurrentWithImports` and the repeated race runs above are clean. Cancelled requests abort without a body (`TestExportConfigCancelledRequestAbortsWithoutBody`). -5. **CodeQL.** See the table; no findings in changed files. -6. **Commits and wording.** `git log origin/development..HEAD --format=%s`: `test(e2e): add a fixme spec for the CrowdSec export download`, `fix: keep a CrowdSec import from disturbing the running system's own state`, `fix: build the CrowdSec export safely before sending it`, `docs: describe how a CrowdSec import keeps the running system's state`, `test: cover the CrowdSec export error paths`. All generic, none uses the `(security)` scope, none describes the work as a vulnerability fix, and a search of the full messages for secret, credential, vulnerability, exploit, injection and session terms found nothing. Public docs (`docs/features/crowdsec.md`, `ARCHITECTURE.md`) name no secret files, pid file or bouncer key. -7. **E2E tolerance.** The added case asserts status 200, the content type, an exact Content-Length match against the body, and that a `config.yaml` entry exists (`/^(config\/)?config\.yaml$/`). No status lists, catch blocks, skips, fixme markers, soft assertions or logging remain in the spec. See F6. -8. **Security-sensitive items.** None that needs a private advisory. The change is a protective default and a robustness fix on admin-authenticated endpoints. F1 is a low hardening item that requires local write access to the data folder. +1. **The DSN sweep did not change test semantics.** The 24 handler test files in the diff change only DSN construction: every non-DSN changed line was filtered out (the only other changes are `fmt` import removals and the `testutil` import additions). No `t.Skip`/`Skipf`/`Skip(` was added, no `func Test*` or `func Benchmark*` was removed, and no assertion changed. `git diff --numstat` shows every handler file is a 1:1 or 2:1 line swap. Cases checked by hand: the previously process-wide `file::memory:?cache=shared` sites (db_health tests, `pr_coverage_test.go`, `permission_helpers_test.go`, `system_permissions_handler_test.go`, `security_handler_settings_test.go` which had a hard-coded test name) now get one unique name per test, which is stored in a variable and reused where the old code shared it (for example `db_health_handler_test.go` passes the same DSN to `database.Connect` and to `NewDBHealthHandler`, so `CheckIntegrityDedicated` still opens the same named shared-cache database). No function calls `MemoryDSN` twice where the old code used one string twice (scripted check over all changed files: none). The unrelated-connection design of the dedicated-corruption test is kept (separate `MemoryDSN` for the unrelated handler connection, an on-disk file for the corrupt one). Tests that once relied on a database leaking from an earlier test would now fail, and none does. The benchmark file now uses `MemoryDSN(b, ...)`, whose per-call counter keeps every benchmark iteration set unique. +2. **`testutil.MemoryDSN` / `OpenMemoryDB`.** Uniqueness comes from a process-wide `atomic.Uint64` counter combined with the sanitized test name, so it holds across `-count`, `-shuffle`, parallel tests and subtests (tests: same-name subtests, two calls in one test). Sanitization replaces every run of non-`[A-Za-z0-9_]` characters (slashes, spaces, unicode, punctuation) with one underscore, so the name is always a valid SQLite file-URI path; a very long name only makes a long in-memory name (no filesystem is touched). Params are appended with `&` after the fixed `?mode=memory&cache=shared`, so the `?`/`&` ordering is right. It takes `testing.TB`, with a benchmark test. `OpenMemoryDB` closes the pool in `t.Cleanup` (tested: ping fails after the subtest). `handlers.OpenTestDB` keeps `_journal_mode=WAL` and `_busy_timeout=5000`, the silent logger and the close-on-cleanup; only the name suffix changed (counter instead of time plus random), so its 231 callers are unaffected. See F2 for a nit. +3. **Guard test.** It finds the module root by walking up from its own source file via `runtime.Caller` to the first `go.mod`, and fails loudly if a guarded directory is missing. It walks guarded packages recursively over `*_test.go` only and reports `path:line`; its own reporting is covered by a unit test and the red/green probe above. The `"cache=" + "shared"` split keeps the marker out of the guard's own file (the guard lives in `testutil`, which is not a guarded package anyway). The `#nosec G304 G122` comment is adequate: the path comes from a walk of a fixed in-repo directory in a test, with no external input. See F3 for the false-negative scope. +4. **Workflow `.github/workflows/backend-repeatability.yml`.** `actionlint` clean. `actions/checkout@3d3c42e5...` (v7) and `actions/setup-go@b7ad1dad...` (v7.0.0) match the SHAs and versions used in `quality-checks.yml` and the rest of the repo. Permissions: `contents: read` only; `persist-credentials: false`; no secrets; triggers are `schedule` and `workflow_dispatch` only (no `pull_request`, so nothing runs from forks). `concurrency` group per workflow and ref with `cancel-in-progress: false`. Cron `0 5 * * 4` (Thursday 05:00 UTC) collides with no other cron in the repo (checked all `cron:` lines). The run step has no interpolated untrusted input: the seed is `GITHUB_RUN_ID % 2147483646 + 1` (always 1..2147483646, so a valid `-shuffle` value), echoed to the log and the step summary so a failure is replayable, and `TMPDIR` is the runner temp directory. Timeouts: `go test -timeout=60m` inside a 75-minute job; the measured work is about 4 min (CrowdSec) plus about 14 min (handlers) with `-count=2` under `-race`, so there is wide headroom. It is not a required check. The plan suggested `-timeout=30m` and `timeout-minutes: 40`; the larger values are sensible given measured times (see F1 for a note on default-branch-only scheduling). +5. **Plan alignment.** Commit order and subjects match plan commits 1.1-1.5. The plan's "fixes for non-DSN shared state" in 1.3 turned out to be unneeded: the DSN sweep alone made every named test repeatable, which the runs above confirm. +6. **Commits.** `git log origin/development..HEAD --format=%s`: `test: add a unique in-memory database helper for backend tests`, `test: make the CrowdSec tests repeatable in one process`, `test: make the handler tests repeatable in one process`, `test: guard against shared database names in the repeatable packages`, `ci: run the backend repeatability check weekly`. All generic, none uses the `(security)` scope, no session ID or link. +7. **Security-sensitive items.** None. Nothing in the diff touches production code paths, secrets, tokens or CI permissions beyond read-only. ## Findings @@ -43,14 +50,12 @@ No critical, high or medium findings. ### Low -- **F1: low (hardening).** `backend/internal/crowdsec/export.go` `addExportFile` (the `os.Lstat` at about line 123 followed by `os.Open`): a regular file that is swapped for a pipe or link between the `Lstat` and the `Open` is opened anyway. A pipe would block the open while the data lock is held, stalling every other change to the CrowdSec folder until the process restarts; a link would be followed. Real risk is low: only a process that can already write to the data folder (the engine or its tooling running as the same user) can race it, and the window is microseconds. Suggested: open with `O_NONBLOCK|O_NOFOLLOW`, then `f.Stat()` and require a regular file that is `os.SameFile` as the `Lstat` result (the code already calls `f.Stat()` afterwards for the size check). -- **F2: low (resource use).** `crowdsec_handler.go` `buildExport`: the compressed-size limit (50 MiB) is checked after the whole archive is written, so an oversized folder is written to the temp directory (up to about 500 MiB of incompressible data) before it is rejected, while the data lock is held. The temp directory may be memory-backed in some deployments. Admin-only and rare. Suggested: wrap the destination in a counting writer that returns `ErrExportTooLarge` as soon as the compressed byte count passes the limit. +- **F1: low (CI behavior).** `.github/workflows/backend-repeatability.yml`: GitHub runs `schedule` triggers only from the default branch, so after merge into `development` the weekly job first exists on `main` after the next promotion, and until then only `workflow_dispatch` on the branch works. Not a defect; mention in the PR body, and run one manual dispatch once the file is on a branch GitHub knows about. ### Informational -- **F3: info.** `isLiveSymlink` (`hubstate.go`) uses `Lstat`, which follows symlinks in intermediate path components, so on its own it answers for the link's target directory. It is safe today because every caller visits ancestors first (`emptyDirExceptRel` never descends into a kept entry, `copyTreeRel` skips before recursing, `KeptOrBeneathKept` runs shortest prefix first). Suggested: add a sentence to the doc comment and a unit case documenting that `keep("/x")` is not meaningful on its own. -- **F4: info.** `IsHubPath` and `IsEngineOwnedPath` match case-sensitively (as before), while `IsSecretPath` is case-insensitive. On a case-insensitive host volume mounted into the container, an upload entry in a different letter case could reach a protected directory. Linux volumes (the supported deployment) are unaffected. Optional: make the hub and engine-owned rules case-insensitive like the secret rule. -- **F5: info (pre-existing, test hygiene).** `TestConsoleEnroll*`, `TestHeartbeatPoller_CheckHeartbeat` and `TestCertificateHandler_Export_PasswordGuard*` fail when run twice in one process (`-count=2`), also on `origin/development`, which prevents repeat-run and flake-hunting runs of those packages. Suggested follow-up: remove the shared state (package-level variables or fixed paths) those tests rely on. -- **F6: info.** The new E2E assertion allows an optional `config/` prefix for `config.yaml`. This matches the two valid layouts the import accepts; tightening it to the layout the E2E container actually produces would remove the last bit of tolerance. +- **F2: info (nit).** `backend/internal/testutil/dsn.go` `MemoryDSN`: a test name that sanitizes to an empty string cannot happen (names always start with `Test`/`Benchmark`/`Example`/`Fuzz`), so no change is needed; if the helper is ever used from `TestMain` or an unnamed context, `t.Name()` would be empty and the DSN `file:_N` would still be unique and valid. +- **F3: info (guard scope).** `backend/internal/testutil/dsn_guard_test.go`: the guard only matches the literal `cache=shared` on a single source line, so a DSN assembled across lines or with a different spelling (for example `file::memory:` without `cache=shared`, or the marker built by concatenation like the guard's own constant) is not detected, and only `internal/crowdsec` and `internal/api/handlers` are covered (192 other `cache=shared` test lines exist in other packages that are not yet clean). Acceptable as a cheap tripwire. Optional: add other packages to `dsnGuardPackages` as they are cleaned up. +- **F4: info (follow-up, already planned).** Packages outside the two guarded ones still use name-derived or fixed shared-cache DSNs; extending the repeatability check to them is future work, not part of this PR. -Out-of-scope follow-ups already tracked in the plan (not new): none of F1-F6 are regressions from this branch. +Out-of-scope findings to file: none that need an issue beyond F3/F4 (low priority, optional).