textmachine/platform/internal/runner/backup_test.go

113 lines
4.9 KiB
Go

package runner
import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
)
// The verb's argv is `backup --config <workdir>/book.yaml` and carries NO `--keys-file`. Pinned at
// the argv because both halves are one-token regressions: a dropped `--config` backs up whatever
// directory the process happens to sit in, and an added `--keys-file` makes a $0 command refuse on
// every engine build (D20.4 — the read verbs must not demand keys).
func TestTheBackupVerbAsksForTheBooksConfigAndNoKeys(t *testing.T) {
args := BackupArgs("/srv/books/bk_1")
if args[0] != "backup" {
t.Errorf("argv does not start with the verb: %q", args)
}
if i := slices.Index(args, "--config"); i < 0 || args[i+1] != filepath.Join("/srv/books/bk_1", ConfigFile) {
t.Errorf("the book's own configuration is not what is backed up: %q", args)
}
if slices.Contains(args, "--keys-file") {
t.Errorf("a $0 verb is being handed provider keys: %q", args)
}
}
// The path is READ off the engine's line and never derived, so the parse is what stands between this
// platform and copying the wrong file. Every case below is a way the parse could quietly succeed on
// something it should refuse.
//
// Mutation caught: matching only the prefix (a line that begins the same way is read as a path);
// dropping the containment guard (a line naming any path on the host becomes a file-read primitive);
// answering an empty path (the copier then opens the book's own directory).
func TestTheBackupPathIsReadStrictlyAndStaysInsideTheBook(t *testing.T) {
const workdir = "/srv/books/bk_1"
good := "backup OK: /srv/books/bk_1/backups/20260905T120000Z.db (integrity_check green, VACUUM INTO)\n"
got, err := backupPathIn(good, workdir)
if err != nil || got != "/srv/books/bk_1/backups/20260905T120000Z.db" {
t.Fatalf("the engine's own success line did not parse: %q, %v", got, err)
}
// A relative path is resolved against the book, because that is the directory the verb ran in.
if got, err := backupPathIn("backup OK: backups/x.db (integrity_check green, VACUUM INTO)\n", workdir); err != nil ||
got != "/srv/books/bk_1/backups/x.db" {
t.Errorf("a relative path was not resolved against the book: %q, %v", got, err)
}
// Noise before the line is fine: the engine prints other things.
if _, err := backupPathIn("pre-flight: something\n"+good, workdir); err != nil {
t.Errorf("a success line preceded by other output did not parse: %v", err)
}
for name, out := range map[string]string{
"prefix only": "backup OK: /srv/books/bk_1/x.db\n",
"suffix only": "/srv/books/bk_1/x.db (integrity_check green, VACUUM INTO)\n",
"empty path": "backup OK: (integrity_check green, VACUUM INTO)\n",
"nothing at all": "",
"another verb": "manifest OK: /srv/books/bk_1/x.json\n",
"outside escape": "backup OK: /srv/books/bk_1/../bk_2/x.db (integrity_check green, VACUUM INTO)\n",
"outside outright": "backup OK: /etc/shadow (integrity_check green, VACUUM INTO)\n",
} {
if got, err := backupPathIn(out, workdir); err == nil {
t.Errorf("%s: parsed as %q instead of being refused", name, got)
}
}
}
// A zero exit whose output cannot be read is an ERROR and not a silent success. Without this the
// caller would report a restore point it cannot point at, which is the exact shape of "we thought we
// had backups".
func TestABackupThatSucceedsWithoutNamingItsFileIsAnError(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("sh not on PATH: the fake engine cannot be built")
}
dir := t.TempDir()
fake := filepath.Join(dir, "quiet-engine")
if err := os.WriteFile(fake, []byte("#!"+sh+"\necho 'all done'\nexit 0\n"), 0o700); err != nil {
t.Fatal(err)
}
out, err := New(nil).Backup(t.Context(), fake, dir)
if err == nil {
t.Fatalf("a silent success was accepted: %+v", out)
}
if !strings.Contains(err.Error(), "without naming the restore point") {
t.Errorf("the error does not say what is wrong: %v", err)
}
}
// A non-zero exit is DATA and not an error: a book that has never been cut has no project database,
// and the engine says so rather than failing. Reading that as a broken deployment would stop the
// whole backup pass over an ordinary state.
func TestABookWithNoDatabaseComesBackAsAnExitCodeAndNotAnError(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("sh not on PATH: the fake engine cannot be built")
}
dir := t.TempDir()
fake := filepath.Join(dir, "refusing-engine")
script := "#!" + sh + "\necho 'tmctl backup: project database does not exist yet' >&2\nexit 1\n"
if err := os.WriteFile(fake, []byte(script), 0o700); err != nil {
t.Fatal(err)
}
out, err := New(nil).Backup(t.Context(), fake, dir)
if err != nil {
t.Fatalf("an ordinary refusal was raised as an error: %v", err)
}
if !out.Exited || out.ExitCode != 1 || out.Path != "" {
t.Errorf("the refusal did not come back as an exit code: %+v", out)
}
if !strings.Contains(out.Stderr, "does not exist yet") {
t.Errorf("the engine's own words were lost: %q", out.Stderr)
}
}