package main import ( "context" "errors" "strings" "testing" "time" ) const takeLine = "sudo -n zfs snapshot -- tank/media@before-move" // snapshotting makes the fake's snapshot command add a snapshot to what the list then answers, with a // creation time of its own: the answer must carry the list's time, not the clock's. func snapshotting(f *fake, cmd string, added ...string) { f.on(cmd, "") f.after[cmd] = func(f *fake) { key := line("zfs", SnapshotsArgv()) r := f.answers[key] for _, a := range added { r.Out += a + "\n" } f.answers[key] = r } } func TestTheSnapshotCommandIsFixedWordsThroughSudoWithTheNameAfterDashDash(t *testing.T) { m := &Machine{UID: 1000} p, argv := m.Escalated("zfs", SnapshotArgv("tank/media", "before-move", false)...) if got := p + " " + strings.Join(argv, " "); got != takeLine { t.Errorf("ran %q", got) } p, argv = m.Escalated("zfs", SnapshotArgv("tank", "x", true)...) if got := p + " " + strings.Join(argv, " "); got != "sudo -n zfs snapshot -r -- tank@x" { t.Errorf("recursive: %q", got) } root := &Machine{UID: 0} if p, argv := root.Escalated("zfs", SnapshotArgv("tank", "x", false)...); p != "zfs" || argv[0] != "snapshot" { t.Errorf("as root, no sudo: %s %v", p, argv) } } func TestASnapshotIsAnsweredFromTheListAfterwardsAndRecorded(t *testing.T) { f := withZFS(t) snapshotting(f, takeLine, "tank/media@before-move\t1791400000\t0\t26938034880512") a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "before moving the library", false) if err != nil { t.Fatal(err) } if !a.Done || len(a.Made) != 1 || a.Made[0].Name != "tank/media@before-move" || a.Made[0].Referenced != 26938034880512 { t.Fatalf("answer: %+v", a) } if a.Made[0].Created != time.Unix(1791400000, 0).Local().Format(time.RFC3339) || a.Made[0].Created == f.now.Format(time.RFC3339) { t.Errorf("the creation time is the list's: %s", a.Made[0].Created) } if len(a.Others) != 0 || !strings.Contains(a.Said, "No other snapshot came or went") || !strings.Contains(a.Said, "Why: before moving the library") { t.Errorf("said: %s / %v", a.Said, a.Others) } if strings.Join(a.Ran, " ") != takeLine || len(ran(f, "sudo")) != 1 { t.Errorf("ran: %v; every call: %v", a.Ran, f.calls) } if len(f.emitted) != 1 || f.emitted[0]["_type"] != SnapshotEvent || f.emitted[0]["why"] != "before moving the library" || f.emitted[0]["done"] != true || f.emitted[0]["made"] != "tank/media@before-move" || !a.Recorded { t.Errorf("recorded: %+v", f.emitted) } } func TestASnapshotTheListDoesNotShowIsNotAnsweredAsMade(t *testing.T) { f := withZFS(t) f.on(takeLine, "") // zfs exits 0, and the list does not change a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why", false) if err == nil || a.Done || !strings.Contains(err.Error(), "the list shows 0 of the 1") { t.Fatalf("err %v, answer %+v", err, a) } if len(f.emitted) != 1 || f.emitted[0]["done"] != false { t.Errorf("the failed act is recorded too: %+v", f.emitted) } } func TestARefusedSudoIsSaidAndRecorded(t *testing.T) { f := withZFS(t) f.answers[takeLine] = Ran{Code: 1, Err: "sudo: a password is required"} _, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why", false) if err == nil || !strings.Contains(err.Error(), "NOT DONE") || !strings.Contains(err.Error(), "sudo refused the operator account") { t.Fatalf("err: %v", err) } if len(f.emitted) != 1 || f.emitted[0]["done"] != false { t.Errorf("recorded: %+v", f.emitted) } } func TestAnUnrecordedActSaysSoLoudly(t *testing.T) { f := withZFS(t) snapshotting(f, takeLine, "tank/media@before-move\t1791400000\t0\t1") f.emitErr = errors.New("the bus did not accept it") a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why", false) if err != nil { t.Fatal(err) } if a.Recorded || !strings.Contains(a.Said, "NOT RECORDED on the bus: the bus did not accept it") { t.Errorf("said: %s", a.Said) } } func TestAnotherSnapshotMeanwhileIsSaidNotHidden(t *testing.T) { f := withZFS(t) snapshotting(f, takeLine, "tank/media@before-move\t1791400000\t0\t1", "tank/downloads@auto-1200\t1791547201\t0\t1") a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why", false) if err != nil { t.Fatal(err) } if len(a.Others) != 1 || a.Others[0] != "appeared: tank/downloads@auto-1200" || !strings.Contains(a.Said, "not by this act") { t.Errorf("others: %v; said %s", a.Others, a.Said) } } func TestEveryRefusalRunsNoSnapshotCommand(t *testing.T) { cases := []struct{ dataset, name, why, says string }{ {"tank/nope", "x", "why", "no dataset"}, {"", "x", "why", "name the dataset"}, {"tank/media@x", "y", "why", "is not a dataset"}, {"tank/media", "", "why", "name the snapshot"}, {"tank/media", "a b", "why", "may hold only"}, {"tank/media", "x;rm -rf /", "why", "may hold only"}, {"tank/media", "$(reboot)", "why", "may hold only"}, {"tank/media", "a/b", "why", "may hold only"}, {"tank/media", "a@b", "why", "may hold only"}, {"tank/media", "-r", "why", ""}, // a name may start with '-': it follows -- and the dataset's @ {"tank/media", strings.Repeat("x", 250), "why", "longer than"}, {"tank/media", "before-rename", "why", "already exists"}, {"tank/media", "x", "", "say why"}, {"tank/media", "x", "one\ntwo", "one line"}, } for _, c := range cases { f := withZFS(t) _, err := f.machine().TakeSnapshot(context.Background(), c.dataset, c.name, c.why, false) if c.says == "" { if len(ran(f, "sudo -n zfs snapshot -- tank/media@-r")) != 1 { t.Errorf("%q: the name follows -- and runs as a name: %v", c.name, f.calls) } continue } if err == nil || !strings.Contains(err.Error(), c.says) { t.Errorf("%q@%q why %q: %v", c.dataset, c.name, c.why, err) } if n := len(ran(f, "sudo")); n != 0 { t.Errorf("%q@%q: a refusal ran %v", c.dataset, c.name, f.calls) } } } func TestRecursiveIsRefusedWhenAChildHasTheNameAlready(t *testing.T) { f := withZFS(t) _, err := f.machine().TakeSnapshot(context.Background(), "tank", "weekly", "why", true) if err == nil || !strings.Contains(err.Error(), "tank/downloads@weekly already exists") { t.Fatalf("err: %v", err) } // Without recursive, only tank's own name counts. f = withZFS(t) snapshotting(f, "sudo -n zfs snapshot -- tank@weekly", "tank@weekly\t1791400000\t0\t1") if a, err := f.machine().TakeSnapshot(context.Background(), "tank", "weekly", "why", false); err != nil || len(a.Made) != 1 { t.Errorf("tank@weekly alone: %v %+v", err, a) } } func TestRecursiveAnswersEveryChildsSnapshot(t *testing.T) { f := withZFS(t) snapshotting(f, "sudo -n zfs snapshot -r -- tank@all", "tank@all\t1\t0\t1", "tank/media@all\t1\t0\t1", "tank/downloads@all\t1\t0\t1", "tank/vm@all\t1\t0\t1") a, err := f.machine().TakeSnapshot(context.Background(), "tank", "all", "why", true) if err != nil || len(a.Made) != 4 || !a.Recursive { t.Fatalf("%v %+v", err, a) } } func TestAMachineWithoutZFSSaysTheModuleDoesNotApply(t *testing.T) { f := newFake(t) // every command: executable file not found m := f.machine() for _, tool := range Tools(m) { if tool.Name == "zfs_health" { continue } args := map[string]any{"dataset": "tank", "name": "x", "why": "y"} got, err := tool.Run(args) if err != nil { t.Errorf("%s: %v", tool.Name, err) continue } na, ok := got.(NotApplicable) if !ok || na.Applicable || !strings.Contains(na.Said, "does not apply here") { t.Errorf("%s: %+v", tool.Name, got) } } h := m.Health(context.Background()) if h.Healthy || !strings.Contains(h.Why, "not installed") || !strings.Contains(h.Why, "Unassign") { t.Errorf("health: %+v", h) } if n := len(ran(f, "sudo -n zfs snapshot")); n != 0 { t.Errorf("no snapshot is tried: %v", f.calls) } } func TestAMissingKernelModuleIsNotApplicableToo(t *testing.T) { r := Ran{Code: 1, Err: "The ZFS modules cannot be auto-loaded.\nTry running 'modprobe zfs' as root to manually load them."} if why := absent(r); !strings.Contains(why, "kernel module is not loaded") { t.Errorf("absent: %q", why) } }