Files
Charon/backend/internal/services/backup_service_wave4_test.go
2026-03-04 18:34:49 +00:00

268 lines
10 KiB
Go

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