package services import ( "archive/zip" "fmt" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func openWave4ZipInTempDir(t *testing.T, tempDir, zipPath string) *os.File { t.Helper() absTempDir, err := filepath.Abs(tempDir) require.NoError(t, err) absZipPath, err := filepath.Abs(zipPath) require.NoError(t, err) relPath, err := filepath.Rel(absTempDir, absZipPath) require.NoError(t, err) require.False(t, relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator))) // #nosec G304 -- absZipPath is constrained to test TempDir via Abs+Rel checks above. zipFile, err := os.OpenFile(absZipPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) require.NoError(t, err) return zipFile } func registerBackupRawErrorHook(t *testing.T, db *gorm.DB, name string, shouldFail func(*gorm.DB) bool) { t.Helper() require.NoError(t, db.Callback().Raw().Before("gorm:raw").Register(name, func(tx *gorm.DB) { if shouldFail(tx) { _ = tx.AddError(fmt.Errorf("forced raw failure")) } })) t.Cleanup(func() { _ = db.Callback().Raw().Remove(name) }) } func backupSQLContains(tx *gorm.DB, fragment string) bool { if tx == nil || tx.Statement == nil { return false } return strings.Contains(strings.ToLower(tx.Statement.SQL.String()), strings.ToLower(fragment)) } func setupRehydrateDBPair(t *testing.T) (*gorm.DB, string, string) { t.Helper() tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") require.NoError(t, os.MkdirAll(dataDir, 0o700)) activeDBPath := filepath.Join(tmpDir, "active.db") activeDB, err := gorm.Open(sqlite.Open(activeDBPath), &gorm.Config{}) require.NoError(t, err) require.NoError(t, activeDB.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`).Error) restoreDBPath := filepath.Join(tmpDir, "restore.db") restoreDB, err := gorm.Open(sqlite.Open(restoreDBPath), &gorm.Config{}) require.NoError(t, err) require.NoError(t, restoreDB.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`).Error) require.NoError(t, restoreDB.Exec(`INSERT INTO users (name) VALUES ('alice')`).Error) return activeDB, dataDir, restoreDBPath } func TestBackupServiceWave4_Rehydrate_CheckpointWarningPath(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") require.NoError(t, os.MkdirAll(dataDir, 0o700)) activeDBPath := filepath.Join(tmpDir, "active.db") activeDB, err := gorm.Open(sqlite.Open(activeDBPath), &gorm.Config{}) require.NoError(t, err) // Place an invalid database file at DataDir/DatabaseName so checkpointSQLiteDatabase fails restoredDBPath := filepath.Join(dataDir, "charon.db") require.NoError(t, os.WriteFile(restoredDBPath, []byte("not-sqlite"), 0o600)) svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db"} err = svc.RehydrateLiveDatabase(activeDB) require.Error(t, err) } func TestBackupServiceWave4_Rehydrate_CreateTempFailure(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) activeDB, err := gorm.Open(sqlite.Open(filepath.Join(tmpDir, "active.db")), &gorm.Config{}) require.NoError(t, err) t.Setenv("TMPDIR", filepath.Join(tmpDir, "missing-temp-dir")) svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db"} err = svc.RehydrateLiveDatabase(activeDB) require.Error(t, err) require.Contains(t, err.Error(), "create temporary restore database copy") } func TestBackupServiceWave4_Rehydrate_CopyErrorFromDirectorySource(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") require.NoError(t, os.MkdirAll(dataDir, 0o700)) activeDB, err := gorm.Open(sqlite.Open(filepath.Join(tmpDir, "active.db")), &gorm.Config{}) require.NoError(t, err) // Use a directory as restore source path so io.Copy fails deterministically. badSourceDir := filepath.Join(tmpDir, "restore-source-dir") require.NoError(t, os.MkdirAll(badSourceDir, 0o700)) svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db", restoreDBPath: badSourceDir} err = svc.RehydrateLiveDatabase(activeDB) require.Error(t, err) require.Contains(t, err.Error(), "copy restored database to temporary file") } func TestBackupServiceWave4_Rehydrate_CopyTableErrorOnSchemaMismatch(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") require.NoError(t, os.MkdirAll(dataDir, 0o700)) activeDBPath := filepath.Join(tmpDir, "active.db") activeDB, err := gorm.Open(sqlite.Open(activeDBPath), &gorm.Config{}) require.NoError(t, err) require.NoError(t, activeDB.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`).Error) restoreDBPath := filepath.Join(tmpDir, "restore.db") restoreDB, err := gorm.Open(sqlite.Open(restoreDBPath), &gorm.Config{}) require.NoError(t, err) require.NoError(t, restoreDB.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, extra TEXT)`).Error) require.NoError(t, restoreDB.Exec(`INSERT INTO users (name, extra) VALUES ('alice', 'x')`).Error) svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db", restoreDBPath: restoreDBPath} err = svc.RehydrateLiveDatabase(activeDB) require.Error(t, err) require.Contains(t, err.Error(), "copy table users") } func TestBackupServiceWave4_ExtractDatabaseFromBackup_CreateTempError(t *testing.T) { tmpDir := t.TempDir() zipPath := filepath.Join(tmpDir, "backup.zip") zf := openWave4ZipInTempDir(t, tmpDir, zipPath) zw := zip.NewWriter(zf) entry, err := zw.Create("charon.db") require.NoError(t, err) _, err = entry.Write([]byte("sqlite-header-placeholder")) require.NoError(t, err) require.NoError(t, zw.Close()) require.NoError(t, zf.Close()) t.Setenv("TMPDIR", filepath.Join(tmpDir, "missing-temp-dir")) svc := &BackupService{DatabaseName: "charon.db"} _, err = svc.extractDatabaseFromBackup(zipPath) require.Error(t, err) require.Contains(t, err.Error(), "create restore snapshot file") } func TestBackupServiceWave4_UnzipWithSkip_MkdirParentError(t *testing.T) { tmpDir := t.TempDir() zipPath := filepath.Join(tmpDir, "nested.zip") zf := openWave4ZipInTempDir(t, tmpDir, zipPath) zw := zip.NewWriter(zf) entry, err := zw.Create("nested/file.txt") require.NoError(t, err) _, err = entry.Write([]byte("hello")) require.NoError(t, err) require.NoError(t, zw.Close()) require.NoError(t, zf.Close()) // Make destination a regular file so MkdirAll(filepath.Dir(fpath)) fails with ENOTDIR. destFile := filepath.Join(tmpDir, "dest-as-file") require.NoError(t, os.WriteFile(destFile, []byte("block"), 0o600)) svc := &BackupService{} err = svc.unzipWithSkip(zipPath, destFile, nil) require.Error(t, err) } func TestBackupServiceWave4_Rehydrate_ClearSQLiteSequenceError(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") require.NoError(t, os.MkdirAll(dataDir, 0o700)) activeDB, err := gorm.Open(sqlite.Open(filepath.Join(tmpDir, "active.db")), &gorm.Config{}) require.NoError(t, err) require.NoError(t, activeDB.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)`).Error) restoreDBPath := filepath.Join(tmpDir, "restore.db") restoreDB, err := gorm.Open(sqlite.Open(restoreDBPath), &gorm.Config{}) require.NoError(t, err) require.NoError(t, restoreDB.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)`).Error) require.NoError(t, restoreDB.Exec(`INSERT INTO users (name) VALUES ('alice')`).Error) registerBackupRawErrorHook(t, activeDB, "wave4-clear-sqlite-sequence", func(tx *gorm.DB) bool { return backupSQLContains(tx, "delete from sqlite_sequence") }) svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db", restoreDBPath: restoreDBPath} err = svc.RehydrateLiveDatabase(activeDB) require.Error(t, err) require.Contains(t, err.Error(), "clear sqlite_sequence") } func TestBackupServiceWave4_Rehydrate_CopySQLiteSequenceError(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") require.NoError(t, os.MkdirAll(dataDir, 0o700)) activeDB, err := gorm.Open(sqlite.Open(filepath.Join(tmpDir, "active.db")), &gorm.Config{}) require.NoError(t, err) require.NoError(t, activeDB.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)`).Error) restoreDBPath := filepath.Join(tmpDir, "restore.db") restoreDB, err := gorm.Open(sqlite.Open(restoreDBPath), &gorm.Config{}) require.NoError(t, err) require.NoError(t, restoreDB.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)`).Error) require.NoError(t, restoreDB.Exec(`INSERT INTO users (name) VALUES ('alice')`).Error) registerBackupRawErrorHook(t, activeDB, "wave4-copy-sqlite-sequence", func(tx *gorm.DB) bool { return backupSQLContains(tx, "insert into sqlite_sequence select * from restore_src.sqlite_sequence") }) svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db", restoreDBPath: restoreDBPath} err = svc.RehydrateLiveDatabase(activeDB) require.Error(t, err) require.Contains(t, err.Error(), "copy sqlite_sequence") } func TestBackupServiceWave4_Rehydrate_DetachErrorNotBusyOrLocked(t *testing.T) { activeDB, dataDir, restoreDBPath := setupRehydrateDBPair(t) registerBackupRawErrorHook(t, activeDB, "wave4-detach-error", func(tx *gorm.DB) bool { return backupSQLContains(tx, "detach database restore_src") }) svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db", restoreDBPath: restoreDBPath} err := svc.RehydrateLiveDatabase(activeDB) require.Error(t, err) require.Contains(t, err.Error(), "detach restored database") } func TestBackupServiceWave4_Rehydrate_WALCheckpointErrorNotBusyOrLocked(t *testing.T) { activeDB, dataDir, restoreDBPath := setupRehydrateDBPair(t) registerBackupRawErrorHook(t, activeDB, "wave4-wal-checkpoint-error", func(tx *gorm.DB) bool { return backupSQLContains(tx, "pragma wal_checkpoint(truncate)") }) svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db", restoreDBPath: restoreDBPath} err := svc.RehydrateLiveDatabase(activeDB) require.Error(t, err) require.Contains(t, err.Error(), "checkpoint wal after rehydrate") }