package backup import ( "bytes" "context" "errors" "log" "os" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // R-200 — the diagnostic half. What is asserted here is the VERDICT and the NON-WRITE, because those // are the two things that make this a proof rather than a change to a live box. type fakeRecoverer struct { pw, sha string err error gotCode string callable bool } func (f *fakeRecoverer) RecoverOffsiteRepoPassword(_ context.Context, code string) (string, string, error) { f.callable = true f.gotCode = code return f.pw, f.sha, f.err } // Scenario A at this layer — the recovered key's hash is compared against the on-disk one and the // verdict is the equality, not "no error". func TestCheckOffsiteKeyRecoverable_MatchAndMismatch(t *testing.T) { m, _ := newOffboxManager(t) localHash, ok := m.OffboxRepoPasswordHash() if !ok { t.Fatal("precondition: no local repo password") } // The key came back identical. res, err := m.CheckOffsiteKeyRecoverable(context.Background(), &fakeRecoverer{pw: "irrelevant", sha: localHash}, "R") if err != nil { t.Fatal(err) } if !res.Match || !res.LocalPresent || res.RecoveredSHA256 != localHash || res.LocalSHA256 != localHash { t.Fatalf("identical keys must report Match: %+v", res) } // A DIFFERENT key must report a mismatch, not an error — "it worked and disagreed" is a finding // about the system and must be distinguishable from "a step failed". res, err = m.CheckOffsiteKeyRecoverable(context.Background(), &fakeRecoverer{pw: "x", sha: "0000000000000000000000000000000000000000000000000000000000000000"}, "R") if err != nil { t.Fatalf("a mismatch is a verdict, not an error: %v", err) } if res.Match { t.Fatal("a different recovered key must NOT report Match") } } // A box with no local password reports that distinctly — it is the rebuilt-box shape, where the next // step is to install rather than to compare, and reading it as a mismatch would be wrong. func TestCheckOffsiteKeyRecoverable_NoLocalPassword(t *testing.T) { m := newBareManager(t) res, err := m.CheckOffsiteKeyRecoverable(context.Background(), &fakeRecoverer{pw: "x", sha: "abc"}, "R") if err != nil { t.Fatal(err) } if res.LocalPresent || res.Match { t.Fatalf("no local key must report LocalPresent=false and Match=false: %+v", res) } if res.RecoveredSHA256 != "abc" { t.Fatalf("the recovery itself succeeded and must be reported: %+v", res) } } // §8.5 — THE CHECK MUST NOT INSTALL. This is the assertion that keeps a diagnostic a diagnostic. // RED-PROOF: add `m.InjectOffboxPassword(pw, true)` to CheckOffsiteKeyRecoverable → the on-disk // password changes → this FAILS. func TestCheckOffsiteKeyRecoverable_WritesNothing(t *testing.T) { m, _ := newOffboxManager(t) before, err := os.ReadFile(m.offboxPwPath()) if err != nil { t.Fatal(err) } dir := m.offboxDir() beforeEntries, _ := os.ReadDir(dir) recovered := "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" if _, err := m.CheckOffsiteKeyRecoverable(context.Background(), &fakeRecoverer{pw: recovered, sha: HashResticPassword(recovered)}, "R"); err != nil { t.Fatal(err) } after, err := os.ReadFile(m.offboxPwPath()) if err != nil { t.Fatal(err) } if !bytes.Equal(before, after) { t.Fatal("the check INSTALLED the recovered password — it must compare and never write (§8.5); " + "installing changes a live box on a path nobody has walked") } afterEntries, _ := os.ReadDir(dir) if len(afterEntries) != len(beforeEntries) { var names []string for _, e := range afterEntries { names = append(names, e.Name()) } t.Fatalf("the check created files in the offbox dir: %v", names) } // And nothing leaked into the data dir either. _ = filepath.Walk(m.cfg.Paths.DataDir, func(p string, info os.FileInfo, werr error) error { if werr != nil || info == nil || info.IsDir() { return nil } body, rerr := os.ReadFile(p) if rerr == nil && strings.Contains(string(body), recovered) { t.Errorf("the recovered password was written to %s", p) } return nil }) } // R goes to the agent verbatim and is not mangled or retained by this layer. func TestCheckOffsiteKeyRecoverable_PassesRThrough(t *testing.T) { m, _ := newOffboxManager(t) const code = "correct horse battery staple sedative anaconda wobbly kingdom placard yodel" f := &fakeRecoverer{pw: "x", sha: "abc"} if _, err := m.CheckOffsiteKeyRecoverable(context.Background(), f, code); err != nil { t.Fatal(err) } if f.gotCode != code { t.Fatalf("the recovery code reached the agent as %q — a 10-word code must not be re-split or trimmed internally", f.gotCode) } } // An agent-side failure surfaces as an error, and the verdict is NOT reported as a mismatch. func TestCheckOffsiteKeyRecoverable_AgentFailure(t *testing.T) { m, _ := newOffboxManager(t) _, err := m.CheckOffsiteKeyRecoverable(context.Background(), &fakeRecoverer{err: errors.New("the recovery code did not open the sealed bundle")}, "R") if err == nil { t.Fatal("an agent failure must be an error, never a silent mismatch") } } // The CLI's exit codes are load-bearing: 0 match, 2 clean mismatch, 1 a step failed. "It failed" and // "it worked and disagreed" must never share a status, because only one of them is a finding. func TestRunRecoveryCheck_ExitCodes(t *testing.T) { m, _ := newOffboxManager(t) localHash, _ := m.OffboxRepoPasswordHash() cases := []struct { name string rec OffsiteKeyRecoverer in string want int }{ {"match", &fakeRecoverer{pw: "x", sha: localHash}, "some recovery code\n", 0}, {"mismatch", &fakeRecoverer{pw: "x", sha: "0000000000000000000000000000000000000000000000000000000000000000"}, "some recovery code\n", 2}, {"agent failure", &fakeRecoverer{err: errors.New("wrong code")}, "some recovery code\n", 1}, {"no code on stdin", &fakeRecoverer{pw: "x", sha: localHash}, "", 1}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { var out, errb bytes.Buffer got := RunRecoveryCheck(RecoveryCheckDeps{ Manager: m, Recoverer: tc.rec, In: strings.NewReader(tc.in), Out: &out, Err: &errb, }) if got != tc.want { t.Fatalf("exit = %d, want %d (out=%q err=%q)", got, tc.want, out.String(), errb.String()) } // No printed stream may ever carry a password or a recovery code. combined := out.String() + errb.String() for _, secret := range []string{"PRIVATE-KEY-MATERIAL", "some recovery code"} { if strings.Contains(combined, secret) { t.Errorf("the diagnostic printed a secret (%s): %s", secret, combined) } } }) } } // newBareManager is an offbox manager with a data dir and NO repo password — the freshly rebuilt-box // shape, which the no-local-password case needs and newOffboxManager deliberately does not produce. func newBareManager(t *testing.T) *Manager { t.Helper() logger := log.New(os.Stderr, "", 0) dataDir := t.TempDir() sett, err := settings.Load(filepath.Join(dataDir, "settings.json"), logger) if err != nil { t.Fatal(err) } cfg := &config.Config{} cfg.Paths.DataDir = dataDir cfg.Paths.SystemDataPath = filepath.Join(dataDir, "sys") return NewManager(cfg, sett, logger) } // R-200 Part 0 — the INSTALL sibling. What is asserted is the three outcomes, the confirmation gate, // and that R does not survive either path. // An install on a box with NO local password writes it — the rebuilt-box shape, which is the only // situation this command exists for. // RED-PROOF: drop the `confirm` check so an unconfirmed run installs → the dry-run case below FAILS. func TestRecoverAndInstall_InstallsOnABareBox(t *testing.T) { m := newBareManager(t) pw := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" rec := &fakeRecoverer{pw: pw, sha: HashResticPassword(pw)} // 1) DRY RUN — prints the hashes, writes nothing. var out, errb bytes.Buffer if got := RecoverAndInstall(RecoveryCheckDeps{Manager: m, Recoverer: rec, In: strings.NewReader("code\n"), Out: &out, Err: &errb}, false); got != 0 { t.Fatalf("dry run exit = %d, want 0 (%s / %s)", got, out.String(), errb.String()) } if _, present := m.OffboxRepoPasswordHash(); present { t.Fatal("the DRY RUN wrote the password — the confirmation gate does not hold, which is the " + "whole reason the operator gets to see the hashes before anything exists to undo") } if !strings.Contains(out.String(), "DRY RUN") { t.Errorf("the dry run must say so, got %q", out.String()) } // 2) CONFIRMED — writes it, and it reads back identical. out.Reset() errb.Reset() if got := RecoverAndInstall(RecoveryCheckDeps{Manager: m, Recoverer: rec, In: strings.NewReader("code\n"), Out: &out, Err: &errb}, true); got != 0 { t.Fatalf("confirmed exit = %d, want 0 (%s / %s)", got, out.String(), errb.String()) } got, present := m.OffboxRepoPasswordHash() if !present || got != HashResticPassword(pw) { t.Fatalf("the recovered password was not placed (present=%v hash=%q)", present, got) } if !strings.Contains(out.String(), "INSTALLED") { t.Errorf("a successful install must say so, got %q", out.String()) } // The VALUE must not have been printed on either stream. if strings.Contains(out.String()+errb.String(), pw) { t.Fatal("the repository password was printed") } } // An identical key already present is "unchanged", not "installed" and not an error — and nothing is // written, so a re-run is harmless. func TestRecoverAndInstall_UnchangedWhenIdentical(t *testing.T) { m, _ := newOffboxManager(t) localHash, _ := m.OffboxRepoPasswordHash() before, err := os.ReadFile(m.offboxPwPath()) if err != nil { t.Fatal(err) } var out, errb bytes.Buffer got := RecoverAndInstall(RecoveryCheckDeps{ Manager: m, Recoverer: &fakeRecoverer{pw: "x", sha: localHash}, In: strings.NewReader("code\n"), Out: &out, Err: &errb, }, true) if got != 0 { t.Fatalf("exit = %d, want 0", got) } if !strings.Contains(out.String(), "UNCHANGED") { t.Errorf("an identical key must report UNCHANGED, got %q", out.String()) } after, _ := os.ReadFile(m.offboxPwPath()) if !bytes.Equal(before, after) { t.Fatal("an UNCHANGED outcome rewrote the file") } } // A DIFFERENT key already present is REFUSED — installing would clobber the key the box's current // repository is encrypted under, and which history to keep is not this command's decision. func TestRecoverAndInstall_RefusesToClobberADifferentKey(t *testing.T) { m, _ := newOffboxManager(t) before, err := os.ReadFile(m.offboxPwPath()) if err != nil { t.Fatal(err) } var out, errb bytes.Buffer got := RecoverAndInstall(RecoveryCheckDeps{ Manager: m, Recoverer: &fakeRecoverer{pw: "y", sha: "0000000000000000000000000000000000000000000000000000000000000000"}, In: strings.NewReader("code\n"), Out: &out, Err: &errb, }, true) if got != 2 { t.Fatalf("exit = %d, want 2 (a refusal is its own outcome, not a generic failure)", got) } if !strings.Contains(errb.String(), "REFUSED") { t.Errorf("the refusal must say so, got %q", errb.String()) } after, _ := os.ReadFile(m.offboxPwPath()) if !bytes.Equal(before, after) { t.Fatal("a REFUSED install clobbered the existing key — the exact outcome the refusal exists to prevent") } } // R must not survive either path, and the recovery code must never be printed. func TestRecoverAndInstall_RLeavesNoTrace(t *testing.T) { const code = "correct horse battery staple sedative anaconda wobbly kingdom placard yodel" for _, tc := range []struct { name string confirm bool }{{"dry run", false}, {"confirmed", true}} { t.Run(tc.name, func(t *testing.T) { m := newBareManager(t) pw := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" var out, errb bytes.Buffer RecoverAndInstall(RecoveryCheckDeps{ Manager: m, Recoverer: &fakeRecoverer{pw: pw, sha: HashResticPassword(pw)}, In: strings.NewReader(code + "\n"), Out: &out, Err: &errb, }, tc.confirm) combined := out.String() + errb.String() if strings.Contains(combined, code) { t.Errorf("the recovery code was printed: %s", combined) } if strings.Contains(combined, pw) { t.Errorf("the repository password was printed: %s", combined) } // POSITIVE CONTROL for the sweep below: plant R in the data dir, prove the walk finds it, // remove it. An absence check is worth only what its sensitivity is. ctrl := filepath.Join(m.cfg.Paths.DataDir, ".planted-control") if err := os.WriteFile(ctrl, []byte(code), 0o600); err != nil { t.Fatal(err) } if n := countFilesContaining(t, m.cfg.Paths.DataDir, code); n != 1 { t.Fatalf("positive control: the sweep found %d planted copies, want 1 — the sweep is not sensitive", n) } if err := os.Remove(ctrl); err != nil { t.Fatal(err) } if n := countFilesContaining(t, m.cfg.Paths.DataDir, code); n != 0 { t.Fatalf("the recovery code survived in %d file(s) under the data dir", n) } }) } } func countFilesContaining(t *testing.T, root, needle string) int { t.Helper() n := 0 _ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error { if err != nil || info == nil || info.IsDir() { return nil } body, rerr := os.ReadFile(p) if rerr == nil && strings.Contains(string(body), needle) { n++ } return nil }) return n }