package backup import ( "context" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // R-241 — abandoning starts a countdown that ENDS THE QUESTION (Scenarios E, F, G). // // The countdown is driven by an injected clock throughout. §7.4 forbids shortening a live timer to // watch the terminal step fire: it is the only thing in the product that deletes a customer's // off-site history, and a step tested once on real data is a step regretted once. // abandonFixture: an orphaned, configured box holding a key, with the hub holding a package for a // DIFFERENT key — i.e. shape (c) is live and the customer is being offered recovery. // Returns the manager and a recorder of every remote shell command issued. type sshRecorder struct{ cmds []string } func (r *sshRecorder) run(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error) { r.cmds = append(r.cmds, remoteCmd) return []byte(""), nil } func abandonFixture(t *testing.T, now time.Time) (*Manager, *settings.Settings, *sshRecorder) { t.Helper() m, sett, _ := offerFixture(t, true) if err := sett.SetHubEscrowKeySHA256(otherKeyHash, now.Format(time.RFC3339)); err != nil { t.Fatal(err) } if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.EscrowState = "escrowed" o.RepoState = "orphaned" }); err != nil { t.Fatal(err) } rec := &sshRecorder{} m.SetOffboxSSH(rec.run) m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) { return []byte(""), nil }) m.SetOffboxClock(func() time.Time { return now }) return m, sett, rec } // ── SCENARIO E — abandoning sets aside, keeps the package, starts a countdown, stays reversible ── func TestR241_ScenarioE_AbandonStartsAReversibleCountdown(t *testing.T) { start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) m, _, rec := abandonFixture(t, start) if err := m.ResetOrphanedRepo(context.Background()); err != nil { t.Fatalf("abandon: %v", err) } // The store was MOVED, not deleted — no rm anywhere in this phase. joined := strings.Join(rec.cmds, " | ") if !strings.Contains(joined, "mv ") { t.Errorf("the old store must be moved aside; commands were: %s", joined) } if strings.Contains(joined, "rm -rf") { t.Fatalf("NOTHING may be deleted when the customer abandons — only at the end of the grace. Commands: %s", joined) } st := m.AbandonStatus() if !st.Active { t.Fatal("a countdown must be running after an abandonment") } if got := st.DueAt.Sub(start); got != abandonGraceDays*24*time.Hour { t.Errorf("countdown length = %v, want %d days", got, abandonGraceDays) } if st.DaysLeft != abandonGraceDays { t.Errorf("DaysLeft = %d, want %d", st.DaysLeft, abandonGraceDays) } if st.RepoPath == "" { t.Error("the set-aside path must be recorded, or the terminal step has nothing to delete") } // THE GRACE IS REAL: the recovery offer stays reachable for the whole window. A grace in which // recovery is impossible would be decorative. if !m.OffsiteRecoveryOffer() { t.Fatal("the recovery offer MUST stay reachable during the grace — that is the change-of-mind path") } } // ── SCENARIO G — changing your mind inside the window ─────────────────────────────────────────── // // RED-PROOF: make the countdown uncancellable (delete the body of CancelAbandon). The countdown then // survives a successful recovery and this test fails — a customer who proved they hold their code // would still have the history deleted under them. func TestR241_ScenarioG_RecoveryInsideTheWindowCancelsTheCountdown(t *testing.T) { start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) m, _, _ := abandonFixture(t, start) if err := m.ResetOrphanedRepo(context.Background()); err != nil { t.Fatal(err) } day6 := start.AddDate(0, 0, 6) m.SetOffboxClock(func() time.Time { return day6 }) if st := m.AbandonStatus(); !st.Active || st.DaysLeft != 8 { t.Fatalf("precondition: day 6 of 14 should leave 8 days, got %+v", st) } pathBefore := m.AbandonStatus().RepoPath m.CancelAbandon("the customer recovered with their code") st := m.AbandonStatus() if st.Active { t.Fatal("a countdown must be cancellable — the customer found their code") } if st.RepoPath != pathBefore { t.Errorf("the set-aside store must stay NAMEABLE after a cancel: got %q want %q", st.RepoPath, pathBefore) } // And a sweep now deletes nothing, on any later date. m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 90) }) deleted, err := m.AbandonSweep(context.Background()) if err != nil || deleted { t.Fatalf("a cancelled countdown must never delete: deleted=%v err=%v", deleted, err) } } // ── SCENARIO F — the countdown ends the question, and removes BOTH halves ──────────────────────── // // RED-PROOF (store half): make AbandonSweep skip the rm. The first assertion fails. // RED-PROOF (package half): drop AbandonPurgeRequested from OffboxReportStatus. The declaration // assertion fails — the hub is never asked and the package outlives the store for ever. func TestR241_ScenarioF_TerminalStepRemovesBothHalvesTogether(t *testing.T) { start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) m, sett, rec := abandonFixture(t, start) if err := m.ResetOrphanedRepo(context.Background()); err != nil { t.Fatal(err) } setAside := m.AbandonStatus().RepoPath // Not due yet — nothing happens, quietly. m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 13) }) if deleted, err := m.AbandonSweep(context.Background()); deleted || err != nil { t.Fatalf("day 13 must not delete: deleted=%v err=%v", deleted, err) } // Due. m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 14).Add(time.Minute) }) rec.cmds = nil deleted, err := m.AbandonSweep(context.Background()) if err != nil { t.Fatalf("terminal step: %v", err) } if !deleted { t.Fatal("the terminal step must delete when due") } // HALF 1: the store is gone. joined := strings.Join(rec.cmds, " | ") if !strings.Contains(joined, "rm -rf") || !strings.Contains(joined, setAside) { t.Fatalf("the set-aside store at %s must be deleted; commands: %s", setAside, joined) } // HALF 2: the hub is ASKED for the package, and keeps being asked until it confirms. st := m.OffboxReportStatus() if st == nil || !st.AbandonPurgeRequested { t.Fatalf("the report must declare abandon_purge_requested until the hub drops the package, got %+v", st) } // It repeats — a lost request must retry rather than leave the pair half-removed. if d2, err2 := m.AbandonSweep(context.Background()); d2 || err2 != nil { t.Fatalf("a second sweep must be a quiet no-op while awaiting the hub: deleted=%v err=%v", d2, err2) } if st2 := m.OffboxReportStatus(); st2 == nil || !st2.AbandonPurgeRequested { t.Fatal("the declaration must persist across sweeps until confirmed") } // The hub confirms by no longer reporting a superseded package → the question is over. m.ClearAbandonPurgeIfConfirmed(false) if got := sett.GetOffboxTarget(); got.AbandonPurgeRequested || got.AbandonRepoPath != "" || got.AbandonAt != "" { t.Errorf("the abandonment must be fully closed out, got %+v", got) } if st3 := m.OffboxReportStatus(); st3 != nil && st3.AbandonPurgeRequested { t.Error("the declaration must stop once the hub has confirmed") } } // While the hub STILL reports a superseded package, the close-out must not fire — otherwise the box // stops asking and the package outlives the store silently, which is exactly half of Scenario F. func TestR241_PurgeIsNotClosedOutWhileThePackageRemains(t *testing.T) { start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) m, sett, _ := abandonFixture(t, start) if err := m.ResetOrphanedRepo(context.Background()); err != nil { t.Fatal(err) } m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) }) if _, err := m.AbandonSweep(context.Background()); err != nil { t.Fatal(err) } m.ClearAbandonPurgeIfConfirmed(true) // the hub STILL holds a retained package if !sett.GetOffboxTarget().AbandonPurgeRequested { t.Fatal("the request must stand while the hub still reports a superseded package") } } // A transport failure during the terminal step must NOT clear the countdown — it retries tomorrow. // Silently abandoning the abandonment would leave the store for ever with nothing counting down. func TestR241_TerminalStepFailureKeepsTheCountdownDue(t *testing.T) { start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) m, sett, _ := abandonFixture(t, start) if err := m.ResetOrphanedRepo(context.Background()); err != nil { t.Fatal(err) } m.SetOffboxSSH(func(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error) { return []byte("ssh: connect to host nas.local port 22: No route to host"), context.DeadlineExceeded }) m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) }) deleted, err := m.AbandonSweep(context.Background()) if deleted || err == nil { t.Fatalf("a failed deletion must be reported, not swallowed: deleted=%v err=%v", deleted, err) } got := sett.GetOffboxTarget() if got.AbandonAt == "" || got.AbandonPurgeRequested { t.Fatalf("a failed terminal step must leave the countdown DUE and unrequested, got %+v", got) } if !m.AbandonStatus().Active { t.Error("the countdown must still be active so tomorrow's sweep retries") } } // Quiet by construction: a box with no countdown does no work and says nothing (§ the daily job's // own contract). Asserted, because "it probably does nothing" is how a sweep with a bug hides. func TestR241_Sweep_QuietWhenNothingDue(t *testing.T) { m, _, rec := abandonFixture(t, time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)) deleted, err := m.AbandonSweep(context.Background()) if deleted || err != nil { t.Fatalf("a box with no countdown must be a pure no-op: deleted=%v err=%v", deleted, err) } if len(rec.cmds) != 0 { t.Fatalf("a no-op sweep must issue no remote commands, got %v", rec.cmds) } if m.AbandonStatus().Active { t.Error("no countdown should be reported") } } // The UNCLAIMED auto-reset must NOT start a customer countdown — nobody decided anything there. // An as-delivered box tidying a stranger's leftover store must not put a 14-day deletion clock on it. func TestR241_UnclaimedAutoResetStartsNoCountdown(t *testing.T) { start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) m, _, _ := abandonFixture(t, start) t2 := m.settings.GetOffboxTarget() base, env := m.offboxBaseArgs(t2) if err := m.resetOrphanedRepo(context.Background(), base, env, "auto (unclaimed)"); err != nil { t.Fatal(err) } if m.AbandonStatus().Active { t.Fatal("the unclaimed auto-reset must not start a customer abandonment countdown") } } // ── §7.5 — THE OPERATOR LEVERS ────────────────────────────────────────────────────────────────── // // The automatic 30-day ending is deliberately NOT built (R-245). These are what IS built: the path // that actually happens is the customer telephoning, and support needs something to press. func TestR241_OperatorCanExtendARunningCountdown(t *testing.T) { start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) m, _, rec := abandonFixture(t, start) if err := m.ResetOrphanedRepo(context.Background()); err != nil { t.Fatal(err) } day10 := start.AddDate(0, 0, 10) m.SetOffboxClock(func() time.Time { return day10 }) due, err := m.ExtendAbandon(30) if err != nil { t.Fatalf("extend: %v", err) } if want := day10.AddDate(0, 0, 30); !due.Equal(want) { t.Errorf("new due = %v, want %v (from NOW, not from the old date)", due, want) } // The original date has passed and nothing is deleted, because the extension moved it. m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) }) rec.cmds = nil if deleted, serr := m.AbandonSweep(context.Background()); deleted || serr != nil { t.Fatalf("an extended countdown must not fire on the old date: deleted=%v err=%v", deleted, serr) } if len(rec.cmds) != 0 { t.Fatalf("nothing may be deleted after an extension, got %v", rec.cmds) } } func TestR241_OperatorCanStopARunningCountdown(t *testing.T) { start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) m, _, rec := abandonFixture(t, start) if err := m.ResetOrphanedRepo(context.Background()); err != nil { t.Fatal(err) } if err := m.StopAbandon(); err != nil { t.Fatalf("stop: %v", err) } if m.AbandonStatus().Active { t.Fatal("the countdown must be stopped") } m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 90) }) rec.cmds = nil if deleted, err := m.AbandonSweep(context.Background()); deleted || err != nil { t.Fatalf("a stopped countdown must never delete: deleted=%v err=%v", deleted, err) } if len(rec.cmds) != 0 { t.Fatalf("a stopped countdown must issue no remote commands, got %v", rec.cmds) } } // Both levers REFUSE when nothing is running. A silent no-op is the thing an operator most easily // mistakes for success — they would tell the customer it was handled. func TestR241_OperatorLeversRefuseWhenNothingIsRunning(t *testing.T) { m, _, _ := abandonFixture(t, time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)) if _, err := m.ExtendAbandon(30); err == nil { t.Error("extending a countdown that is not running must be an error, never a quiet success") } if err := m.StopAbandon(); err == nil { t.Error("stopping a countdown that is not running must be an error, never a quiet success") } if _, err := m.ExtendAbandon(0); err == nil { t.Error("a non-positive extension must be refused") } } // Once the store is deleted there is nothing left to extend or stop, and saying otherwise would be // the worst kind of reassurance: an operator telling a customer their data is safe when it is gone. func TestR241_OperatorLeversRefuseAfterTheDeletion(t *testing.T) { start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) m, _, _ := abandonFixture(t, start) if err := m.ResetOrphanedRepo(context.Background()); err != nil { t.Fatal(err) } m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) }) if _, err := m.AbandonSweep(context.Background()); err != nil { t.Fatal(err) } if _, err := m.ExtendAbandon(30); err == nil { t.Error("extending after the deletion must be refused — there is nothing left to save") } if err := m.StopAbandon(); err == nil { t.Error("stopping after the deletion must be refused — there is nothing left to save") } }