1651 lines
51 KiB
Go
1651 lines
51 KiB
Go
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)
|
|
}
|