package backup import ( "context" "errors" "path/filepath" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // R-729 / R-545 — the household can remove its own off-site target; the repository is never touched, // and the repository password is kept whenever anything could depend on it. See offbox_clear.go. func clearFixture(t *testing.T, hubHoldsPackage bool, tgt *settings.OffboxTarget) (*Manager, *settings.Settings, string) { t.Helper() m, sett, pwPath := mintGuardManager(t, false) // mint first, THEN set the package fact if err := sett.SetOffboxTarget(tgt); err != nil { t.Fatal(err) } if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil { t.Fatal(err) } if err := sett.SetHubEscrowIdentityPresent(hubHoldsPackage); err != nil { t.Fatal(err) } // Any remote command would go through the runner — none may run. m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) { t.Errorf("R-729: clearing a target ran restic %v — the repository must never be touched", args) return nil, errors.New("forbidden") }) m.SetOffboxSSH(func(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error) { t.Errorf("R-729: clearing a target ran a remote command %q — the repository must never be touched", remoteCmd) return nil, errors.New("forbidden") }) return m, sett, filepath.Dir(pwPath) } func ownNAS() *settings.OffboxTarget { return &settings.OffboxTarget{Enabled: false, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", EscrowState: "pending"} } // The headline: a never-used own-NAS target is forgotten and every secret it minted is gone. func TestR729_Clear_ForgetsTargetAndDeletesSecrets(t *testing.T) { m, sett, dir := clearFixture(t, false, ownNAS()) res, err := m.ClearOffboxTarget() if err != nil { t.Fatalf("clear: %v", err) } if sett.GetOffboxTarget() != nil { t.Fatal("R-729: the target is still in settings after the clear") } for _, f := range []string{"ssh_key", "known_hosts", "repo_password"} { if exists(filepath.Join(dir, f)) { t.Errorf("R-545: %s is still on disk after clearing a target nothing depends on", f) } } if res.KeptRepoPassword { t.Error("KeptRepoPassword=true for a target with no escrow, no history and no hub package") } // Idempotent refusal: nothing left to clear. if _, err := m.ClearOffboxTarget(); !errors.Is(err, ErrOffboxClearNoTarget) { t.Errorf("second clear: want ErrOffboxClearNoTarget, got %v", err) } } // The password is the one secret that may protect history — kept whenever anything depends on it. func TestR729_Clear_KeepsRepoPasswordWhenSomethingDependsOnIt(t *testing.T) { cases := []struct { name string pkg bool mutate func(*settings.OffboxTarget) }{ {"hub holds a sealed package (R-241)", true, func(*settings.OffboxTarget) {}}, {"escrow confirmed", false, func(o *settings.OffboxTarget) { o.EscrowState = "escrowed" }}, {"a run succeeded once", false, func(o *settings.OffboxTarget) { o.LastSuccess = "2026-10-01T02:00:00Z" }}, {"snapshots exist", false, func(o *settings.OffboxTarget) { o.SnapshotCount = 3 }}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { tgt := ownNAS() c.mutate(tgt) m, sett, dir := clearFixture(t, c.pkg, tgt) res, err := m.ClearOffboxTarget() if err != nil { t.Fatalf("clear: %v", err) } if !exists(filepath.Join(dir, "repo_password")) { t.Fatal("R-241: the repository password was deleted although something depends on it") } if !res.KeptRepoPassword { t.Error("KeptRepoPassword=false although the key was kept") } if exists(filepath.Join(dir, "ssh_key")) || exists(filepath.Join(dir, "known_hosts")) { t.Error("the transport secrets must still be deleted") } if sett.GetOffboxTarget() != nil { t.Error("the target must still be forgotten") } }) } } // Refusals leave every file and the setting exactly as they were. func TestR729_Clear_Refusals(t *testing.T) { check := func(t *testing.T, m *Manager, sett *settings.Settings, dir string, want error) { t.Helper() if _, err := m.ClearOffboxTarget(); !errors.Is(err, want) { t.Fatalf("want %v, got %v", want, err) } if sett.GetOffboxTarget() == nil { t.Error("a refused clear removed the target") } for _, f := range []string{"ssh_key", "known_hosts", "repo_password"} { if !exists(filepath.Join(dir, f)) { t.Errorf("a refused clear deleted %s", f) } } } t.Run("hub tier", func(t *testing.T) { tgt := ownNAS() tgt.Transport = settings.TransportRclonePinned m, sett, dir := clearFixture(t, false, tgt) check(t, m, sett, dir, ErrOffboxClearHubTier) }) t.Run("run in flight", func(t *testing.T) { m, sett, dir := clearFixture(t, false, ownNAS()) if err := m.acquireRunning(); err != nil { t.Fatal(err) } defer m.releaseRunning() check(t, m, sett, dir, ErrOffboxClearBusy) }) t.Run("abandonment countdown", func(t *testing.T) { tgt := ownNAS() tgt.AbandonStartedAt = time.Now().UTC().Format(time.RFC3339) tgt.AbandonAt = time.Now().Add(7 * 24 * time.Hour).UTC().Format(time.RFC3339) tgt.AbandonRepoPath = "/srv/repo.orphaned-1" m, sett, dir := clearFixture(t, false, tgt) check(t, m, sett, dir, ErrOffboxClearAbandonOn) }) t.Run("hub-held deletion", func(t *testing.T) { tgt := ownNAS() tgt.AbandonHubDueAt = time.Now().Add(3 * 24 * time.Hour).UTC().Format(time.RFC3339) m, sett, dir := clearFixture(t, false, tgt) check(t, m, sett, dir, ErrOffboxClearAbandonOn) }) } // The single-flight is released after a clear — a later run is not wedged. func TestR729_Clear_ReleasesSingleFlight(t *testing.T) { m, _, _ := clearFixture(t, false, ownNAS()) if _, err := m.ClearOffboxTarget(); err != nil { t.Fatal(err) } if m.IsRunning() { t.Fatal("the clear left the single-flight held") } }