package persistence import ( "os" "path/filepath" "strings" "testing" ) func writeTestFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatal(err) } } func readTestFile(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(data) } func TestTxnCommitAppliesAllOps(t *testing.T) { dir := t.TempDir() journalDir := filepath.Join(dir, ".journal") writeTestFile(t, filepath.Join(dir, "a.yaml"), "old-a") writeTestFile(t, filepath.Join(dir, "b.yaml"), "old-b") writeTestFile(t, filepath.Join(dir, "c.yaml"), "old-c") txn := NewTransaction(journalDir) if err := txn.Replace(filepath.Join(dir, "a.yaml"), []byte("new-a"), 0644); err != nil { t.Fatal(err) } if err := txn.Delete(filepath.Join(dir, "b.yaml")); err != nil { t.Fatal(err) } if err := txn.Move(filepath.Join(dir, "c.yaml"), filepath.Join(dir, "d.yaml")); err != nil { t.Fatal(err) } if err := txn.Replace(filepath.Join(dir, "created.yaml"), []byte("created"), 0600); err != nil { t.Fatal(err) } if err := txn.Commit(); err != nil { t.Fatalf("commit: %v", err) } if got := readTestFile(t, filepath.Join(dir, "a.yaml")); got != "new-a" { t.Fatalf("replace failed: %q", got) } if _, err := os.Stat(filepath.Join(dir, "b.yaml")); !os.IsNotExist(err) { t.Fatalf("delete failed: %v", err) } if _, err := os.Stat(filepath.Join(dir, "c.yaml")); !os.IsNotExist(err) { t.Fatalf("move left source behind: %v", err) } if got := readTestFile(t, filepath.Join(dir, "d.yaml")); got != "old-c" { t.Fatalf("move lost content: %q", got) } if got := readTestFile(t, filepath.Join(dir, "created.yaml")); got != "created" { t.Fatalf("create failed: %q", got) } if entries, _ := ListJournalFiles(journalDir); len(entries) != 0 { t.Fatalf("journal not cleaned: %v", entries) } } func TestTxnRollbackOnFailure(t *testing.T) { dir := t.TempDir() journalDir := filepath.Join(dir, ".journal") writeTestFile(t, filepath.Join(dir, "a.yaml"), "old-a") // A file inside a read-only subdirectory makes the delete fail. sub := filepath.Join(dir, "sub") writeTestFile(t, filepath.Join(sub, "gone.yaml"), "old-gone") if err := os.Chmod(sub, 0500); err != nil { t.Fatal(err) } t.Cleanup(func() { os.Chmod(sub, 0700) }) txn := NewTransaction(journalDir) if err := txn.Replace(filepath.Join(dir, "a.yaml"), []byte("new-a"), 0644); err != nil { t.Fatal(err) } if err := txn.Delete(filepath.Join(sub, "gone.yaml")); err != nil { t.Fatal(err) } if err := txn.Commit(); err == nil { t.Fatal("expected commit to fail") } if got := readTestFile(t, filepath.Join(dir, "a.yaml")); got != "old-a" { t.Fatalf("rollback did not restore a.yaml: %q", got) } if got := readTestFile(t, filepath.Join(sub, "gone.yaml")); got != "old-gone" { t.Fatalf("rollback did not restore deleted file: %q", got) } // The restore of gone.yaml ran inside the still read-only directory, so // the ROLLBACK itself failed there: the journal is the only durable // record of the pre-transaction state and must be KEPT (deleting it // would make any remaining divergence unrecoverable). See // TestCommitKeepsJournalAfterFailedRollback. if entries, _ := ListJournalFiles(journalDir); len(entries) != 1 { t.Fatalf("journal must be kept after a failed rollback: %v", entries) } // Repair the directory permission: the next Recover completes the // rollback and cleans the journal. if err := os.Chmod(sub, 0700); err != nil { t.Fatal(err) } if err := Recover(journalDir); err != nil { t.Fatalf("recovery retry after repair: %v", err) } if got := readTestFile(t, filepath.Join(dir, "a.yaml")); got != "old-a" { t.Fatalf("recovery retry disturbed a.yaml: %q", got) } if got := readTestFile(t, filepath.Join(sub, "gone.yaml")); got != "old-gone" { t.Fatalf("recovery retry disturbed gone.yaml: %q", got) } if entries, _ := ListJournalFiles(journalDir); len(entries) != 0 { t.Fatalf("completed recovery left journals behind: %v", entries) } } func TestRecoverRollsBackLeftoverJournal(t *testing.T) { // Simulate a crash: journal exists (it says a.yaml becomes new-a and // c.yaml is deleted) but the operations never ran. dir := t.TempDir() journalDir := filepath.Join(dir, ".journal") writeTestFile(t, filepath.Join(dir, "a.yaml"), "old-a") writeTestFile(t, filepath.Join(dir, "c.yaml"), "old-c") txn := NewTransaction(journalDir) if err := txn.Replace(filepath.Join(dir, "a.yaml"), []byte("new-a"), 0644); err != nil { t.Fatal(err) } if err := txn.Delete(filepath.Join(dir, "c.yaml")); err != nil { t.Fatal(err) } journal, err := txn.MarshalJournal() if err != nil { t.Fatal(err) } if err := os.MkdirAll(journalDir, 0700); err != nil { t.Fatal(err) } if err := WriteFile(filepath.Join(journalDir, "txn-crash.json"), journal, 0600); err != nil { t.Fatal(err) } if err := Recover(journalDir); err != nil { t.Fatalf("recover: %v", err) } if got := readTestFile(t, filepath.Join(dir, "a.yaml")); got != "old-a" { t.Fatalf("recovery did not roll back a.yaml: %q", got) } if got := readTestFile(t, filepath.Join(dir, "c.yaml")); got != "old-c" { t.Fatalf("recovery resurrected a delete: %q", got) } if entries, _ := ListJournalFiles(journalDir); len(entries) != 0 { t.Fatalf("recovered journal not removed: %v", entries) } } func TestRecoverDropsCorruptJournal(t *testing.T) { dir := t.TempDir() journalDir := filepath.Join(dir, ".journal") if err := os.MkdirAll(journalDir, 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(journalDir, "txn-bad.json"), []byte("{not json"), 0600); err != nil { t.Fatal(err) } if err := Recover(journalDir); err != nil { t.Fatalf("recover corrupt: %v", err) } if entries, _ := ListJournalFiles(journalDir); len(entries) != 0 { t.Fatalf("corrupt journal not removed: %v", entries) } } func TestTxnMoveOverwritesDestination(t *testing.T) { dir := t.TempDir() journalDir := filepath.Join(dir, ".journal") writeTestFile(t, filepath.Join(dir, "src.yaml"), "src-content") writeTestFile(t, filepath.Join(dir, "dst.yaml"), "dst-content") txn := NewTransaction(journalDir) if err := txn.Move(filepath.Join(dir, "src.yaml"), filepath.Join(dir, "dst.yaml")); err != nil { t.Fatal(err) } if err := txn.Commit(); err != nil { t.Fatalf("commit: %v", err) } if got := readTestFile(t, filepath.Join(dir, "dst.yaml")); got != "src-content" { t.Fatalf("move did not overwrite destination: %q", got) } if _, err := os.Stat(filepath.Join(dir, "src.yaml")); !os.IsNotExist(err) { t.Fatalf("source not removed: %v", err) } if !strings.HasSuffix(readTestFile(t, filepath.Join(dir, "dst.yaml")), "content") { t.Fatal("unreachable") } } // Regression: Move followed by Delete/Replace on the same path used to clobber // the move record, silently losing the file with a nil Commit error. func TestTxnRejectsConflictingMoveThenDelete(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "src.yaml") dst := filepath.Join(dir, "dst.yaml") writeTestFile(t, src, "content") txn := NewTransaction(filepath.Join(dir, ".journal")) if err := txn.Move(src, dst); err != nil { t.Fatal(err) } if err := txn.Delete(src); err == nil { t.Fatal("Delete after Move on the same path was accepted") } } func TestTxnRejectsConflictingMoveThenReplace(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "src.yaml") dst := filepath.Join(dir, "dst.yaml") writeTestFile(t, src, "content") txn := NewTransaction(filepath.Join(dir, ".journal")) if err := txn.Move(src, dst); err != nil { t.Fatal(err) } if err := txn.Replace(src, []byte("new"), 0644); err == nil { t.Fatal("Replace after Move on the same path was accepted") } } // Move then Replace on the destination is the supported pattern and must still // work. func TestTxnMoveThenReplaceDestination(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "src.yaml") dst := filepath.Join(dir, "dst.yaml") writeTestFile(t, src, "content") txn := NewTransaction(filepath.Join(dir, ".journal")) if err := txn.Move(src, dst); err != nil { t.Fatal(err) } if err := txn.Replace(dst, []byte("rewritten"), 0644); err != nil { t.Fatal(err) } if err := txn.Commit(); err != nil { t.Fatal(err) } if got := readTestFile(t, dst); got != "rewritten" { t.Fatalf("dst = %q, want rewritten", got) } } // Regression: an unrecognised op kind must abort recovery and keep the journal // rather than silently dropping the before-image and deleting the record. func TestRecoverKeepsJournalWithUnknownOpKind(t *testing.T) { dir := t.TempDir() journalDir := filepath.Join(dir, ".journal") if err := os.MkdirAll(journalDir, 0700); err != nil { t.Fatal(err) } journal := []byte(`{"ops":[{"path":"` + filepath.Join(dir, "a.yaml") + `","kind":"future-kind","old_exists":false}]}`) path := filepath.Join(journalDir, "txn-unknown.json") if err := os.WriteFile(path, journal, 0600); err != nil { t.Fatal(err) } if err := Recover(journalDir); err == nil { t.Fatal("Recover accepted an unknown op kind") } if _, err := os.Stat(path); err != nil { t.Fatalf("journal with unknown kind was removed: %v", err) } }