package backup import ( "errors" "os" "os/exec" "path/filepath" "strings" "testing" ) func TestRestoreFailureRollsBackFiles(t *testing.T) { root := t.TempDir() current := filepath.Join(root, "debtik") network := filepath.Join(root, "network") stage := filepath.Join(root, "staged") for _, dir := range []string{current, network, filepath.Join(stage, "debtik"), filepath.Join(stage, "network")} { if err := os.MkdirAll(dir, 0700); err != nil { t.Fatal(err) } } write := func(path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0600); err != nil { t.Fatal(err) } } write(filepath.Join(current, "old.json"), "old config") write(filepath.Join(network, "a.cfg"), "old network") write(filepath.Join(stage, "debtik", "new.json"), "new config") write(filepath.Join(stage, "network", "a.cfg"), "new network") write(filepath.Join(stage, "network", "b.cfg"), "new file") mgr := NewManager(current, network, filepath.Join(root, "backups"), nil) count := 0 err := mgr.applyStagedRestore(stage, func(step string) error { if step == "network" { count++ if count == 2 { return errors.New("simulated write failure") } } return nil }) if err == nil || !strings.Contains(err.Error(), "previous files restored") { t.Fatalf("unexpected error %v", err) } for path, want := range map[string]string{filepath.Join(current, "old.json"): "old config", filepath.Join(network, "a.cfg"): "old network"} { data, err := os.ReadFile(path) if err != nil || string(data) != want { t.Fatalf("%s: %q %v", path, data, err) } } for _, path := range []string{filepath.Join(current, "new.json"), filepath.Join(network, "b.cfg")} { if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("unexpected remaining %s: %v", path, err) } } } func TestRestoreCrashHelper(t *testing.T) { root := os.Getenv("DEBTIK_CRASH_TEST_ROOT") if root == "" { return } point := os.Getenv("DEBTIK_CRASH_TEST_POINT") mgr := NewManager(filepath.Join(root, "debtik"), filepath.Join(root, "network"), filepath.Join(root, "backups"), nil) err := mgr.applyStagedRestore(filepath.Join(root, "stage"), func(step string) error { if step == point { os.Exit(42) } return nil }) t.Fatalf("failed to reach %s: %v", point, err) } func TestRecoveryAfterProcessCrash(t *testing.T) { for _, point := range []string{"old-renamed", "debtik", "network", "committed"} { t.Run(point, func(t *testing.T) { root := t.TempDir() current := filepath.Join(root, "debtik") network := filepath.Join(root, "network") stage := filepath.Join(root, "stage") for _, dir := range []string{current, network, filepath.Join(stage, "debtik"), filepath.Join(stage, "network")} { if err := os.MkdirAll(dir, 0700); err != nil { t.Fatal(err) } } for path, content := range map[string]string{filepath.Join(current, "config.json"): "old", filepath.Join(network, "a.cfg"): "old network", filepath.Join(stage, "debtik", "config.json"): "new", filepath.Join(stage, "network", "a.cfg"): "new network"} { if err := os.WriteFile(path, []byte(content), 0600); err != nil { t.Fatal(err) } } cmd := exec.Command(os.Args[0], "-test.run=^TestRestoreCrashHelper$") cmd.Env = append(os.Environ(), "DEBTIK_CRASH_TEST_ROOT="+root, "DEBTIK_CRASH_TEST_POINT="+point) if err := cmd.Run(); err == nil { t.Fatal("expected process to terminate") } else if ex, ok := err.(*exec.ExitError); !ok || ex.ExitCode() != 42 { t.Fatalf("unexpected subprocess failure: %v", err) } mgr := NewManager(current, network, filepath.Join(root, "backups"), nil) recovered, err := mgr.RecoverInterruptedRestore() if err != nil || !recovered { t.Fatalf("recover: %v %v", recovered, err) } config := "old" netConfig := "old network" if point == "committed" { config = "new" netConfig = "new network" } for path, want := range map[string]string{filepath.Join(current, "config.json"): config, filepath.Join(network, "a.cfg"): netConfig} { data, err := os.ReadFile(path) if err != nil || string(data) != want { t.Fatalf("%s: %q %v", path, data, err) } } if again, err := mgr.RecoverInterruptedRestore(); err != nil || again { t.Fatalf("recovery not idempotent: %v %v", again, err) } }) } } func TestRecoveryAfterCrashWithNoPreviousConfig(t *testing.T) { root := t.TempDir() stage := filepath.Join(root, "stage") network := filepath.Join(root, "network") if err := os.MkdirAll(filepath.Join(stage, "debtik"), 0700); err != nil { t.Fatal(err) } if err := os.MkdirAll(network, 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(stage, "debtik", "new.json"), []byte("new"), 0600); err != nil { t.Fatal(err) } cmd := exec.Command(os.Args[0], "-test.run=^TestRestoreCrashHelper$") cmd.Env = append(os.Environ(), "DEBTIK_CRASH_TEST_ROOT="+root, "DEBTIK_CRASH_TEST_POINT=debtik") if err := cmd.Run(); err == nil { t.Fatal("expected process to terminate") } else if ex, ok := err.(*exec.ExitError); !ok || ex.ExitCode() != 42 { t.Fatal(err) } mgr := NewManager(filepath.Join(root, "debtik"), network, filepath.Join(root, "backups"), nil) if recovered, err := mgr.RecoverInterruptedRestore(); err != nil || !recovered { t.Fatalf("recovery: %v %v", recovered, err) } if _, err := os.Stat(filepath.Join(root, "debtik")); !os.IsNotExist(err) { t.Fatalf("new directory still present: %v", err) } } func TestRecoveryRejectsJournalWithUnrelatedPaths(t *testing.T) { root := t.TempDir() mgr := NewManager(filepath.Join(root, "debtik"), filepath.Join(root, "network"), filepath.Join(root, "backups"), nil) journal := &restoreJournal{DebtikDir: mgr.debtikDir, NetworkDir: mgr.networkDir, Files: []journalFile{{Path: filepath.Join(root, "outside.cfg")}}} if err := mgr.writeJournal(journal); err != nil { t.Fatal(err) } if _, err := mgr.RecoverInterruptedRestore(); err == nil { t.Fatal("accepted unrelated file") } if _, err := os.Stat(mgr.journalPath()); err != nil { t.Fatalf("journal lost after failed recovery: %v", err) } } func TestRecoveryRejectsIncompleteJournal(t *testing.T) { root := t.TempDir() current := filepath.Join(root, "debtik") if err := os.MkdirAll(current, 0700); err != nil { t.Fatal(err) } marker := filepath.Join(current, "config") if err := os.WriteFile(marker, []byte("preserve"), 0600); err != nil { t.Fatal(err) } mgr := NewManager(current, filepath.Join(root, "network"), filepath.Join(root, "backups"), nil) if err := mgr.writeJournal(&restoreJournal{DebtikDir: current, NetworkDir: mgr.networkDir, HasDebtik: true}); err != nil { t.Fatal(err) } if _, err := mgr.RecoverInterruptedRestore(); err == nil { t.Fatal("accepted incomplete journal") } if data, err := os.ReadFile(marker); err != nil || string(data) != "preserve" { t.Fatalf("config damaged: %q %v", data, err) } }