package system import ( "os" "path/filepath" "strings" "testing" ) // TestAtomicReplaceMovesPreviousAside pins the basic shape: an existing // dst is moved to dst+suffix, and newSrc is renamed into place. // Critical for `banger update` — without the .previous backup the // rollback path has nothing to restore. func TestAtomicReplaceMovesPreviousAside(t *testing.T) { dir := t.TempDir() dst := filepath.Join(dir, "banger") if err := os.WriteFile(dst, []byte("old"), 0o755); err != nil { t.Fatalf("write dst: %v", err) } src := filepath.Join(dir, "banger.new") if err := os.WriteFile(src, []byte("new"), 0o755); err != nil { t.Fatalf("write src: %v", err) } if err := AtomicReplace(src, dst, ".previous"); err != nil { t.Fatalf("AtomicReplace: %v", err) } got, _ := os.ReadFile(dst) if string(got) != "new" { t.Fatalf("dst content = %q, want %q", got, "new") } prev, _ := os.ReadFile(dst + ".previous") if string(prev) != "old" { t.Fatalf("backup content = %q, want %q", prev, "old") } // src must be gone (it was renamed, not copied). if _, err := os.Stat(src); !os.IsNotExist(err) { t.Fatalf("src should have been renamed away; got %v", err) } } // TestAtomicReplaceFreshInstall covers the case where dst doesn't // exist yet (fresh install). Should still install newSrc; no backup // is left behind. func TestAtomicReplaceFreshInstall(t *testing.T) { dir := t.TempDir() dst := filepath.Join(dir, "banger") src := filepath.Join(dir, "banger.new") if err := os.WriteFile(src, []byte("new"), 0o755); err != nil { t.Fatalf("write src: %v", err) } if err := AtomicReplace(src, dst, ".previous"); err != nil { t.Fatalf("AtomicReplace: %v", err) } got, _ := os.ReadFile(dst) if string(got) != "new" { t.Fatalf("dst content = %q, want %q", got, "new") } if _, err := os.Stat(dst + ".previous"); !os.IsNotExist(err) { t.Fatalf(".previous should not exist for a fresh install") } } // TestAtomicReplaceClearsStaleBackup: a leftover .previous from a // half-finished prior update would otherwise block the rename. // AtomicReplace must clear it. func TestAtomicReplaceClearsStaleBackup(t *testing.T) { dir := t.TempDir() dst := filepath.Join(dir, "banger") if err := os.WriteFile(dst, []byte("old"), 0o755); err != nil { t.Fatalf("write dst: %v", err) } if err := os.WriteFile(dst+".previous", []byte("ancient"), 0o755); err != nil { t.Fatalf("write stale previous: %v", err) } src := filepath.Join(dir, "banger.new") if err := os.WriteFile(src, []byte("new"), 0o755); err != nil { t.Fatalf("write src: %v", err) } if err := AtomicReplace(src, dst, ".previous"); err != nil { t.Fatalf("AtomicReplace: %v", err) } prev, _ := os.ReadFile(dst + ".previous") if string(prev) != "old" { t.Fatalf("backup content = %q, want %q (stale 'ancient' should have been overwritten with the just-replaced 'old')", prev, "old") } } // TestAtomicReplaceRefusesEmptySuffix is paranoia: an empty suffix // would silently no-op the backup AND break rollback. Refuse rather // than letting the caller paint themselves into a corner. func TestAtomicReplaceRefusesEmptySuffix(t *testing.T) { dir := t.TempDir() dst := filepath.Join(dir, "banger") src := filepath.Join(dir, "banger.new") _ = os.WriteFile(dst, []byte("old"), 0o755) _ = os.WriteFile(src, []byte("new"), 0o755) err := AtomicReplace(src, dst, "") if err == nil { t.Fatal("AtomicReplace with empty suffix succeeded; want error") } if !strings.Contains(err.Error(), "suffixPrevious") { t.Fatalf("err = %v, want suffix-related message", err) } } // TestAtomicReplaceRollbackRestoresPrevious pins the rollback story // after a doctor failure: AtomicReplaceRollback restores the .previous // backup back into place. func TestAtomicReplaceRollbackRestoresPrevious(t *testing.T) { dir := t.TempDir() dst := filepath.Join(dir, "banger") src := filepath.Join(dir, "banger.new") _ = os.WriteFile(dst, []byte("old"), 0o755) _ = os.WriteFile(src, []byte("new"), 0o755) if err := AtomicReplace(src, dst, ".previous"); err != nil { t.Fatalf("AtomicReplace: %v", err) } if err := AtomicReplaceRollback(dst, ".previous"); err != nil { t.Fatalf("Rollback: %v", err) } got, _ := os.ReadFile(dst) if string(got) != "old" { t.Fatalf("post-rollback dst = %q, want %q", got, "old") } if _, err := os.Stat(dst + ".previous"); !os.IsNotExist(err) { t.Fatalf(".previous should be gone after rollback; stat err = %v", err) } } // TestAtomicReplaceRollbackTolerantWhenNoBackup: rolling back when // there's nothing to roll back (fresh-install case) must be a no-op, // not an error. The updater calls Rollback unconditionally on // failure paths and shouldn't have to track "was there a backup?" // itself. func TestAtomicReplaceRollbackTolerantWhenNoBackup(t *testing.T) { dir := t.TempDir() dst := filepath.Join(dir, "banger") if err := os.WriteFile(dst, []byte("current"), 0o755); err != nil { t.Fatalf("write dst: %v", err) } if err := AtomicReplaceRollback(dst, ".previous"); err != nil { t.Fatalf("Rollback should be a no-op when no backup exists; got %v", err) } got, _ := os.ReadFile(dst) if string(got) != "current" { t.Fatalf("dst was disturbed despite no backup: %q", got) } }