1004 lines
41 KiB
Go
1004 lines
41 KiB
Go
package backup
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"textmachine/platform/internal/pgstore"
|
||
"textmachine/platform/internal/runner"
|
||
)
|
||
|
||
// fakeEngine stands in for `tmctl backup`: it writes a file where the engine would and reports it,
|
||
// or refuses the way the engine refuses a book with no database.
|
||
type fakeEngine struct {
|
||
refuse bool
|
||
fail error
|
||
calls int
|
||
content string
|
||
}
|
||
|
||
func (f *fakeEngine) Backup(_ context.Context, _, workdir string) (runner.BackupOutcome, error) {
|
||
f.calls++
|
||
if f.fail != nil {
|
||
return runner.BackupOutcome{}, f.fail
|
||
}
|
||
if f.refuse {
|
||
return runner.BackupOutcome{Exited: true, ExitCode: 1,
|
||
Stderr: "tmctl backup: project database does not exist yet"}, nil
|
||
}
|
||
dir := filepath.Join(workdir, "backups")
|
||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||
return runner.BackupOutcome{}, err
|
||
}
|
||
path := filepath.Join(dir, "snapshot.db")
|
||
body := f.content
|
||
if body == "" {
|
||
body = "CONSISTENT-COPY"
|
||
}
|
||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||
return runner.BackupOutcome{}, err
|
||
}
|
||
return runner.BackupOutcome{Exited: true, ExitCode: 0, Path: path}, nil
|
||
}
|
||
|
||
type fakeStore struct {
|
||
books []pgstore.BookDirectory
|
||
err error
|
||
}
|
||
|
||
func (f *fakeStore) BooksForBackup(context.Context) ([]pgstore.BookDirectory, error) {
|
||
return f.books, f.err
|
||
}
|
||
|
||
// fakePg writes a file where pg_dump would and exits 0, or fails. Written as a shell script because
|
||
// the code under test SPAWNS the tool: a Go double would prove the wiring this platform does not have.
|
||
func fakePg(t *testing.T, dir, name string, fail bool) string {
|
||
t.Helper()
|
||
sh, err := exec.LookPath("sh")
|
||
if err != nil {
|
||
t.Skip("sh not on PATH: the pg_dump double cannot be built")
|
||
}
|
||
path := filepath.Join(dir, name)
|
||
script := "#!" + sh + "\n"
|
||
if fail {
|
||
script += "echo 'pg_dump: error: connection failed' >&2\nexit 1\n"
|
||
} else {
|
||
// --file=<path> is the argument the real tool takes and the one this code passes.
|
||
script += "for a in \"$@\"; do case \"$a\" in --file=*) printf 'PGDUMP' > \"${a#--file=}\";; esac; done\nexit 0\n"
|
||
}
|
||
if err := os.WriteFile(path, []byte(script), 0o700); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return path
|
||
}
|
||
|
||
// fakeRefusing stands in for a `pg_restore --list` that cannot read the archive — a truncated dump,
|
||
// a version mismatch that wrote a header and stopped.
|
||
func fakeRefusing(t *testing.T, dir, name string) string {
|
||
t.Helper()
|
||
sh, err := exec.LookPath("sh")
|
||
if err != nil {
|
||
t.Skip("sh not on PATH")
|
||
}
|
||
path := filepath.Join(dir, name)
|
||
body := "#!" + sh + "\necho 'pg_restore: error: did not find magic string in file header' >&2\nexit 1\n"
|
||
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return path
|
||
}
|
||
|
||
func fakeOK(t *testing.T, dir, name string) string {
|
||
t.Helper()
|
||
sh, err := exec.LookPath("sh")
|
||
if err != nil {
|
||
t.Skip("sh not on PATH")
|
||
}
|
||
path := filepath.Join(dir, name)
|
||
if err := os.WriteFile(path, []byte("#!"+sh+"\nexit 0\n"), 0o700); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return path
|
||
}
|
||
|
||
// aBook makes a book directory shaped like a real one: a configuration, a source, a LIVE database
|
||
// with its write-ahead sidecar, a sidecar artefact, and the engine's own `backups/` subdirectory.
|
||
func aBook(t *testing.T, root, id string) pgstore.BookDirectory {
|
||
t.Helper()
|
||
dir := filepath.Join(root, id)
|
||
if err := os.MkdirAll(filepath.Join(dir, "backups"), 0o750); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for name, body := range map[string]string{
|
||
"book.yaml": "book_id: " + id + "\ntitle: t\n",
|
||
"source.txt": "第1章\n",
|
||
id + ".db": "LIVE-AND-POSSIBLY-TORN",
|
||
id + ".db-wal": "WAL",
|
||
id + ".db.manifest.json": `{"manifest_version":"tm-manifest-v2"}`,
|
||
"events.jsonl": "{}\n",
|
||
"backups/older-engine-point.db": "AN OLDER ENGINE RESTORE POINT",
|
||
} {
|
||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
return pgstore.BookDirectory{ID: id, Workdir: dir, Status: "not_started"}
|
||
}
|
||
|
||
func service(t *testing.T, books []pgstore.BookDirectory, eng *fakeEngine, dumpFails bool) (*Service, string) {
|
||
t.Helper()
|
||
tools := t.TempDir()
|
||
dest := t.TempDir()
|
||
return &Service{
|
||
Cfg: Config{
|
||
Dir: dest, Every: time.Hour, Keep: 3,
|
||
PgDumpBin: fakePg(t, tools, "pg_dump", dumpFails),
|
||
PgRestoreBin: fakeOK(t, tools, "pg_restore"),
|
||
DSN: "postgres://u:pw@127.0.0.1:5432/db?sslmode=disable",
|
||
EngineBinary: "/opt/engine/tmctl",
|
||
},
|
||
Store: &fakeStore{books: books}, Engine: eng,
|
||
}, dest
|
||
}
|
||
|
||
// The whole shape of a restore point in one pass: the money registry, every book's files, the
|
||
// engine's consistent copy standing in for the live database, and a manifest that can find each one.
|
||
//
|
||
// Mutation caught: skipping the engine call (the live, possibly torn file becomes the backup);
|
||
// copying the live database anyway (a restore has two candidates and no way to choose); leaving the
|
||
// engine's own `backups/` in (every pass copies every previous pass); publishing without the manifest
|
||
// (nothing can be verified afterwards).
|
||
func TestARestorePointCarriesTheLedgerAndEachBooksConsistentDatabase(t *testing.T) {
|
||
root := t.TempDir()
|
||
books := []pgstore.BookDirectory{aBook(t, root, "bk_one"), aBook(t, root, "bk_two")}
|
||
eng := &fakeEngine{}
|
||
svc, dest := service(t, books, eng, false)
|
||
|
||
res, err := svc.Take(t.Context())
|
||
if err != nil {
|
||
t.Fatalf("Take: %v", err)
|
||
}
|
||
if res.Books != 2 || res.Skipped != 0 || !res.Complete {
|
||
t.Fatalf("result does not describe two copied books: %+v", res)
|
||
}
|
||
if eng.calls != 2 {
|
||
t.Errorf("the engine was asked for %d consistent copies, want one per book", eng.calls)
|
||
}
|
||
m, err := ReadManifest(res.Path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if m.Version != ManifestVersion || !m.Complete || len(m.Books) != 2 {
|
||
t.Fatalf("manifest: %+v", m)
|
||
}
|
||
if m.Postgres.Name != DumpFile || m.Postgres.Bytes == 0 {
|
||
t.Errorf("the money registry is not in the point: %+v", m.Postgres)
|
||
}
|
||
for _, b := range m.Books {
|
||
names := map[string]bool{}
|
||
for _, f := range b.Files {
|
||
names[f.Name] = true
|
||
}
|
||
db := filepath.ToSlash(filepath.Join(BooksDir, b.BookID, ProjectDBName))
|
||
if !names[db] {
|
||
t.Errorf("%s: the consistent database is missing: %v", b.BookID, names)
|
||
}
|
||
body, err := os.ReadFile(filepath.Join(res.Path, filepath.FromSlash(db)))
|
||
if err != nil || string(body) != "CONSISTENT-COPY" {
|
||
// The point of the whole mechanism: what is stored is the engine's snapshot, never the
|
||
// file a run may be writing.
|
||
t.Errorf("%s: the stored database is not the engine's copy: %q, %v", b.BookID, body, err)
|
||
}
|
||
for _, want := range []string{"book.yaml", "source.txt", b.BookID + ".db.manifest.json", "events.jsonl"} {
|
||
if !names[filepath.ToSlash(filepath.Join(BooksDir, b.BookID, want))] {
|
||
t.Errorf("%s: %s did not make it into the point: %v", b.BookID, want, names)
|
||
}
|
||
}
|
||
for _, unwanted := range []string{b.BookID + ".db", b.BookID + ".db-wal", "backups/older-engine-point.db"} {
|
||
if names[filepath.ToSlash(filepath.Join(BooksDir, b.BookID, unwanted))] {
|
||
t.Errorf("%s: %s should not be in a restore point: %v", b.BookID, unwanted, names)
|
||
}
|
||
}
|
||
}
|
||
// The engine's copy is taken AWAY, not left behind: otherwise every pass grows each book's own
|
||
// directory by a whole database.
|
||
for _, b := range books {
|
||
if _, err := os.Stat(filepath.Join(b.Workdir, "backups", "snapshot.db")); !errors.Is(err, os.ErrNotExist) {
|
||
t.Errorf("%s: the engine's copy was left in the book's directory (%v)", b.ID, err)
|
||
}
|
||
}
|
||
if points, _ := List(dest); len(points) != 1 {
|
||
t.Errorf("published points: %v", points)
|
||
}
|
||
}
|
||
|
||
// The money registry has no partial outcome: a point without it answers nothing about who paid what,
|
||
// so a failed dump publishes NOTHING and leaves no staging directory behind either.
|
||
//
|
||
// Mutation caught: publishing anyway (a restore point that looks complete and carries no ledger);
|
||
// returning early without the cleanup (a full copy of the deployment accumulates per failure).
|
||
func TestAFailedLedgerDumpPublishesNothingAndLeavesNothing(t *testing.T) {
|
||
root := t.TempDir()
|
||
svc, dest := service(t, []pgstore.BookDirectory{aBook(t, root, "bk_one")}, &fakeEngine{}, true)
|
||
if _, err := svc.Take(t.Context()); err == nil {
|
||
t.Fatal("a failed pg_dump was reported as a restore point")
|
||
}
|
||
points, err := List(dest)
|
||
if err != nil || len(points) != 0 {
|
||
t.Errorf("a point was published without the ledger: %v (%v)", points, err)
|
||
}
|
||
entries, err := os.ReadDir(dest)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(entries) != 0 {
|
||
t.Errorf("the staging directory was left behind: %v", entries)
|
||
}
|
||
}
|
||
|
||
// A book that cannot be copied does NOT sink the pass — nine books saved beat none — but the point
|
||
// says so about itself, and Verify repeats it. The alternative is the dangerous one: a point that
|
||
// silently holds less than it claims.
|
||
func TestABookThatCannotBeCopiedMarksThePointIncompleteRatherThanFailingIt(t *testing.T) {
|
||
root := t.TempDir()
|
||
ok := aBook(t, root, "bk_ok")
|
||
|
||
// ⚠ A MISSING DIRECTORY IS TWO FACTS, AND THIS TEST ASSERTED ONLY THE HARMLESS ONE UNTIL 05.09.
|
||
// Its earlier edition treated ANY absent directory as an ordinary skip that leaves the point
|
||
// complete — reasoning that a rejected upload takes its directory with it. True for a rejected
|
||
// book; false for every other status, and the false half is the dangerous one: an unmounted books
|
||
// volume, or a books tree moved while the absolute workdirs in Postgres stayed put, makes EVERY
|
||
// book an "ordinary skip" and publishes a point holding only the ledger, marked complete, with the
|
||
// age gauge green. Retention then evicts the last point that held any paid work on schedule.
|
||
// The assertion below is the corrected one and is strictly stronger.
|
||
gone := pgstore.BookDirectory{ID: "bk_gone", Workdir: filepath.Join(root, "not_here"), Status: "not_started"}
|
||
svc, _ := service(t, []pgstore.BookDirectory{ok, gone}, &fakeEngine{}, false)
|
||
res, err := svc.Take(t.Context())
|
||
if err != nil {
|
||
t.Fatalf("one absent book ended the whole pass: %v", err)
|
||
}
|
||
if res.Books != 1 || res.Skipped != 1 {
|
||
t.Fatalf("counts: %+v", res)
|
||
}
|
||
m, err := ReadManifest(res.Path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if m.Complete {
|
||
t.Errorf("a book whose status says its directory should be there went missing, and the point still calls itself complete: %+v", m)
|
||
}
|
||
var skipped string
|
||
for _, b := range m.Books {
|
||
if b.BookID == "bk_gone" {
|
||
skipped = b.Skipped
|
||
}
|
||
}
|
||
if skipped == "" || !strings.Contains(skipped, "not_started") {
|
||
t.Errorf("the hole does not name the status that makes it one: %q", skipped)
|
||
}
|
||
|
||
// The other half: a REJECTED book legitimately has no directory — its reject path removed it with
|
||
// the source — and that must NOT make every point of every deployment read incomplete, which is
|
||
// how the flag stops meaning anything.
|
||
rejected := pgstore.BookDirectory{ID: "bk_rejected", Workdir: filepath.Join(root, "also_not_here"), Status: "rejected"}
|
||
svcR, _ := service(t, []pgstore.BookDirectory{aBook(t, root, "bk_fine"), rejected}, &fakeEngine{}, false)
|
||
resR, err := svcR.Take(t.Context())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !resR.Complete || resR.Skipped != 1 {
|
||
t.Errorf("a rejected book with no directory was treated as a hole: %+v", resR)
|
||
}
|
||
|
||
// An engine that FAILS is different: something is wrong and the point is not what it claims.
|
||
svc2, _ := service(t, []pgstore.BookDirectory{aBook(t, root, "bk_two")}, &fakeEngine{fail: errors.New("no such binary")}, false)
|
||
res2, err := svc2.Take(t.Context())
|
||
if err != nil {
|
||
t.Fatalf("a failing engine ended the whole pass: %v", err)
|
||
}
|
||
if res2.Complete {
|
||
t.Error("a book the engine could not copy left the point marked complete")
|
||
}
|
||
problems, err := Verify(res2.Path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(problems) == 0 || !strings.Contains(strings.Join(problems, " "), "INCOMPLETE") {
|
||
t.Errorf("Verify does not repeat the point's own verdict: %v", problems)
|
||
}
|
||
}
|
||
|
||
// ⛔ THE WORST OBJECT THIS PACKAGE CAN PRODUCE: a point that says `complete: true` over a hole. It was
|
||
// REAL — measured 05.09 by running two `tmplatformctl backup` at once against a live stand — and it
|
||
// came from treating every non-zero engine exit as one ordinary "skip".
|
||
//
|
||
// The engine refuses to overwrite a restore point it already made under the same timestamp
|
||
// (backend/internal/store/backup.go: "refusing to overwrite a restore point"), so the loser of a
|
||
// same-second race made the winner's engine call fail — and the winner published `books=0 skipped=1
|
||
// complete=true`. The discriminator is the book's own intake status and nothing else.
|
||
//
|
||
// Mutation caught: collapsing the two branches back into one skip; reading the discriminator off the
|
||
// exit code or the engine's message; making a not-yet-cut book fail the whole pass.
|
||
func TestAnEngineRefusalOnACutBookIsAHoleAndNotASkip(t *testing.T) {
|
||
root := t.TempDir()
|
||
cut := aBook(t, root, "bk_cut") // status not_started: intake finished, a database must exist
|
||
svc, _ := service(t, []pgstore.BookDirectory{cut}, &fakeEngine{refuse: true}, false)
|
||
res, err := svc.Take(t.Context())
|
||
if err != nil {
|
||
t.Fatalf("one refused book ended the whole pass: %v", err)
|
||
}
|
||
if res.Complete {
|
||
t.Error("a point holding NO copy of a cut book was published as complete")
|
||
}
|
||
m, err := ReadManifest(res.Path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if m.Complete || len(m.Books) != 1 || m.Books[0].Skipped == "" {
|
||
t.Errorf("the manifest does not name the hole: %+v", m)
|
||
}
|
||
|
||
// The other half: a book whose intake has NOT finished has nothing to copy yet, and that must NOT
|
||
// make every point of every deployment read "incomplete" — which is how the flag stops meaning
|
||
// anything.
|
||
for _, status := range []string{"uploading", "parsing"} {
|
||
early := aBook(t, root, "bk_"+status)
|
||
early.Status = status
|
||
svc2, _ := service(t, []pgstore.BookDirectory{early}, &fakeEngine{refuse: true}, false)
|
||
res2, err := svc2.Take(t.Context())
|
||
if err != nil {
|
||
t.Fatalf("%s: %v", status, err)
|
||
}
|
||
if !res2.Complete || res2.Skipped != 1 {
|
||
t.Errorf("%s: a book with no database yet was treated as a hole: %+v", status, res2)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Two passes in the same second must not share a staging directory: the winner renames it away under
|
||
// the loser's feet, and the loser's half-written files are what the winner then publishes.
|
||
//
|
||
// Mutation caught: MkdirAll instead of Mkdir — the exact one-word difference that produced the
|
||
// measured failure above.
|
||
func TestTwoPassesInOneSecondDoNotShareAStagingDirectory(t *testing.T) {
|
||
root := t.TempDir()
|
||
svc, dest := service(t, []pgstore.BookDirectory{aBook(t, root, "bk_one")}, &fakeEngine{}, false)
|
||
fixed := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
|
||
svc.Now = func() time.Time { return fixed }
|
||
if _, err := svc.Take(t.Context()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// A second pass in the same second finds the published point and refuses BEFORE doing any work.
|
||
if _, err := svc.Take(t.Context()); err == nil {
|
||
t.Fatal("a second pass in the same second was allowed to run")
|
||
}
|
||
// And with the first pass still in flight — its staging directory present, nothing published —
|
||
// the second must refuse on the CLAIM rather than start writing into it.
|
||
staging := filepath.Join(dest, partialPrefix+"20260905T130000Z")
|
||
if err := os.Mkdir(staging, 0o750); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
svc.Now = func() time.Time { return fixed.Add(time.Hour) }
|
||
_, err := svc.Take(t.Context())
|
||
if err == nil || !strings.Contains(err.Error(), "already being written") {
|
||
t.Fatalf("a pass wrote into another pass's staging directory: %v", err)
|
||
}
|
||
if points, _ := List(dest); len(points) != 1 {
|
||
t.Errorf("published points: %v", points)
|
||
}
|
||
}
|
||
|
||
// ⛔ THE ENGINE'S `.env` OF PROVIDER KEYS MUST NEVER REACH A RESTORE POINT. The runbook tells the
|
||
// operator to ship this directory OFF THE HOST; a backup that carried the deployment's API keys with
|
||
// it would be a credential leak on a schedule.
|
||
//
|
||
// Mutation caught: dropping the dotfile rule (the keys ride out with every generation).
|
||
func TestProviderKeysBesideABookAreNeverCopiedAndTheOmissionIsRecorded(t *testing.T) {
|
||
root := t.TempDir()
|
||
b := aBook(t, root, "bk_keys")
|
||
if err := os.WriteFile(filepath.Join(b.Workdir, ".env"), []byte("DEEPSEEK_API_KEY=sk-live-do-not-copy"+"\n"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
svc, _ := service(t, []pgstore.BookDirectory{b}, &fakeEngine{}, false)
|
||
res, err := svc.Take(t.Context())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// Not by reading the manifest — by walking what actually landed on the disk.
|
||
var leaked []string
|
||
err = filepath.WalkDir(res.Path, func(p string, d os.DirEntry, err error) error {
|
||
if err != nil || d.IsDir() {
|
||
return err
|
||
}
|
||
body, rerr := os.ReadFile(p)
|
||
if rerr == nil && strings.Contains(string(body), "sk-live-do-not-copy") {
|
||
leaked = append(leaked, p)
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(leaked) > 0 {
|
||
t.Fatalf("provider keys are inside the restore point: %v", leaked)
|
||
}
|
||
// And the omission is NAMED, so nothing goes missing quietly.
|
||
m, err := ReadManifest(res.Path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !strings.Contains(strings.Join(m.Books[0].NotCopied, " "), ".env") {
|
||
t.Errorf("the skipped dotfile is not recorded: %v", m.Books[0].NotCopied)
|
||
}
|
||
}
|
||
|
||
// A book's own data can live in a SUBDIRECTORY — `langpack_extend` is a per-book overlay of the
|
||
// pair's canon, resolved against book.yaml. An earlier version of this package skipped every
|
||
// subdirectory and still called the point COMPLETE, which is a book's canon lost in silence.
|
||
//
|
||
// Mutation caught: going back to `if e.IsDir() { continue }`; or recursing into the engine's own
|
||
// `backups/`, which is what makes a backup directory grow quadratically.
|
||
func TestABooksSubdirectoriesAreCopiedButTheEnginesOwnBackupsAreNot(t *testing.T) {
|
||
root := t.TempDir()
|
||
b := aBook(t, root, "bk_deep")
|
||
if err := os.MkdirAll(filepath.Join(b.Workdir, "langpack", "zh-ru"), 0o750); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(b.Workdir, "langpack", "zh-ru", "heading.txt"), []byte("Глава {n}"+"\n"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
svc, _ := service(t, []pgstore.BookDirectory{b}, &fakeEngine{}, false)
|
||
res, err := svc.Take(t.Context())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if body, err := os.ReadFile(filepath.Join(res.Path, BooksDir, "bk_deep", "langpack", "zh-ru", "heading.txt")); err != nil ||
|
||
string(body) != "Глава {n}"+"\n" {
|
||
t.Errorf("a book's own data in a subdirectory did not survive: %q (%v)", body, err)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(res.Path, BooksDir, "bk_deep", engineBackupsDir)); !errors.Is(err, os.ErrNotExist) {
|
||
t.Errorf("the engine's own restore points were copied into ours (%v)", err)
|
||
}
|
||
m, err := ReadManifest(res.Path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !strings.Contains(strings.Join(m.Books[0].NotCopied, " "), engineBackupsDir+"/") {
|
||
t.Errorf("the skipped directory is not recorded: %v", m.Books[0].NotCopied)
|
||
}
|
||
}
|
||
|
||
// A pass that runs out of its budget publishes NOTHING. Truncating instead would drop the NEWEST
|
||
// books — the listing is oldest-first — while resetting the age gauge, so every generation would omit
|
||
// exactly the work being paid for now and the one alertable number would stay green.
|
||
func TestAPassThatRunsOutOfItsBudgetPublishesNothing(t *testing.T) {
|
||
root := t.TempDir()
|
||
books := []pgstore.BookDirectory{aBook(t, root, "bk_a"), aBook(t, root, "bk_b")}
|
||
svc, dest := service(t, books, &fakeEngine{}, false)
|
||
ctx, cancel := context.WithCancel(t.Context())
|
||
cancel() // the deadline has already passed when the books are reached
|
||
if _, err := svc.Take(ctx); err == nil {
|
||
t.Fatal("a pass with no budget left published a restore point")
|
||
}
|
||
if points, _ := List(dest); len(points) != 0 {
|
||
t.Errorf("a truncated point was published: %v", points)
|
||
}
|
||
entries, _ := os.ReadDir(dest)
|
||
if len(entries) != 0 {
|
||
t.Errorf("staging was left behind: %v", entries)
|
||
}
|
||
}
|
||
|
||
// The age gauge reports the newest COMPLETE point. A point that could not copy a book is worth
|
||
// keeping and is not a backup OF THAT BOOK, so letting its timestamp reset the age would hide exactly
|
||
// the state an operator needs paging for.
|
||
func TestTheAgeGaugeIgnoresAnIncompletePoint(t *testing.T) {
|
||
root := t.TempDir()
|
||
good := aBook(t, root, "bk_ok")
|
||
svc, _ := service(t, []pgstore.BookDirectory{good}, &fakeEngine{}, false)
|
||
at := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
|
||
svc.Now = func() time.Time { return at }
|
||
if _, err := svc.Take(t.Context()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
age, has, err := svc.Age(at.Add(time.Hour))
|
||
if err != nil || !has || age != time.Hour {
|
||
t.Fatalf("a complete point: age=%v has=%v err=%v", age, has, err)
|
||
}
|
||
// A LATER point that is incomplete must not make the deployment look freshly backed up.
|
||
svc.Store = &fakeStore{books: []pgstore.BookDirectory{good}}
|
||
svc.Engine = &fakeEngine{refuse: true}
|
||
at = at.Add(6 * time.Hour)
|
||
if _, err := svc.Take(t.Context()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
age, has, err = svc.Age(at.Add(time.Hour))
|
||
if err != nil || !has || age != 7*time.Hour {
|
||
t.Errorf("an incomplete point reset the age: age=%v has=%v err=%v (want 7h, the last COMPLETE one)", age, has, err)
|
||
}
|
||
}
|
||
|
||
// A book that fails PART WAY must leave NOTHING behind, not a half-copied database the manifest does
|
||
// not mention.
|
||
//
|
||
// ⚠ The runbook's restore step is `cp -a books/<id>/. <workdir>/`. A truncated `project.db` sitting in
|
||
// a point whose manifest says the book was skipped is therefore not an untidiness: it is an operator
|
||
// copying a torn database over a good one, having been told this book was not in the point at all.
|
||
//
|
||
// Mutation caught: dropping the RemoveAll — the partial files are published, Verify never hashes them
|
||
// because the manifest has no entry for them, and `--verify` reports the point sound.
|
||
func TestAPartlyCopiedBookLeavesNothingInThePoint(t *testing.T) {
|
||
root := t.TempDir()
|
||
b := aBook(t, root, "bk_halfway")
|
||
// The engine's snapshot lands, and the rest of the copy then fails: a directory the copier cannot
|
||
// read reproduces "failed after some files were already written".
|
||
unreadable := filepath.Join(b.Workdir, "locked")
|
||
if err := os.Mkdir(unreadable, 0o000); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { _ = os.Chmod(unreadable, 0o750) })
|
||
svc, _ := service(t, []pgstore.BookDirectory{b}, &fakeEngine{}, false)
|
||
res, err := svc.Take(t.Context())
|
||
if err != nil {
|
||
t.Fatalf("one book's failure ended the whole pass: %v", err)
|
||
}
|
||
if res.Complete {
|
||
t.Error("a point missing a book was published as complete")
|
||
}
|
||
if _, err := os.Stat(filepath.Join(res.Path, BooksDir, "bk_halfway")); !errors.Is(err, os.ErrNotExist) {
|
||
left, _ := os.ReadDir(filepath.Join(res.Path, BooksDir, "bk_halfway"))
|
||
names := []string{}
|
||
for _, e := range left {
|
||
names = append(names, e.Name())
|
||
}
|
||
t.Errorf("a skipped book left files in the point that the manifest does not mention: %v", names)
|
||
}
|
||
// And the manifest is an exact description of what is on the disk: everything it lists verifies,
|
||
// and it lists everything.
|
||
if problems, err := Verify(res.Path); err != nil {
|
||
t.Fatal(err)
|
||
} else if len(problems) != 1 || !strings.Contains(problems[0], "INCOMPLETE") {
|
||
t.Errorf("Verify: %v", problems)
|
||
}
|
||
}
|
||
|
||
// The dump is READ BACK before the point is published, and a dump that cannot be read back does not
|
||
// become a restore point. Nothing observed this: the double `pg_restore` exited 0 whatever it was
|
||
// handed, so removing the check entirely would have left every test green.
|
||
func TestADumpThatCannotBeReadBackIsNotPublished(t *testing.T) {
|
||
root := t.TempDir()
|
||
tools := t.TempDir()
|
||
dest := t.TempDir()
|
||
svc := &Service{
|
||
Cfg: Config{Dir: dest, Every: time.Hour, Keep: 3,
|
||
PgDumpBin: fakePg(t, tools, "pg_dump", false),
|
||
PgRestoreBin: fakeRefusing(t, tools, "pg_restore"),
|
||
DSN: "postgres://u:pw@127.0.0.1:5432/db?sslmode=disable",
|
||
EngineBinary: "/opt/engine/tmctl"},
|
||
Store: &fakeStore{books: []pgstore.BookDirectory{aBook(t, root, "bk_one")}}, Engine: &fakeEngine{},
|
||
}
|
||
_, err := svc.Take(t.Context())
|
||
if err == nil {
|
||
t.Fatal("a dump pg_restore cannot read was published as a restore point")
|
||
}
|
||
if !strings.Contains(err.Error(), "cannot be read back") {
|
||
t.Errorf("the error does not say what is wrong: %v", err)
|
||
}
|
||
if points, _ := List(dest); len(points) != 0 {
|
||
t.Errorf("published anyway: %v", points)
|
||
}
|
||
}
|
||
|
||
// Two guards their comments argue for at length and nothing checked: a book directory holding
|
||
// something that is not a regular file (following a symlink would copy from outside the book), and
|
||
// the removal of a `.partial-` a killed pass left behind.
|
||
func TestTheSymlinkGuardAndTheStaleStagingPruneBothWork(t *testing.T) {
|
||
root := t.TempDir()
|
||
b := aBook(t, root, "bk_one")
|
||
// A symlink pointing OUT of the book, which must not be followed into the point.
|
||
outside := filepath.Join(t.TempDir(), "somebody-elses-secret")
|
||
if err := os.WriteFile(outside, []byte("NOT THIS BOOK'S"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.Symlink(outside, filepath.Join(b.Workdir, "escape.txt")); err != nil {
|
||
t.Skipf("this filesystem does not do symlinks: %v", err)
|
||
}
|
||
svc, dest := service(t, []pgstore.BookDirectory{b}, &fakeEngine{}, false)
|
||
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
|
||
svc.Now = func() time.Time { return now }
|
||
|
||
// A leftover staging directory from a pass killed two days ago.
|
||
stale := filepath.Join(dest, partialPrefix+"20260903T120000Z")
|
||
if err := os.MkdirAll(stale, 0o750); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// ...and one from a pass that may still be running.
|
||
fresh := filepath.Join(dest, partialPrefix+"20260905T113000Z")
|
||
if err := os.MkdirAll(fresh, 0o750); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
res, err := svc.Take(t.Context())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(res.Path, BooksDir, "bk_one", "escape.txt")); !errors.Is(err, os.ErrNotExist) {
|
||
t.Errorf("a symlink out of the book was followed into the restore point (%v)", err)
|
||
}
|
||
m, err := ReadManifest(res.Path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !strings.Contains(strings.Join(m.Books[0].NotCopied, " "), "escape.txt") {
|
||
t.Errorf("the skipped symlink is not recorded: %v", m.Books[0].NotCopied)
|
||
}
|
||
if _, err := os.Stat(stale); !errors.Is(err, os.ErrNotExist) {
|
||
t.Errorf("a day-old staging leftover was not pruned (%v)", err)
|
||
}
|
||
if _, err := os.Stat(fresh); err != nil {
|
||
t.Errorf("a staging directory young enough to be a LIVE pass was removed under it: %v", err)
|
||
}
|
||
}
|
||
|
||
// ⛔ A BOOK WHOSE SOURCE IS NAMED `source.db` MUST NOT LOSE ITS SOURCE. The intake keeps the user's
|
||
// own extension verbatim (`extensionOf` is not an allowlist), so a reader who uploads `novel.db` gets
|
||
// `source.db` on disk — and a rule that skips everything ending in `.db` drops the book's own text
|
||
// from every restore point. The source is the one file in that directory nobody can be asked to
|
||
// supply again.
|
||
//
|
||
// Mutation caught: dropping the `!isTheUploadedSource(name)` half of the guard.
|
||
func TestASourceNamedLikeADatabaseIsStillCopied(t *testing.T) {
|
||
root := t.TempDir()
|
||
b := aBook(t, root, "bk_dbsource")
|
||
// The book's real source, under the name the intake would have written for an upload called
|
||
// `novel.db` — and the live project database beside it, which must still be skipped.
|
||
if err := os.WriteFile(filepath.Join(b.Workdir, "source.db"), []byte("第一章 текст книги"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
svc, _ := service(t, []pgstore.BookDirectory{b}, &fakeEngine{}, false)
|
||
res, err := svc.Take(t.Context())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
body, err := os.ReadFile(filepath.Join(res.Path, BooksDir, "bk_dbsource", "source.db"))
|
||
if err != nil || string(body) != "第一章 текст книги" {
|
||
t.Fatalf("the book's source was dropped because it is named like a database: %q (%v)", body, err)
|
||
}
|
||
// ...and the live database beside it is STILL skipped, so the guard did not simply turn the rule off.
|
||
if _, err := os.Stat(filepath.Join(res.Path, BooksDir, "bk_dbsource", "bk_dbsource.db")); !errors.Is(err, os.ErrNotExist) {
|
||
t.Errorf("the live project database was copied after all (%v)", err)
|
||
}
|
||
m, err := ReadManifest(res.Path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !strings.Contains(strings.Join(m.Books[0].NotCopied, " "), "bk_dbsource.db ") {
|
||
t.Errorf("the live database is not recorded as skipped: %v", m.Books[0].NotCopied)
|
||
}
|
||
}
|
||
|
||
// ⛔ RETENTION MUST NOT BE A COUNTDOWN TO DATA LOSS. Retention counts DIRECTORIES, and a deployment
|
||
// producing degraded points — an unmounted books volume, an engine that stopped answering — produces
|
||
// them on the same schedule as good ones. With the defaults that is `Keep × Every` before every point
|
||
// holding the paid work has been evicted, and the operator's only signal arrives after the last real
|
||
// backup is already gone.
|
||
//
|
||
// Mutation caught: dropping the newestComplete exception from prune.
|
||
func TestARunOfDegradedPointsCannotEvictTheLastCompleteOne(t *testing.T) {
|
||
root := t.TempDir()
|
||
good := aBook(t, root, "bk_ok")
|
||
svc, dest := service(t, []pgstore.BookDirectory{good}, &fakeEngine{}, false)
|
||
svc.Cfg.Keep = 2
|
||
at := time.Date(2026, 9, 5, 0, 0, 0, 0, time.UTC)
|
||
svc.Now = func() time.Time { return at }
|
||
|
||
// One good point.
|
||
res, err := svc.Take(t.Context())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
theGoodOne := res.Stamp
|
||
|
||
// Then the outage: the book's directory is gone while its status says it should be there, so every
|
||
// later point is incomplete. Four of them — twice the retention count.
|
||
if err := os.RemoveAll(good.Workdir); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for range 4 {
|
||
at = at.Add(6 * time.Hour)
|
||
if _, err := svc.Take(t.Context()); err != nil {
|
||
t.Fatalf("a degraded pass failed outright: %v", err)
|
||
}
|
||
}
|
||
|
||
points, err := List(dest)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var found bool
|
||
for _, p := range points {
|
||
if p == theGoodOne {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("the only point holding the paid work was evicted by retention; what is left: %v", points)
|
||
}
|
||
// And it is still the one the gauge reports, so the operator is not told the deployment is fresh.
|
||
age, has, err := svc.Age(at)
|
||
if err != nil || !has {
|
||
t.Fatalf("age: %v %v", has, err)
|
||
}
|
||
if age != 24*time.Hour {
|
||
t.Errorf("age %v: the gauge is not reporting the last COMPLETE point", age)
|
||
}
|
||
}
|
||
|
||
// ⛔ PV2-02: A BOOK STILL IN INTAKE MUST GET ITS FILES INTO THE POINT. It has no project database
|
||
// yet, and an earlier version answered that by taking NOTHING — not the uploaded source, not
|
||
// `book.yaml`, not the journal — while the point still called itself complete.
|
||
//
|
||
// ⚠ `parsing` is not a five-minute window: the intake's `not_configured` and `storage_unavailable`
|
||
// never become terminal (internal/books/parse.go), so a host with a broken book template holds every
|
||
// upload in that state indefinitely. Those uploads are exactly the ones with no other copy anywhere,
|
||
// and this package's own comment calls the source irreplaceable.
|
||
//
|
||
// Mutation caught: returning from copyBook before copyBookFiles on the not-yet-cut branch.
|
||
func TestABookStillInIntakeStillGetsItsSourceIntoThePoint(t *testing.T) {
|
||
root := t.TempDir()
|
||
for _, status := range []string{"uploading", "parsing"} {
|
||
b := aBook(t, root, "bk_"+status)
|
||
b.Status = status
|
||
svc, _ := service(t, []pgstore.BookDirectory{b}, &fakeEngine{refuse: true}, false)
|
||
res, err := svc.Take(t.Context())
|
||
if err != nil {
|
||
t.Fatalf("%s: %v", status, err)
|
||
}
|
||
if !res.Complete {
|
||
t.Errorf("%s: a book that legitimately has no database made the point incomplete", status)
|
||
}
|
||
// THE POINT: its own files are here, whatever the database situation.
|
||
for _, want := range []string{"source.txt", "book.yaml", "events.jsonl"} {
|
||
p := filepath.Join(res.Path, BooksDir, b.ID, want)
|
||
if _, err := os.Stat(p); err != nil {
|
||
t.Errorf("%s: %s is not in the point — the user's own upload was dropped: %v", status, want, err)
|
||
}
|
||
}
|
||
m, err := ReadManifest(res.Path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(m.Books[0].Files) == 0 {
|
||
t.Errorf("%s: the manifest lists no files for a book whose directory was full", status)
|
||
}
|
||
if m.Books[0].Skipped == "" {
|
||
t.Errorf("%s: the absent database is not explained", status)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ⛔ PV2-03: A REJECTED BOOK THAT KEPT ITS DIRECTORY IS NOT A HOLE. Only one of the two terminal
|
||
// reject paths removes the directory — `ReasonSourceUnreadable` — while the other (the attempt budget
|
||
// exhausted because the engine would not run) deliberately KEEPS the user's file
|
||
// (internal/books/parse.go, `if reason == ReasonSourceUnreadable { s.removeDir(...) }`).
|
||
//
|
||
// Such a book has a directory and no database, which fell between the branches and made every point
|
||
// incomplete FOREVER: the age gauge then reads +Inf permanently, the one alertable signal of real
|
||
// backup loss is stuck red and worthless, and prune's "never evict the newest COMPLETE point" becomes
|
||
// vacuous because no complete point exists. And the cause that produces such books — the engine not
|
||
// running — is the same deployment incident.
|
||
//
|
||
// Mutation caught: dropping `|| b.Status == statusRejected` from the engine-refusal branch.
|
||
func TestARejectedBookThatKeptItsDirectoryIsNotAHole(t *testing.T) {
|
||
root := t.TempDir()
|
||
b := aBook(t, root, "bk_rejected_kept")
|
||
b.Status = "rejected" // its directory is right there: the reject path kept the user's file
|
||
svc, dest := service(t, []pgstore.BookDirectory{b}, &fakeEngine{refuse: true}, false)
|
||
at := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
|
||
svc.Now = func() time.Time { return at }
|
||
res, err := svc.Take(t.Context())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !res.Complete {
|
||
t.Fatal("a rejected book that kept its directory made the point incomplete — the gauge would stick at +Inf forever")
|
||
}
|
||
// The gauge must actually report an age, which is the half that matters operationally.
|
||
age, has, err := svc.Age(at.Add(time.Hour))
|
||
if err != nil || !has || age != time.Hour {
|
||
t.Errorf("the age gauge has no complete point to report: age=%v has=%v err=%v", age, has, err)
|
||
}
|
||
// And the user's file the deployment deliberately kept is in the point.
|
||
if _, err := os.Stat(filepath.Join(res.Path, BooksDir, b.ID, "source.txt")); err != nil {
|
||
t.Errorf("the source the reject path kept was not backed up: %v", err)
|
||
}
|
||
if points, _ := List(dest); len(points) != 1 {
|
||
t.Errorf("points: %v", points)
|
||
}
|
||
}
|
||
|
||
// Verify is what makes the manifest a guarantee rather than a description: a copy that rotted on the
|
||
// disk is indistinguishable from a good one by size alone, and the moment to find out is not the
|
||
// moment you need it.
|
||
func TestVerifyCatchesAFileThatChangedUnderTheManifest(t *testing.T) {
|
||
root := t.TempDir()
|
||
svc, _ := service(t, []pgstore.BookDirectory{aBook(t, root, "bk_one")}, &fakeEngine{}, false)
|
||
res, err := svc.Take(t.Context())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if problems, err := Verify(res.Path); err != nil || len(problems) != 0 {
|
||
t.Fatalf("a fresh point does not verify: %v (%v)", problems, err)
|
||
}
|
||
db := filepath.Join(res.Path, BooksDir, "bk_one", ProjectDBName)
|
||
// The SAME LENGTH, one byte different — which is the case a size check cannot see and the one
|
||
// silent corruption actually looks like.
|
||
if err := os.WriteFile(db, []byte("CONSISTENT-COPX"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
problems, err := Verify(res.Path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(problems) != 1 || !strings.Contains(problems[0], "sha256") {
|
||
t.Errorf("a silently corrupted database was not caught: %v", problems)
|
||
}
|
||
}
|
||
|
||
// A manifest of a shape this build does not know is REFUSED rather than half-read. That is the whole
|
||
// reason the document carries a version, and the failure it prevents is the worst kind: a restore
|
||
// that reads three fields of five and reports success.
|
||
func TestARestorePointOfAnUnknownShapeIsRefused(t *testing.T) {
|
||
dir := t.TempDir()
|
||
point := filepath.Join(dir, "20260905T120000Z")
|
||
if err := os.MkdirAll(point, 0o750); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
raw, _ := json.Marshal(Manifest{Version: "tm-platform-restore-point-v9", Stamp: "20260905T120000Z"})
|
||
if err := os.WriteFile(filepath.Join(point, ManifestFile), raw, 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := ReadManifest(point); err == nil || !strings.Contains(err.Error(), "this build reads") {
|
||
t.Errorf("a future restore point was read as if it were this one's: %v", err)
|
||
}
|
||
}
|
||
|
||
// Keep is enforced AFTER a publish and never before: a prune that ran first would, on a host that
|
||
// then failed to write the new point, leave fewer copies than it started with.
|
||
func TestOldPointsGoOnlyOnceANewOneExists(t *testing.T) {
|
||
root := t.TempDir()
|
||
svc, dest := service(t, []pgstore.BookDirectory{aBook(t, root, "bk_one")}, &fakeEngine{}, false)
|
||
svc.Cfg.Keep = 2
|
||
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
|
||
svc.Now = func() time.Time { return now }
|
||
for range 4 {
|
||
if _, err := svc.Take(t.Context()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
now = now.Add(time.Hour)
|
||
}
|
||
points, err := List(dest)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(points) != 2 {
|
||
t.Fatalf("kept %v, want the newest 2", points)
|
||
}
|
||
if points[1] != "20260905T150000Z" || points[0] != "20260905T140000Z" {
|
||
t.Errorf("the wrong two survived: %v", points)
|
||
}
|
||
}
|
||
|
||
// The pass decides for itself whether a point is due, so the schedule survives a restart with no
|
||
// state of its own — and a deployment on a fifteen-second sweep tick does not take a full copy of
|
||
// itself every fifteen seconds.
|
||
func TestTheSweepTakesAPointOnlyWhenOneIsDue(t *testing.T) {
|
||
root := t.TempDir()
|
||
svc, dest := service(t, []pgstore.BookDirectory{aBook(t, root, "bk_one")}, &fakeEngine{}, false)
|
||
svc.Cfg.Every = 6 * time.Hour
|
||
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
|
||
svc.Now = func() time.Time { return now }
|
||
if err := svc.Sweep(t.Context()); err != nil { // no point at all: one is due
|
||
t.Fatal(err)
|
||
}
|
||
now = now.Add(5 * time.Hour)
|
||
if err := svc.Sweep(t.Context()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if points, _ := List(dest); len(points) != 1 {
|
||
t.Fatalf("a point was taken before it was due: %v", points)
|
||
}
|
||
now = now.Add(2 * time.Hour)
|
||
if err := svc.Sweep(t.Context()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if points, _ := List(dest); len(points) != 2 {
|
||
t.Fatalf("no point was taken when one was due: %v", points)
|
||
}
|
||
age, has, err := svc.Age(now)
|
||
if err != nil || !has || age != 0 {
|
||
t.Errorf("age of the newest point: %v %v %v", age, has, err)
|
||
}
|
||
}
|
||
|
||
// The DSN carries a password and it must never reach an argv: /proc/<pid>/cmdline is readable by
|
||
// every process on the host.
|
||
//
|
||
// Mutation caught: passing the connection string through as `-d <dsn>`, which is the shortest way to
|
||
// write this function and leaks the password to every local process.
|
||
func TestThePasswordTravelsInTheEnvironmentAndNeverInTheArgv(t *testing.T) {
|
||
args, env, err := dumpArgs("postgres://alice:s3cr3t@db.example:6432/tm?sslmode=disable", "/tmp/out.dump")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if strings.Contains(strings.Join(args, " "), "s3cr3t") {
|
||
t.Fatalf("the password is on the command line: %q", args)
|
||
}
|
||
var found bool
|
||
for _, kv := range env {
|
||
if kv == "PGPASSWORD=s3cr3t" {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Error("the password did not reach the child's environment, so the dump would prompt and hang")
|
||
}
|
||
joined := strings.Join(args, " ")
|
||
for _, want := range []string{"db.example", "6432", "alice", "/tm", "--format=custom",
|
||
"--file=/tmp/out.dump", "--no-password"} {
|
||
if !strings.Contains(joined, want) {
|
||
t.Errorf("missing %s in %q", want, joined)
|
||
}
|
||
}
|
||
// ⛔ AND EVERY OTHER PARAMETER THE OPERATOR PUT IN THE DSN, `sslmode` above all. An earlier version
|
||
// rebuilt the connection from host/port/user/dbname and dropped the rest, so a deployment whose
|
||
// DSN says `verify-full` had its nightly dump go out under libpq's default `prefer` — TLS optional,
|
||
// certificate unchecked — and nothing said so, because pg_dump succeeds either way.
|
||
strict, _, err := dumpArgs("postgres://alice:s3cr3t@db.example:6432/tm?sslmode=verify-full&sslrootcert=/etc/ca.pem&connect_timeout=9", "/tmp/o")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
sj := strings.Join(strict, " ")
|
||
for _, want := range []string{"sslmode=verify-full", "sslrootcert=/etc/ca.pem", "connect_timeout=9"} {
|
||
if !strings.Contains(sj, want) {
|
||
t.Errorf("the DSN's %s did not reach pg_dump — the dump would run under libpq defaults: %q", want, sj)
|
||
}
|
||
}
|
||
if strings.Contains(sj, "s3cr3t") {
|
||
t.Errorf("forwarding the DSN carried the password onto the argv: %q", sj)
|
||
}
|
||
// A DSN that cannot be parsed must not put itself into the error: that is the same leak by
|
||
// another door.
|
||
_, _, err = dumpArgs("://not a dsn:hunter2@", "/tmp/x")
|
||
if err == nil || strings.Contains(err.Error(), "hunter2") {
|
||
t.Errorf("the unparseable DSN leaked into the message: %v", err)
|
||
}
|
||
}
|
||
|
||
// The restore instructions travel INSIDE the point, because a restore happens on the worst day this
|
||
// deployment has and possibly from a copy that travelled away from this repository.
|
||
func TestAPointCarriesItsOwnRestoreInstructions(t *testing.T) {
|
||
root := t.TempDir()
|
||
svc, _ := service(t, []pgstore.BookDirectory{aBook(t, root, "bk_one")}, &fakeEngine{}, false)
|
||
res, err := svc.Take(t.Context())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
m, err := ReadManifest(res.Path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
joined := strings.Join(m.Notes, "\n")
|
||
for _, want := range []string{"pg_restore", ProjectDBName, "project_db"} {
|
||
if !strings.Contains(joined, want) {
|
||
t.Errorf("the notes do not say how to restore (%s missing): %v", want, m.Notes)
|
||
}
|
||
}
|
||
}
|