113 lines
4.9 KiB
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)
|
|
}
|
|
}
|