package quiesce import ( "context" "testing" "time" ) // R-88 Part 3 — the invariant, pinned by name so a refactor has to delete an obviously-named // contract to reintroduce the bug. See the comment block on scheduledRunAllowed. // // A missing value means UNKNOWN. Only a POSITIVE determination of "never backed up" may fire the // safety valve. Prior instances of the opposite mistake: hub v0.12.0, v0.73.0, R-81, R-88. // // ── WHY ONLY HALF THE CONTRACT IS PINNED HERE ──────────────────────────────────────────────── // // Scenario C ("unknown must NOT bypass the window gate") is NOT testable in this package today, and // writing a test that pretends otherwise would be worse than leaving it out. The agent returns // byte-identical responses for "the storage read errored" and "there has never been a backup" — // same Due, same Reason, same nil AgeSecs — so the controller has nothing to discriminate on. Giving // "unknown" its own representation is an agent wire change, tracked as R-88 Part 2. // // What IS pinned here is Scenario D, and it is the half that a careless Part 2 would break. A fix // that makes nil stop firing the valve — the obvious way to "fix C" — turns a loud bug into a silent // one: a box powered on only outside its backup window would never back up at all, and nobody would // notice for weeks. This test fails against that implementation. // agedBackend wraps tierBackend so DueFor can report a REAL age. The shared fake always returns nil // ("never"), which is exactly the case under test here — so the control case needs its own shape // rather than a field added to a fake three other suites depend on. type agedBackend struct { *tierBackend ages map[string]*int64 } func (a *agedBackend) DueFor(ctx context.Context, target string) (bool, *int64, string, error) { due, _, _, err := a.tierBackend.DueFor(ctx, target) // A real age implies a KNOWN state — that is what the agent would send. return due, a.ages[target], string(AgeStateKnown), err } // SCENARIO D — a genuinely never-backed-up box still gets its first backup, outside the window. // // COMPANION RED-PROOF (observed): flip the nil branch of scheduledRunAllowed from `return true` to // `return false` — i.e. apply "unknown must not bypass the gate" too broadly, which is precisely the // over-correction Scenario C invites — and this fails with: // // "CONTRACT VIOLATED: a never-backed-up box outside its window did NOT back up (0 stack stop(s)) — // the safety valve was removed; a box only ever powered on outside its window would starve" // // (DELETING the branch outright instead panics on the nil deref two lines down — the test still // catches it, but `return false` is the mutation a real over-correction would produce.) // // Restored. func TestContract_NeverBackedUp_RunsOutsideTheWindow(t *testing.T) { // 12:00 Budapest, window 02:30 → gate [04:30, 08:30). Firmly outside. outside := atBudapest(12, 0) be := newTierBackend() be.tiers = []BackupTier{{Target: "local"}} be.dueSet["local"] = true be.phases["local"] = []string{phaseDone} // DueFor returns a nil age — "never backed up" (see tierBackend.DueFor). st := &fakeStacks{running: []string{"bookstack"}} l := windowLoop(t, be, st, "02:30", outside) if err := l.runOnce(context.Background()); err != nil { t.Fatalf("runOnce: %v", err) } if got := len(st.stoppedNames()); got == 0 { t.Fatalf("CONTRACT VIOLATED: a never-backed-up box outside its window did NOT back up (%d stack stop(s)) — "+ "the safety valve was removed; a box only ever powered on outside its window would starve", got) } } // The control for the test above: with a RECENT backup, the same cycle at the same hour defers. // Without this, TestContract_NeverBackedUp_RunsOutsideTheWindow would also pass against a gate that // was simply disabled. func TestContract_RecentBackup_DefersOutsideTheWindow(t *testing.T) { outside := atBudapest(12, 0) be := newTierBackend() be.tiers = []BackupTier{{Target: "local"}} be.dueSet["local"] = true be.phases["local"] = []string{phaseDone} aged := &agedBackend{tierBackend: be, ages: map[string]*int64{"local": i64(20 * 3600)}} // 20h old st := &fakeStacks{running: []string{"bookstack"}} l := windowLoop(t, aged, st, "02:30", outside) if err := l.runOnce(context.Background()); err != nil { t.Fatalf("runOnce: %v", err) } if got := len(st.stoppedNames()); got != 0 { t.Fatalf("a recently-backed-up box outside its window must DEFER, got %d stack stop(s) — "+ "if this passes the gate is not actually gating anything", got) } } // The valve boundary as a truth table on the real predicate, named so it cannot be quietly dropped. func TestContract_SafetyValveBoundary(t *testing.T) { const window = "02:30" outside := atBudapest(12, 0) h := func(hours int64) *int64 { return i64(hours * 3600) } cases := []struct { name string age *int64 want bool }{ {"never backed up (nil) → the valve FIRES", nil, true}, {"just inside cadence+24h → defer", h(47), false}, {"exactly at cadence+24h → defer (strictly greater)", h(48), false}, {"past cadence+24h → the valve fires", h(49), true}, } for _, c := range cases { if got := scheduledRunAllowed(outside, window, c.age, true, cadence24); got != c.want { t.Errorf("CONTRACT VIOLATED: %s → scheduledRunAllowed = %v, want %v", c.name, got, c.want) } } } // The breaker must not be able to starve a first-ever backup either: a never-backed-up tier that // fails still backs off (it must — that is the whole point), but the backoff EXPIRES and it retries. func TestContract_BreakerNeverStarvesAFirstBackup(t *testing.T) { now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) be := tierBackendFailing("local", "local") st := &fakeStacks{running: []string{"bookstack"}} l := breakerLoop(t, be, st, &now) attempts := 0 for i := 0; i < 4; i++ { before := len(st.stoppedNames()) if err := l.runOnce(context.Background()); err != nil { t.Fatal(err) } if len(st.stoppedNames()) > before { attempts++ } now = now.Add(backoffFor(i+1) + time.Minute) // step past each successive backoff } if attempts < 4 { t.Fatalf("CONTRACT VIOLATED: a never-backed-up tier attempted only %d time(s) across 4 expired backoffs; "+ "the breaker must bound the RETRY INTERVAL, never stop retrying", attempts) } }