Files
felhom-controller/controller/internal/backup/offbox_abandon_r241_test.go
T
admin 72368654e4 R-241 part 5: escalating reminders, and operator levers for a running countdown
REMINDERS (SEC 2.3). The offer epoch now stamps when it began, and the
undecided reminder escalates in EMPHASIS at 1, 3, 7 and 14 days.

THE READING IS STATED BECAUSE THE SPEC IS AMBIGUOUS, and it is written into
the code where it can be corrected. For an ABANDONING box, 5/3/1 are
unambiguously days REMAINING before a deletion. An undecided box has no
deadline - nothing counts down to anything, because SEC 7.5 deliberately does
NOT auto-abandon - so 14/7/3/1 cannot be "remaining" and are taken as days
ELAPSED, with the wording firming up rather than the bar appearing and
disappearing. If the operator meant something else, one function changes.

The stamp is re-set on every entry into the offered state, so a box that
settles and is later rebuilt starts its ladder again instead of inheriting an
old one.

OPERATOR LEVERS (SEC 7.5). --abandon-status, --abandon-extend=N and
--abandon-stop on the controller CLI, beside the existing operator
subcommands. They exist because the path that ACTUALLY happens is the customer
telephoning, and support needs something to press.

They live on the CLI and not in the customer UI deliberately: extending a
deletion the customer asked for is an operator judgement, and a customer who
wants it stopped already has the self-service route - they recover with their
code, which cancels it.

BOTH REFUSE RATHER THAN NO-OP, in two situations: when no countdown is
running, and when the store has already been deleted. A silent success is the
thing an operator most easily mistakes for "handled" - they would tell the
customer their data was safe when it is gone. Pinned by two tests.

--abandon-extend counts from NOW, not from the old due date, and a test proves
the old date passes without deleting anything.

Green: go build, go vet, go test ./... all pass; controller gates OK.
2026-08-07 12:08:11 +02:00

336 lines
14 KiB
Go

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")
}
}