package services import ( "archive/zip" "fmt" "os" "path/filepath" "testing" "time" "github.com/Wikid82/charon/backend/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func createSQLiteTestDB(t *testing.T, dbPath string) { t.Helper() db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) require.NoError(t, err) sqlDB, err := db.DB() require.NoError(t, err) t.Cleanup(func() { _ = sqlDB.Close() }) require.NoError(t, db.Exec("CREATE TABLE IF NOT EXISTS healthcheck (id INTEGER PRIMARY KEY, value TEXT)").Error) require.NoError(t, db.Exec("INSERT INTO healthcheck (value) VALUES (?)", "ok").Error) } func TestBackupService_CreateAndList(t *testing.T) { // Setup temp dirs tmpDir, err := os.MkdirTemp("", "cpm-backup-service-test") require.NoError(t, err) defer func() { _ = os.RemoveAll(tmpDir) }() dataDir := filepath.Join(tmpDir, "data") err = os.MkdirAll(dataDir, 0o700) require.NoError(t, err) // Create valid sqlite DB dbPath := filepath.Join(dataDir, "charon.db") createSQLiteTestDB(t, dbPath) // Create dummy caddy dir caddyDir := filepath.Join(dataDir, "caddy") err = os.MkdirAll(caddyDir, 0o700) require.NoError(t, err) err = os.WriteFile(filepath.Join(caddyDir, "caddy.json"), []byte("{}"), 0o600) require.NoError(t, err) cfg := &config.Config{DatabasePath: dbPath} service := NewBackupService(cfg) defer service.Stop() // Prevent goroutine leaks // Test Create filename, err := service.CreateBackup() require.NoError(t, err) assert.NotEmpty(t, filename) assert.FileExists(t, filepath.Join(service.BackupDir, filename)) // Test List backups, err := service.ListBackups() require.NoError(t, err) assert.Len(t, backups, 1) assert.Equal(t, filename, backups[0].Filename) assert.True(t, backups[0].Size > 0) // Test GetBackupPath path, err := service.GetBackupPath(filename) require.NoError(t, err) assert.Equal(t, filepath.Join(service.BackupDir, filename), path) // Test Restore err = service.RestoreBackup(filename) require.NoError(t, err) // DB file is staged for live rehydrate (not directly overwritten during unzip) assert.NotEmpty(t, service.restoreDBPath) assert.FileExists(t, service.restoreDBPath) // Test Delete err = service.DeleteBackup(filename) require.NoError(t, err) assert.NoFileExists(t, filepath.Join(service.BackupDir, filename)) // Test Delete Non-existent err = service.DeleteBackup("non-existent.zip") assert.Error(t, err) } func TestBackupService_Restore_ZipSlip(t *testing.T) { // Setup temp dirs tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), DatabaseName: "charon.db", } _ = os.MkdirAll(service.BackupDir, 0o700) // Create malicious zip zipPath := filepath.Join(service.BackupDir, "malicious.zip") // #nosec G304 -- Test creates malicious zip for security testing zipFile, err := os.Create(zipPath) require.NoError(t, err) w := zip.NewWriter(zipFile) dbEntry, err := w.Create("charon.db") require.NoError(t, err) _, err = dbEntry.Write([]byte("placeholder")) require.NoError(t, err) f, err := w.Create("../../../evil.txt") require.NoError(t, err) _, err = f.Write([]byte("evil")) require.NoError(t, err) _ = w.Close() _ = zipFile.Close() // Attempt restore err = service.RestoreBackup("malicious.zip") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid file path in archive") } func TestBackupService_PathTraversal(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), } // #nosec G301 -- Test backup directory needs standard Unix permissions _ = os.MkdirAll(service.BackupDir, 0o755) // Test GetBackupPath with traversal // Should return error _, err := service.GetBackupPath("../../etc/passwd") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid filename") // Test DeleteBackup with traversal // Should return error err = service.DeleteBackup("../../etc/passwd") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid filename") } func TestBackupService_RunScheduledBackup(t *testing.T) { // Setup temp dirs tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") // #nosec G301 -- Test data directory needs standard Unix permissions _ = os.MkdirAll(dataDir, 0o755) // Create valid sqlite DB dbPath := filepath.Join(dataDir, "charon.db") createSQLiteTestDB(t, dbPath) cfg := &config.Config{DatabasePath: dbPath} service := NewBackupService(cfg) defer service.Stop() // Prevent goroutine leaks // Run scheduled backup manually service.RunScheduledBackup() // Verify backup created backups, err := service.ListBackups() require.NoError(t, err) assert.Len(t, backups, 1) } func TestBackupService_CreateBackup_Errors(t *testing.T) { t.Run("missing database file", func(t *testing.T) { tmpDir := t.TempDir() cfg := &config.Config{DatabasePath: filepath.Join(tmpDir, "nonexistent.db")} service := NewBackupService(cfg) defer service.Stop() // Prevent goroutine leaks _, err := service.CreateBackup() assert.Error(t, err) }) t.Run("cannot create backup directory", func(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "charon.db") createSQLiteTestDB(t, dbPath) // Create backup dir as a file to cause mkdir error backupDir := filepath.Join(tmpDir, "backups") // #nosec G306 -- Test fixture file used to block directory creation _ = os.WriteFile(backupDir, []byte("blocking"), 0o644) service := &BackupService{ DataDir: tmpDir, BackupDir: backupDir, } _, err := service.CreateBackup() assert.Error(t, err) }) } func TestBackupService_RestoreBackup_Errors(t *testing.T) { t.Run("non-existent backup", func(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), } // #nosec G301 -- Test backup directory needs standard Unix permissions _ = os.MkdirAll(service.BackupDir, 0o755) err := service.RestoreBackup("nonexistent.zip") assert.Error(t, err) }) t.Run("invalid zip file", func(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), } // #nosec G301 -- Test backup directory needs standard Unix permissions _ = os.MkdirAll(service.BackupDir, 0o755) // Create invalid zip badZip := filepath.Join(service.BackupDir, "bad.zip") // #nosec G306 -- Test fixture file simulating invalid zip _ = os.WriteFile(badZip, []byte("not a zip"), 0o644) err := service.RestoreBackup("bad.zip") assert.Error(t, err) }) } func TestBackupService_ListBackups_EmptyDir(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ BackupDir: filepath.Join(tmpDir, "backups"), } // #nosec G301 -- Test backup directory needs standard Unix permissions _ = os.MkdirAll(service.BackupDir, 0o755) backups, err := service.ListBackups() require.NoError(t, err) assert.Empty(t, backups) } func TestBackupService_ListBackups_MissingDir(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ BackupDir: filepath.Join(tmpDir, "nonexistent"), } backups, err := service.ListBackups() require.NoError(t, err) assert.Empty(t, backups) } func TestBackupService_CleanupOldBackups(t *testing.T) { t.Run("deletes backups exceeding retention", func(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), } // #nosec G301 -- Test backup directory needs standard Unix permissions _ = os.MkdirAll(service.BackupDir, 0o755) // Create 10 backup files manually with different timestamps for i := 0; i < 10; i++ { filename := fmt.Sprintf("backup_2025-01-%02d_10-00-00.zip", i+1) zipPath := filepath.Join(service.BackupDir, filename) // #nosec G304 -- Test creates backup files with known paths f, err := os.Create(zipPath) require.NoError(t, err) _ = f.Close() // Set modification time to ensure proper ordering modTime := time.Date(2025, 1, i+1, 10, 0, 0, 0, time.UTC) _ = os.Chtimes(zipPath, modTime, modTime) } backups, err := service.ListBackups() require.NoError(t, err) assert.Len(t, backups, 10) // Keep only 3 backups deleted, err := service.CleanupOldBackups(3) require.NoError(t, err) assert.Equal(t, 7, deleted) // Verify only 3 remain backups, err = service.ListBackups() require.NoError(t, err) assert.Len(t, backups, 3) }) t.Run("keeps all when under retention", func(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), } _ = os.MkdirAll(service.BackupDir, 0o750) // Create 3 backup files for i := 0; i < 3; i++ { filename := fmt.Sprintf("backup_2025-01-%02d_10-00-00.zip", i+1) zipPath := filepath.Join(service.BackupDir, filename) f, err := os.Create(zipPath) //nolint:gosec // G304: Test file in temp directory require.NoError(t, err) _ = f.Close() } // Try to keep 7 - should delete nothing deleted, err := service.CleanupOldBackups(7) require.NoError(t, err) assert.Equal(t, 0, deleted) backups, err := service.ListBackups() require.NoError(t, err) assert.Len(t, backups, 3) }) t.Run("minimum retention of 1", func(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), } // #nosec G301 -- Test fixture directory with standard permissions _ = os.MkdirAll(service.BackupDir, 0o755) // Create 5 backup files for i := 0; i < 5; i++ { filename := fmt.Sprintf("backup_2025-01-%02d_10-00-00.zip", i+1) // #nosec G304 -- Test fixture file with controlled path zipPath := filepath.Join(service.BackupDir, filename) f, err := os.Create(zipPath) //nolint:gosec // G304: Test file creation require.NoError(t, err) _ = f.Close() modTime := time.Date(2025, 1, i+1, 10, 0, 0, 0, time.UTC) _ = os.Chtimes(zipPath, modTime, modTime) } // Try to keep 0 - should keep at least 1 deleted, err := service.CleanupOldBackups(0) require.NoError(t, err) assert.Equal(t, 4, deleted) backups, err := service.ListBackups() require.NoError(t, err) assert.Len(t, backups, 1) }) t.Run("empty backup directory", func(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ BackupDir: filepath.Join(tmpDir, "backups"), } _ = os.MkdirAll(service.BackupDir, 0o750) deleted, err := service.CleanupOldBackups(7) require.NoError(t, err) assert.Equal(t, 0, deleted) }) } func TestBackupService_GetLastBackupTime(t *testing.T) { t.Run("returns latest backup time", func(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") _ = os.MkdirAll(dataDir, 0o750) dbPath := filepath.Join(dataDir, "charon.db") createSQLiteTestDB(t, dbPath) cfg := &config.Config{DatabasePath: dbPath} service := NewBackupService(cfg) defer service.Stop() // Prevent goroutine leaks // Create a backup _, err := service.CreateBackup() require.NoError(t, err) lastBackup, err := service.GetLastBackupTime() require.NoError(t, err) assert.False(t, lastBackup.IsZero()) assert.WithinDuration(t, time.Now(), lastBackup, 5*time.Second) }) t.Run("returns zero time when no backups", func(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ BackupDir: filepath.Join(tmpDir, "backups"), } _ = os.MkdirAll(service.BackupDir, 0o750) lastBackup, err := service.GetLastBackupTime() require.NoError(t, err) assert.True(t, lastBackup.IsZero()) }) } func TestDefaultBackupRetention(t *testing.T) { assert.Equal(t, 7, DefaultBackupRetention) } // Phase 1: Critical Coverage Gaps func TestNewBackupService_BackupDirCreationError(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") _ = os.MkdirAll(dataDir, 0o750) // Create a file where backup dir should be to cause mkdir error backupDirPath := filepath.Join(dataDir, "backups") // #nosec G306 -- Test fixture file used to block directory creation _ = os.WriteFile(backupDirPath, []byte("blocking"), 0o644) dbPath := filepath.Join(dataDir, "charon.db") createSQLiteTestDB(t, dbPath) cfg := &config.Config{DatabasePath: dbPath} // Should not panic even if backup dir creation fails (error is logged, not returned) service := NewBackupService(cfg) defer service.Stop() // Prevent goroutine leaks assert.NotNil(t, service) // Service is created but backup dir creation failed (logged as error) } func TestNewBackupService_CronScheduleError(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") _ = os.MkdirAll(dataDir, 0o750) dbPath := filepath.Join(dataDir, "charon.db") createSQLiteTestDB(t, dbPath) cfg := &config.Config{DatabasePath: dbPath} // Service should initialize without panic even if cron has issues // (error is logged, not returned) service := NewBackupService(cfg) defer service.Stop() // Prevent goroutine leaks assert.NotNil(t, service) assert.NotNil(t, service.Cron) } func TestRunScheduledBackup_CreateBackupFails(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") _ = os.MkdirAll(dataDir, 0o750) // Create a fake database path - don't create the actual file dbPath := filepath.Join(dataDir, "charon.db") // Important: Don't create the database file, so CreateBackup will fail // when it tries to verify the database exists cfg := &config.Config{DatabasePath: dbPath} service := NewBackupService(cfg) defer service.Stop() // Prevent goroutine leaks // Should not panic when backup fails service.RunScheduledBackup() // Any zip files that might have been created should be empty or partial backups, err := service.ListBackups() require.NoError(t, err) // CreateBackup creates the file first, then errors if DB doesn't exist // So there might be an empty zip file - but no successful backup for _, b := range backups { // If any backups exist, verify they are empty (0 bytes) as the backup failed assert.Equal(t, int64(0), b.Size, "Failed backup should create empty or no zip file") } } // Phase 2: Error Path Coverage func TestRunScheduledBackup_CleanupFails(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") _ = os.MkdirAll(dataDir, 0o750) dbPath := filepath.Join(dataDir, "charon.db") createSQLiteTestDB(t, dbPath) cfg := &config.Config{DatabasePath: dbPath} service := NewBackupService(cfg) defer service.Stop() // Prevent goroutine leaks createCalled := false cleanupCalled := false service.createBackup = func() (string, error) { createCalled = true return "backup_2026-01-01_00-00-00.zip", nil } service.cleanupOld = func(keep int) (int, error) { cleanupCalled = true assert.Equal(t, DefaultBackupRetention, keep) return 0, fmt.Errorf("forced cleanup failure") } // Should not panic when cleanup fails. service.RunScheduledBackup() assert.True(t, createCalled) assert.True(t, cleanupCalled) } func TestGetLastBackupTime_ListBackupsError(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ BackupDir: filepath.Join(tmpDir, "file_not_dir"), } // Create a file where directory should be _ = os.WriteFile(service.BackupDir, []byte("blocking"), 0o600) lastBackup, err := service.GetLastBackupTime() assert.Error(t, err) assert.True(t, lastBackup.IsZero()) } // Phase 3: Edge Cases func TestRunScheduledBackup_CleanupDeletesZero(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") _ = os.MkdirAll(dataDir, 0o750) dbPath := filepath.Join(dataDir, "charon.db") createSQLiteTestDB(t, dbPath) cfg := &config.Config{DatabasePath: dbPath} service := NewBackupService(cfg) defer service.Stop() // Prevent goroutine leaks // RunScheduledBackup creates 1 backup and tries to cleanup // Since we're below DefaultBackupRetention (7), no deletions should occur service.RunScheduledBackup() backups, err := service.ListBackups() require.NoError(t, err) assert.Equal(t, 1, len(backups)) } func TestCleanupOldBackups_PartialFailure(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), } _ = os.MkdirAll(service.BackupDir, 0o750) // Create 5 backup files for i := 0; i < 5; i++ { filename := fmt.Sprintf("backup_2025-01-%02d_10-00-00.zip", i+1) // #nosec G304 -- Test fixture file with controlled path zipPath := filepath.Join(service.BackupDir, filename) f, err := os.Create(zipPath) //nolint:gosec // G304: Test file require.NoError(t, err) _ = f.Close() modTime := time.Date(2025, 1, i+1, 10, 0, 0, 0, time.UTC) _ = os.Chtimes(zipPath, modTime, modTime) // Make files 0 and 1 read-only to cause deletion to fail if i < 2 { _ = os.Chmod(zipPath, 0o444) // #nosec G302 -- Intentionally testing permission-based deletion failure } } // Try to keep only 2 backups (should delete 3, but 2 will fail) deleted, err := service.CleanupOldBackups(2) require.NoError(t, err) // Should delete at least 1 (file 2), files 0 and 1 may fail due to permissions assert.GreaterOrEqual(t, deleted, 1) assert.LessOrEqual(t, deleted, 3) } func TestCreateBackup_CaddyDirMissing(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") _ = os.MkdirAll(dataDir, 0o750) dbPath := filepath.Join(dataDir, "charon.db") createSQLiteTestDB(t, dbPath) // Explicitly NOT creating caddy directory cfg := &config.Config{DatabasePath: dbPath} service := NewBackupService(cfg) defer service.Stop() // Prevent goroutine leaks // Should succeed with warning logged filename, err := service.CreateBackup() require.NoError(t, err) assert.NotEmpty(t, filename) // Verify backup contains DB but not caddy/ backupPath := filepath.Join(service.BackupDir, filename) assert.FileExists(t, backupPath) } func TestCreateBackup_CaddyDirUnreadable(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") _ = os.MkdirAll(dataDir, 0o750) dbPath := filepath.Join(dataDir, "charon.db") createSQLiteTestDB(t, dbPath) // Create caddy dir with no read permissions caddyDir := filepath.Join(dataDir, "caddy") _ = os.MkdirAll(caddyDir, 0o750) _ = os.Chmod(caddyDir, 0o000) defer func() { _ = os.Chmod(caddyDir, 0o700) }() // #nosec G302 -- Test restores permissions / Restore for cleanup cfg := &config.Config{DatabasePath: dbPath} service := NewBackupService(cfg) defer service.Stop() // Prevent goroutine leaks // Should succeed with warning logged about caddy dir filename, err := service.CreateBackup() require.NoError(t, err) assert.NotEmpty(t, filename) } // Phase 4 & 5: Deep Coverage func TestBackupService_addToZip_FileNotFound(t *testing.T) { tmpDir := t.TempDir() zipPath := filepath.Join(tmpDir, "test.zip") zipFile, err := os.Create(zipPath) //nolint:gosec // G304: Test file in temp directory require.NoError(t, err) defer func() { _ = zipFile.Close() }() w := zip.NewWriter(zipFile) defer func() { _ = w.Close() }() service := &BackupService{} // Try to add non-existent file - should return nil (silent skip) err = service.addToZip(w, "/nonexistent/file.txt", "test.txt") assert.NoError(t, err, "addToZip should return nil for non-existent files") } func TestBackupService_addToZip_FileOpenError(t *testing.T) { // Skip this test on root user (e.g., in some CI environments) if os.Getuid() == 0 { t.Skip("Skipping test that requires non-root user for permission testing") } tmpDir := t.TempDir() zipPath := filepath.Join(tmpDir, "test.zip") zipFile, err := os.Create(zipPath) //nolint:gosec // G304: Test file in temp directory require.NoError(t, err) defer func() { _ = zipFile.Close() }() w := zip.NewWriter(zipFile) defer func() { _ = w.Close() }() // Create a directory (not a file) that cannot be opened as a file srcPath := filepath.Join(tmpDir, "unreadable_dir") err = os.MkdirAll(srcPath, 0o750) require.NoError(t, err) // Create a file inside with no read permissions unreadablePath := filepath.Join(srcPath, "unreadable.txt") err = os.WriteFile(unreadablePath, []byte("test"), 0o000) require.NoError(t, err) defer func() { _ = os.Chmod(unreadablePath, 0o600) }() // #nosec G302 -- Test restores permissions / Restore for cleanup service := &BackupService{} // Should return permission error err = service.addToZip(w, unreadablePath, "test.txt") assert.Error(t, err) } // Additional tests for improved coverage func TestBackupService_Start(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") _ = os.MkdirAll(dataDir, 0o750) dbPath := filepath.Join(dataDir, "charon.db") createSQLiteTestDB(t, dbPath) cfg := &config.Config{DatabasePath: dbPath} service := NewBackupService(cfg) // Test Start service.Start() // Verify cron is running (indirectly by checking entries exist) entries := service.Cron.Entries() assert.NotEmpty(t, entries, "Cron scheduler should have at least one entry") // Stop to cleanup 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") _ = os.MkdirAll(dataDir, 0o750) dbPath := filepath.Join(dataDir, "charon.db") createSQLiteTestDB(t, dbPath) cfg := &config.Config{DatabasePath: dbPath} service := NewBackupService(cfg) defer service.Stop() // Create more backups than DefaultBackupRetention to trigger cleanup for i := 0; i < DefaultBackupRetention+3; i++ { filename := fmt.Sprintf("backup_2025-01-%02d_10-00-00.zip", i+1) zipPath := filepath.Join(service.BackupDir, filename) f, err := os.Create(zipPath) //nolint:gosec // G304: Test file in temp directory require.NoError(t, err) _ = f.Close() modTime := time.Date(2025, 1, i+1, 10, 0, 0, 0, time.UTC) _ = os.Chtimes(zipPath, modTime, modTime) } // RunScheduledBackup creates a new backup and triggers cleanup service.RunScheduledBackup() // Verify cleanup happened (should have DefaultBackupRetention backups) backups, err := service.ListBackups() require.NoError(t, err) // The count should be at or around DefaultBackupRetention after cleanup assert.LessOrEqual(t, len(backups), DefaultBackupRetention+1) } func TestCleanupOldBackups_ListBackupsError(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ BackupDir: filepath.Join(tmpDir, "file_not_dir"), } // Create a file where directory should be _ = os.WriteFile(service.BackupDir, []byte("blocking"), 0o600) deleted, err := service.CleanupOldBackups(5) assert.Error(t, err) assert.Equal(t, 0, deleted) assert.Contains(t, err.Error(), "list backups for cleanup") } func TestListBackups_EntryInfoError(t *testing.T) { // This tests the entry.Info() error path which is hard to trigger // The best we can do is test that valid entries work correctly tmpDir := t.TempDir() service := &BackupService{ BackupDir: filepath.Join(tmpDir, "backups"), } _ = os.MkdirAll(service.BackupDir, 0o750) // Create a valid zip file zipPath := filepath.Join(service.BackupDir, "backup_test.zip") f, err := os.Create(zipPath) //nolint:gosec // G304: Test file in temp directory require.NoError(t, err) _ = f.Close() // Create a non-zip file that should be ignored txtPath := filepath.Join(service.BackupDir, "readme.txt") _ = os.WriteFile(txtPath, []byte("not a backup"), 0o600) // Create a directory that should be ignored dirPath := filepath.Join(service.BackupDir, "subdir.zip") _ = os.MkdirAll(dirPath, 0o750) // #nosec G301 -- test fixture backups, err := service.ListBackups() require.NoError(t, err) // Should only list the zip file assert.Len(t, backups, 1) assert.Equal(t, "backup_test.zip", backups[0].Filename) } func TestRestoreBackup_PathTraversal_FirstCheck(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), } _ = os.MkdirAll(service.BackupDir, 0o750) // #nosec G301 -- test fixture // Test path traversal with filename containing path separator err := service.RestoreBackup("../../../etc/passwd") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid filename") } func TestRestoreBackup_PathTraversal_SecondCheck(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), } _ = os.MkdirAll(service.BackupDir, 0o750) // #nosec G301 -- test fixture // Test with a filename that passes the first check but could still // be problematic (this tests the second prefix check) err := service.RestoreBackup("../otherfile.zip") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid filename") } func TestDeleteBackup_PathTraversal_SecondCheck(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), } _ = os.MkdirAll(service.BackupDir, 0o750) // #nosec G301 -- test fixture // Test first check - filename with path separator err := service.DeleteBackup("sub/file.zip") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid filename") } func TestGetBackupPath_PathTraversal_SecondCheck(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), } _ = os.MkdirAll(service.BackupDir, 0o750) // #nosec G301 -- test fixture // Test first check - filename with path separator _, err := service.GetBackupPath("sub/file.zip") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid filename") } func TestUnzip_DirectoryCreation(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), DatabaseName: "charon.db", } _ = os.MkdirAll(service.BackupDir, 0o750) _ = os.MkdirAll(service.DataDir, 0o750) // Create a zip with nested directory structure zipPath := filepath.Join(service.BackupDir, "nested.zip") zipFile, err := os.Create(zipPath) //nolint:gosec // G304: Test file in temp directory require.NoError(t, err) w := zip.NewWriter(zipFile) dbEntry, err := w.Create("charon.db") require.NoError(t, err) _, err = dbEntry.Write([]byte("placeholder")) require.NoError(t, err) // Add a directory entry _, err = w.Create("subdir/") require.NoError(t, err) // Add a file in that directory f, err := w.Create("subdir/nested_file.txt") require.NoError(t, err) _, err = f.Write([]byte("nested content")) require.NoError(t, err) _ = w.Close() _ = zipFile.Close() // Restore the backup err = service.RestoreBackup("nested.zip") require.NoError(t, err) // Verify nested file was created content, err := os.ReadFile(filepath.Join(service.DataDir, "subdir", "nested_file.txt")) require.NoError(t, err) assert.Equal(t, "nested content", string(content)) } func TestUnzip_OpenFileError(t *testing.T) { // Skip on root if os.Getuid() == 0 { t.Skip("Skipping test that requires non-root user") } tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), } _ = os.MkdirAll(service.BackupDir, 0o750) // #nosec G301 -- test fixture _ = os.MkdirAll(service.DataDir, 0o750) // #nosec G301 -- test fixture // Create a valid zip zipPath := filepath.Join(service.BackupDir, "test.zip") zipFile, err := os.Create(zipPath) // #nosec G304 -- test fixture path require.NoError(t, err) w := zip.NewWriter(zipFile) f, err := w.Create("test.txt") require.NoError(t, err) _, err = f.Write([]byte("test content")) require.NoError(t, err) _ = w.Close() _ = zipFile.Close() // Make data dir read-only to cause OpenFile error _ = os.Chmod(service.DataDir, 0o400) // #nosec G302 -- Test intentionally sets restrictive permissions defer func() { _ = os.Chmod(service.DataDir, 0o755) }() // #nosec G302 -- Restoring permissions for cleanup err = service.RestoreBackup("test.zip") assert.Error(t, err) } func TestUnzip_FileOpenInZipError(t *testing.T) { // This tests the error path when f.Open() fails // Hard to trigger naturally, but we can test normal zip restore works tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), DatabaseName: "charon.db", } _ = os.MkdirAll(service.BackupDir, 0o750) // #nosec G301 -- test fixture _ = os.MkdirAll(service.DataDir, 0o750) // #nosec G301 -- test fixture // Create a valid zip with a file zipPath := filepath.Join(service.BackupDir, "valid.zip") zipFile, err := os.Create(zipPath) // #nosec G304 -- test fixture path require.NoError(t, err) w := zip.NewWriter(zipFile) dbEntry, err := w.Create("charon.db") require.NoError(t, err) _, err = dbEntry.Write([]byte("placeholder")) require.NoError(t, err) f, err := w.Create("test_file.txt") require.NoError(t, err) _, err = f.Write([]byte("file content")) require.NoError(t, err) _ = w.Close() _ = zipFile.Close() // Restore should work err = service.RestoreBackup("valid.zip") require.NoError(t, err) // Verify file was restored content, err := os.ReadFile(filepath.Join(service.DataDir, "test_file.txt")) require.NoError(t, err) assert.Equal(t, "file content", string(content)) } func TestAddDirToZip_WalkError(t *testing.T) { tmpDir := t.TempDir() zipPath := filepath.Join(tmpDir, "test.zip") zipFile, err := os.Create(zipPath) // #nosec G304 -- test fixture path require.NoError(t, err) defer func() { _ = zipFile.Close() }() w := zip.NewWriter(zipFile) defer func() { _ = w.Close() }() service := &BackupService{} // Try to walk a non-existent directory err = service.addDirToZip(w, "/nonexistent/path", "base") assert.Error(t, err) } func TestAddDirToZip_SkipsDirectories(t *testing.T) { tmpDir := t.TempDir() // Create directory structure srcDir := filepath.Join(tmpDir, "src") _ = os.MkdirAll(filepath.Join(srcDir, "subdir"), 0o750) // #nosec G301 -- test fixture _ = os.WriteFile(filepath.Join(srcDir, "file1.txt"), []byte("content1"), 0o600) // #nosec G306 -- test fixture _ = os.WriteFile(filepath.Join(srcDir, "subdir", "file2.txt"), []byte("content2"), 0o600) // #nosec G306 -- test fixture zipPath := filepath.Join(tmpDir, "test.zip") zipFile, err := os.Create(zipPath) // #nosec G304 -- test fixture path require.NoError(t, err) w := zip.NewWriter(zipFile) service := &BackupService{} err = service.addDirToZip(w, srcDir, "backup") require.NoError(t, err) _ = w.Close() _ = zipFile.Close() // Verify zip contains expected files r, err := zip.OpenReader(zipPath) require.NoError(t, err) defer func() { _ = r.Close() }() fileNames := make([]string, 0) for _, f := range r.File { fileNames = append(fileNames, f.Name) } assert.Contains(t, fileNames, "backup/file1.txt") assert.Contains(t, fileNames, "backup/subdir/file2.txt") } func TestGetAvailableSpace_Success(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ BackupDir: tmpDir, } space, err := service.GetAvailableSpace() require.NoError(t, err) assert.Greater(t, space, int64(0)) } func TestGetAvailableSpace_NonExistentDir(t *testing.T) { service := &BackupService{ BackupDir: "/this/path/does/not/exist/anywhere", } _, err := service.GetAvailableSpace() assert.Error(t, err) } // Additional edge case tests for better coverage func TestUnzip_CopyError(t *testing.T) { // Skip on root if os.Getuid() == 0 { t.Skip("Skipping test that requires non-root user") } tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), } _ = os.MkdirAll(service.BackupDir, 0o750) // #nosec G301 -- test directory _ = os.MkdirAll(service.DataDir, 0o750) // #nosec G301 -- test fixture // Create a valid zip zipPath := filepath.Join(service.BackupDir, "test.zip") zipFile, err := os.Create(zipPath) // #nosec G304 -- test fixture path require.NoError(t, err) w := zip.NewWriter(zipFile) f, err := w.Create("subdir/test.txt") require.NoError(t, err) _, err = f.Write([]byte("test content")) require.NoError(t, err) _ = w.Close() _ = zipFile.Close() // Create the subdir as read-only to cause copy error subDir := filepath.Join(service.DataDir, "subdir") _ = os.MkdirAll(subDir, 0o750) // #nosec G301 -- test directory _ = os.Chmod(subDir, 0o400) defer func() { _ = os.Chmod(subDir, 0o755) }() // #nosec G302 -- Restoring permissions for cleanup // Restore should fail because we can't write to subdir err = service.RestoreBackup("test.zip") assert.Error(t, err) } func TestCreateBackup_ZipWriterCloseError(t *testing.T) { // This is hard to trigger directly, but we can verify the code path // by creating a valid backup and ensuring proper cleanup tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") _ = os.MkdirAll(dataDir, 0o750) // #nosec G301 -- test directory dbPath := filepath.Join(dataDir, "charon.db") createSQLiteTestDB(t, dbPath) cfg := &config.Config{DatabasePath: dbPath} service := NewBackupService(cfg) defer service.Stop() // Create a backup successfully filename, err := service.CreateBackup() require.NoError(t, err) assert.NotEmpty(t, filename) // Verify the zip is valid by opening it backupPath := filepath.Join(service.BackupDir, filename) r, err := zip.OpenReader(backupPath) require.NoError(t, err) defer func() { _ = r.Close() }() // Verify it contains the database var foundDB bool for _, f := range r.File { if f.Name == "charon.db" { foundDB = true break } } assert.True(t, foundDB, "Backup should contain database file") } func TestAddToZip_CreateError(t *testing.T) { tmpDir := t.TempDir() zipPath := filepath.Join(tmpDir, "test.zip") zipFile, err := os.Create(zipPath) // #nosec G304 -- test fixture path require.NoError(t, err) defer func() { _ = zipFile.Close() }() w := zip.NewWriter(zipFile) // Create a source file srcPath := filepath.Join(tmpDir, "source.txt") _ = os.WriteFile(srcPath, []byte("test content"), 0o600) // #nosec G306 -- test fixture service := &BackupService{} // Normal addToZip should work err = service.addToZip(w, srcPath, "dest.txt") require.NoError(t, err) // Close the writer to finalize _ = w.Close() // Try to add to closed writer - this should fail w2 := zip.NewWriter(zipFile) _ = service.addToZip(w2, srcPath, "dest2.txt") // This may or may not error depending on internal state // The main point is we're testing the code path } func TestListBackups_IgnoresNonZipFiles(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ BackupDir: filepath.Join(tmpDir, "backups"), } _ = os.MkdirAll(service.BackupDir, 0o750) // #nosec G301 -- test fixture // Create various files _ = os.WriteFile(filepath.Join(service.BackupDir, "backup.zip"), []byte(""), 0o600) // #nosec G306 -- test fixture _ = os.WriteFile(filepath.Join(service.BackupDir, "backup.tar.gz"), []byte(""), 0o600) // #nosec G306 -- test fixture _ = os.WriteFile(filepath.Join(service.BackupDir, "readme.txt"), []byte(""), 0o600) // #nosec G306 -- test fixture _ = os.WriteFile(filepath.Join(service.BackupDir, ".hidden.zip"), []byte(""), 0o600) // #nosec G306 -- test fixture backups, err := service.ListBackups() require.NoError(t, err) // Should only list files ending in .zip assert.Len(t, backups, 2) // backup.zip and .hidden.zip names := make([]string, 0) for _, b := range backups { names = append(names, b.Filename) } assert.Contains(t, names, "backup.zip") assert.Contains(t, names, ".hidden.zip") } func TestRestoreBackup_CreatesNestedDirectories(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), DatabaseName: "charon.db", } _ = os.MkdirAll(service.BackupDir, 0o750) // #nosec G301 -- test fixture // Create a zip with deeply nested structure zipPath := filepath.Join(service.BackupDir, "nested.zip") zipFile, err := os.Create(zipPath) // #nosec G304 -- test fixture path require.NoError(t, err) w := zip.NewWriter(zipFile) dbEntry, err := w.Create("charon.db") require.NoError(t, err) _, err = dbEntry.Write([]byte("placeholder")) require.NoError(t, err) f, err := w.Create("a/b/c/d/deep_file.txt") require.NoError(t, err) _, err = f.Write([]byte("deep content")) require.NoError(t, err) _ = w.Close() _ = zipFile.Close() // DataDir doesn't exist yet err = service.RestoreBackup("nested.zip") require.NoError(t, err) // Verify deeply nested file was created content, err := os.ReadFile(filepath.Join(service.DataDir, "a", "b", "c", "d", "deep_file.txt")) require.NoError(t, err) assert.Equal(t, "deep content", string(content)) } func TestBackupService_FullCycle(t *testing.T) { // Full integration test: create, list, restore, delete tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") _ = os.MkdirAll(dataDir, 0o750) // #nosec G301 -- test directory // Create database and caddy config dbPath := filepath.Join(dataDir, "charon.db") createSQLiteTestDB(t, dbPath) caddyDir := filepath.Join(dataDir, "caddy") _ = os.MkdirAll(caddyDir, 0o750) // #nosec G301 -- test directory _ = os.WriteFile(filepath.Join(caddyDir, "config.json"), []byte(`{"original": true}`), 0o600) // #nosec G306 -- test fixture cfg := &config.Config{DatabasePath: dbPath} service := NewBackupService(cfg) defer service.Stop() // Create backup filename, err := service.CreateBackup() require.NoError(t, err) // Modify files _ = os.WriteFile(filepath.Join(caddyDir, "config.json"), []byte(`{"modified": true}`), 0o600) // #nosec G306 -- test fixture // Restore backup err = service.RestoreBackup(filename) require.NoError(t, err) // DB file is staged for live rehydrate (not directly overwritten during unzip) assert.NotEmpty(t, service.restoreDBPath) assert.FileExists(t, service.restoreDBPath) caddyContent, _ := os.ReadFile(filepath.Join(caddyDir, "config.json")) // #nosec G304 -- test fixture path assert.Equal(t, `{"original": true}`, string(caddyContent)) // List backups backups, err := service.ListBackups() require.NoError(t, err) assert.Len(t, backups, 1) // Get backup path path, err := service.GetBackupPath(filename) require.NoError(t, err) assert.FileExists(t, path) // Delete backup err = service.DeleteBackup(filename) require.NoError(t, err) // Verify deletion backups, err = service.ListBackups() require.NoError(t, err) assert.Empty(t, backups) } // TestBackupService_AddToZip_Errors tests addToZip error handling. func TestBackupService_AddToZip_Errors(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), } _ = os.MkdirAll(service.BackupDir, 0o750) // #nosec G301 -- test fixture t.Run("handle non-existent file gracefully", func(t *testing.T) { zipPath := filepath.Join(service.BackupDir, "test.zip") zipFile, err := os.Create(zipPath) // #nosec G304 -- test fixture path require.NoError(t, err) defer func() { _ = zipFile.Close() }() w := zip.NewWriter(zipFile) defer func() { _ = w.Close() }() // Try to add non-existent file - should return nil (graceful) err = service.addToZip(w, "/non/existent/file.txt", "file.txt") assert.NoError(t, err, "addToZip should handle non-existent files gracefully") }) t.Run("add valid file to zip", func(t *testing.T) { // Create test file testFile := filepath.Join(tmpDir, "test.txt") err := os.WriteFile(testFile, []byte("test content"), 0o600) // #nosec G306 -- test fixture require.NoError(t, err) zipPath := filepath.Join(service.BackupDir, "valid.zip") zipFile, err := os.Create(zipPath) // #nosec G304 -- test fixture path require.NoError(t, err) defer func() { _ = zipFile.Close() }() w := zip.NewWriter(zipFile) err = service.addToZip(w, testFile, "test.txt") assert.NoError(t, err) _ = w.Close() // Verify file was added to zip r, err := zip.OpenReader(zipPath) require.NoError(t, err) defer func() { _ = r.Close() }() assert.Len(t, r.File, 1) assert.Equal(t, "test.txt", r.File[0].Name) }) } // TestBackupService_Unzip_ErrorPaths tests unzip error handling. func TestBackupService_Unzip_ErrorPaths(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), DatabaseName: "charon.db", } _ = os.MkdirAll(service.BackupDir, 0o750) // #nosec G301 -- test directory t.Run("unzip with invalid zip file", func(t *testing.T) { // Create invalid (corrupted) zip file invalidZip := filepath.Join(service.BackupDir, "invalid.zip") err := os.WriteFile(invalidZip, []byte("not a valid zip"), 0o600) // #nosec G306 -- test fixture require.NoError(t, err) err = service.RestoreBackup("invalid.zip") assert.Error(t, err) assert.Contains(t, err.Error(), "zip") }) t.Run("unzip with path traversal attempt", func(t *testing.T) { // Create zip with path traversal zipPath := filepath.Join(service.BackupDir, "traversal.zip") zipFile, err := os.Create(zipPath) // #nosec G304 -- test fixture path require.NoError(t, err) w := zip.NewWriter(zipFile) dbEntry, err := w.Create("charon.db") require.NoError(t, err) _, err = dbEntry.Write([]byte("placeholder")) require.NoError(t, err) f, err := w.Create("../../evil.txt") require.NoError(t, err) _, _ = f.Write([]byte("evil")) _ = w.Close() _ = zipFile.Close() // Should detect and block path traversal err = service.RestoreBackup("traversal.zip") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid file path in archive") }) t.Run("unzip empty zip file", func(t *testing.T) { // Create empty but valid zip emptyZip := filepath.Join(service.BackupDir, "empty.zip") zipFile, err := os.Create(emptyZip) // #nosec G304 -- test fixture path require.NoError(t, err) w := zip.NewWriter(zipFile) _ = w.Close() _ = zipFile.Close() // Empty zip should fail because required database entry is missing err = service.RestoreBackup("empty.zip") assert.Error(t, err) assert.Contains(t, err.Error(), "database entry") }) } // TestBackupService_GetAvailableSpace_EdgeCases tests disk space calculation edge cases. func TestBackupService_GetAvailableSpace_EdgeCases(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), } _ = os.MkdirAll(service.DataDir, 0o750) // #nosec G301 -- test directory t.Run("get available space for existing directory", func(t *testing.T) { availableBytes, err := service.GetAvailableSpace() // May fail on some filesystems or temp directories if err == nil { assert.GreaterOrEqual(t, availableBytes, int64(0), "Available space should be non-negative") } // Test just verifies function doesn't panic }) t.Run("available space error on non-existent directory", func(t *testing.T) { // Create service with non-existent data directory badService := &BackupService{ DataDir: "/non/existent/directory/that/does/not/exist", BackupDir: filepath.Join(tmpDir, "backups"), } _, err := badService.GetAvailableSpace() // Depending on OS, this might succeed or fail // On most systems, it will succeed with the parent directory stats // Just verify the function doesn't panic _ = err }) } // TestBackupService_AddDirToZip_EdgeCases tests addDirToZip with edge cases. func TestBackupService_AddDirToZip_EdgeCases(t *testing.T) { tmpDir := t.TempDir() service := &BackupService{ DataDir: filepath.Join(tmpDir, "data"), BackupDir: filepath.Join(tmpDir, "backups"), } _ = os.MkdirAll(service.BackupDir, 0o750) // #nosec G301 -- test fixture t.Run("add non-existent directory returns error", func(t *testing.T) { zipPath := filepath.Join(service.BackupDir, "test.zip") zipFile, err := os.Create(zipPath) // #nosec G304 -- test fixture path require.NoError(t, err) defer func() { _ = zipFile.Close() }() w := zip.NewWriter(zipFile) defer func() { _ = w.Close() }() err = service.addDirToZip(w, "/non/existent/dir", "base") assert.Error(t, err) }) t.Run("add empty directory to zip", func(t *testing.T) { emptyDir := filepath.Join(tmpDir, "empty") err := os.MkdirAll(emptyDir, 0o750) // #nosec G301 -- test fixture require.NoError(t, err) zipPath := filepath.Join(service.BackupDir, "empty.zip") zipFile, err := os.Create(zipPath) // #nosec G304 -- test fixture path require.NoError(t, err) defer func() { _ = zipFile.Close() }() w := zip.NewWriter(zipFile) err = service.addDirToZip(w, emptyDir, "empty") assert.NoError(t, err) _ = w.Close() // Verify zip has no entries (only directories, which are skipped) r, err := zip.OpenReader(zipPath) require.NoError(t, err) defer func() { _ = r.Close() }() assert.Empty(t, r.File) }) t.Run("add directory with nested files", func(t *testing.T) { testDir := filepath.Join(tmpDir, "nested") _ = os.MkdirAll(filepath.Join(testDir, "subdir"), 0o750) // #nosec G301 -- test directory _ = os.WriteFile(filepath.Join(testDir, "file1.txt"), []byte("content1"), 0o600) // #nosec G306 -- test fixture _ = os.WriteFile(filepath.Join(testDir, "subdir", "file2.txt"), []byte("content2"), 0o600) // #nosec G306 -- test fixture zipPath := filepath.Join(service.BackupDir, "nested.zip") zipFile, err := os.Create(zipPath) // #nosec G304 -- test fixture path require.NoError(t, err) defer func() { _ = zipFile.Close() }() w := zip.NewWriter(zipFile) err = service.addDirToZip(w, testDir, "nested") assert.NoError(t, err) _ = w.Close() // Verify both files were added r, err := zip.OpenReader(zipPath) require.NoError(t, err) defer func() { _ = r.Close() }() assert.Len(t, r.File, 2) }) } func TestSafeJoinPath(t *testing.T) { baseDir := "/data/backups" t.Run("valid_simple_path", func(t *testing.T) { path, err := SafeJoinPath(baseDir, "backup.zip") assert.NoError(t, err) assert.Equal(t, "/data/backups/backup.zip", path) }) t.Run("valid_nested_path", func(t *testing.T) { path, err := SafeJoinPath(baseDir, "2024/01/backup.zip") assert.NoError(t, err) assert.Equal(t, "/data/backups/2024/01/backup.zip", path) }) t.Run("reject_absolute_path", func(t *testing.T) { _, err := SafeJoinPath(baseDir, "/etc/passwd") assert.Error(t, err) assert.Contains(t, err.Error(), "absolute paths not allowed") }) t.Run("reject_parent_traversal", func(t *testing.T) { _, err := SafeJoinPath(baseDir, "../etc/passwd") assert.Error(t, err) assert.Contains(t, err.Error(), "parent directory traversal not allowed") }) t.Run("reject_embedded_parent_traversal", func(t *testing.T) { _, err := SafeJoinPath(baseDir, "foo/../../../etc/passwd") assert.Error(t, err) assert.Contains(t, err.Error(), "parent directory traversal not allowed") }) t.Run("clean_path_normalization", func(t *testing.T) { path, err := SafeJoinPath(baseDir, "./backup.zip") assert.NoError(t, err) assert.Equal(t, "/data/backups/backup.zip", path) }) t.Run("valid_with_dots_in_filename", func(t *testing.T) { path, err := SafeJoinPath(baseDir, "backup.2024.01.01.zip") assert.NoError(t, err) assert.Equal(t, "/data/backups/backup.2024.01.01.zip", path) }) } func TestBackupService_RehydrateLiveDatabase_NilHandle(t *testing.T) { tmpDir := t.TempDir() svc := &BackupService{DataDir: tmpDir, DatabaseName: "charon.db"} err := svc.RehydrateLiveDatabase(nil) require.Error(t, err) assert.Contains(t, err.Error(), "database handle is required") } func TestBackupService_RehydrateLiveDatabase_MissingSource(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") require.NoError(t, os.MkdirAll(dataDir, 0o700)) dbPath := filepath.Join(dataDir, "charon.db") createSQLiteTestDB(t, dbPath) db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) require.NoError(t, err) svc := &BackupService{ DataDir: dataDir, DatabaseName: "charon.db", restoreDBPath: filepath.Join(tmpDir, "missing-restore.sqlite"), } require.NoError(t, os.Remove(dbPath)) err = svc.RehydrateLiveDatabase(db) require.Error(t, err) assert.Contains(t, err.Error(), "restored database file missing") } func TestBackupService_ExtractDatabaseFromBackup_MissingDBEntry(t *testing.T) { tmpDir := t.TempDir() zipPath := filepath.Join(tmpDir, "missing-db-entry.zip") zipFile, err := os.Create(zipPath) //nolint:gosec require.NoError(t, err) writer := zip.NewWriter(zipFile) entry, err := writer.Create("not-charon.db") require.NoError(t, err) _, err = entry.Write([]byte("placeholder")) require.NoError(t, err) require.NoError(t, writer.Close()) require.NoError(t, zipFile.Close()) svc := &BackupService{DatabaseName: "charon.db"} _, err = svc.extractDatabaseFromBackup(zipPath) require.Error(t, err) assert.Contains(t, err.Error(), "database entry charon.db not found") } func TestBackupService_RestoreBackup_ReplacesStagedRestoreSnapshot(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") backupDir := filepath.Join(tmpDir, "backups") require.NoError(t, os.MkdirAll(dataDir, 0o700)) require.NoError(t, os.MkdirAll(backupDir, 0o700)) createBackupZipWithDB := func(name string, content []byte) string { path := filepath.Join(backupDir, name) zipFile, err := os.Create(path) //nolint:gosec require.NoError(t, err) writer := zip.NewWriter(zipFile) entry, err := writer.Create("charon.db") require.NoError(t, err) _, err = entry.Write(content) require.NoError(t, err) require.NoError(t, writer.Close()) require.NoError(t, zipFile.Close()) return path } createBackupZipWithDB("backup-one.zip", []byte("one")) createBackupZipWithDB("backup-two.zip", []byte("two")) svc := &BackupService{ DataDir: dataDir, BackupDir: backupDir, DatabaseName: "charon.db", restoreDBPath: "", } require.NoError(t, svc.RestoreBackup("backup-one.zip")) firstRestore := svc.restoreDBPath assert.NotEmpty(t, firstRestore) assert.FileExists(t, firstRestore) require.NoError(t, svc.RestoreBackup("backup-two.zip")) secondRestore := svc.restoreDBPath assert.NotEqual(t, firstRestore, secondRestore) assert.NoFileExists(t, firstRestore) assert.FileExists(t, secondRestore) }