package backup import ( "context" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // ── R-302 — THE BANNER PROMISES ONLY WHAT THE BOX CAN STILL SEE IS TRUE ───────────────────────── // // The abandon banner said "until then you can still retrieve them with your recovery code", // unconditionally, on every page. Yesterday's reading proved that false on a reachable state. // // THE CONDITION IS A PIN, NOT A COMPARISON AGAINST THE CURRENT KEY, and the difference is the whole // design. The obvious proxy — "does the hub hold a key different from the one I use?" — asks about the // wrong key: the set-aside copies were written under an OLDER key the box no longer has, which is why // they were set aside. On a twice-rebuilt box the proxy answers "yes, promise it" about copies no key // on file can open. The pin instead records the package the hub held AT THE DECISION and asks only // "is the hub still holding that same one?". // // ⚠ THE PIN IS A RECORDED ASSUMPTION. It presumes the package held at the decision is the one that // opens the set-aside copies. Nothing on the box records which key wrote them. See the field comment // on settings.AbandonPinnedEscrowKeySHA256. // // The countdown is never started, shortened or triggered on a real machine — the clock is injected. const pinnedHubKey = "1111111111111111111111111111111111111111111111111111111111111111" const replacedHubKey = "2222222222222222222222222222222222222222222222222222222222222222" // startedCountdown drives the PRODUCTION path (ResetOrphanedRepo → resetOrphanedRepo → // startAbandonCountdown) so the pin cannot be written by tests alone while the live path never sets // it — the inert-seam shape that has shipped here before, fully green. func startedCountdown(t *testing.T, hubKeyAtDecision string) (*Manager, *settings.Settings, time.Time) { t.Helper() start := time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC) m, sett, _ := abandonFixture(t, start) if err := sett.SetHubEscrowKeySHA256(hubKeyAtDecision, start.Format(time.RFC3339)); err != nil { t.Fatal(err) } if err := m.ResetOrphanedRepo(context.Background()); err != nil { t.Fatalf("the production reset path failed: %v", err) } return m, sett, start } // PRODUCTION WIRING: the live decision path writes the pin. If this fails, every render test below is // testing a field nothing sets. func TestR302_ProductionResetPathWritesThePin(t *testing.T) { _, sett, _ := startedCountdown(t, pinnedHubKey) got := sett.GetOffboxTarget().AbandonPinnedEscrowKeySHA256 if got != pinnedHubKey { t.Fatalf("pinned fingerprint = %q, want the hub key cached at the decision (%q). The whole "+ "design is that this is recorded when it is a fact; if the live path does not write it, "+ "the banner falls to the cautious branch for ever and the grace period becomes theatre", got, pinnedHubKey) } if sett.GetOffboxTarget().AbandonAt == "" { t.Error("no countdown recorded — the fixture is not exercising the path it claims to") } } // ── SCENARIO A — package unchanged since the decision → the promise stands ────────────────────── // // RED-PROOF: force the condition false (drop the `cur == t.AbandonPinnedEscrowKeySHA256` arm) and this // fails — a customer who can genuinely still change their mind loses the clause, which the code says // explicitly must not happen ("a grace period during which recovery is impossible would be theatre"). func TestR302_ScenarioA_PackageUnchanged_RetrievalStillOffered(t *testing.T) { m, _, _ := startedCountdown(t, pinnedHubKey) st := m.AbandonStatus() if !st.Active { t.Fatal("countdown not active") } if !st.RetrievalStillOffered { t.Error("the hub still holds the same package it held at the decision, so the customer really " + "can still change their mind — the promise must stand") } } // ── SCENARIO B — the package was REPLACED after the decision → promise withdrawn ──────────────── // // This is the act that cost both demo boxes their history on 2026-08-04: a fresh escrow ceremony // supersedes the package, and the old key it covered is unreachable (superseded packages grant no // read path — hub store.go's own comment). // // RED-PROOF: re-read the pin at render (compare `cur` against itself, i.e. use the CURRENT cached // value on both sides) and this fails — the promise returns, which is today's defect. func TestR302_ScenarioB_PackageReplaced_PromiseWithdrawn(t *testing.T) { m, sett, start := startedCountdown(t, pinnedHubKey) // A fresh ceremony after the decision. if err := sett.SetHubEscrowKeySHA256(replacedHubKey, start.Add(48*time.Hour).Format(time.RFC3339)); err != nil { t.Fatal(err) } if st := m.AbandonStatus(); st.RetrievalStillOffered { t.Error("the hub's package was replaced after the customer decided, so the key that opened the " + "set-aside copies is no longer served — the banner must stop promising retrieval") } // The pin itself must NOT have moved: it is written once, at the decision. if got := sett.GetOffboxTarget().AbandonPinnedEscrowKeySHA256; got != pinnedHubKey { t.Errorf("the pin was refreshed to %q — a field re-read later answers a different question and "+ "silently restores the defect this replaces", got) } } // ── SCENARIO D — a countdown started BEFORE this shipped carries no pin ───────────────────────── // // RED-PROOF: backfill the pin from the current cached value when it is empty and this fails — a legacy // countdown gets promised at, asserting as recorded-at-the-decision something read long afterwards. func TestR302_ScenarioD_LegacyCountdownWithoutAPin_TakesTheCautiousBranch(t *testing.T) { m, sett, _ := startedCountdown(t, pinnedHubKey) // Model the pre-R-302 on-disk shape: a live countdown, no pin. if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.AbandonPinnedEscrowKeySHA256 = "" }); err != nil { t.Fatal(err) } st := m.AbandonStatus() if !st.Active { t.Fatal("countdown should still be running") } if st.RetrievalStillOffered { t.Error("a countdown with no pin was promised at. There is no honest way to know whether the " + "hub's package is still the one from the decision, and the cautious answer is the only one " + "available") } } // ── SCENARIO E — pinned present, hub's cached value EMPTY → cautious ──────────────────────────── // // The hub sends "" for a legacy package that provably seals no repository password. Empty is a // measurement, not a match. // // RED-PROOF: treat empty as equal (drop the `cur != ""` arm) and this fails. func TestR302_ScenarioE_EmptyHubHash_IsNotAMatch(t *testing.T) { m, sett, start := startedCountdown(t, pinnedHubKey) if err := sett.SetHubEscrowKeySHA256("", start.Add(time.Hour).Format(time.RFC3339)); err != nil { t.Fatal(err) } if st := m.AbandonStatus(); st.RetrievalStillOffered { t.Error("an EMPTY hub hash was read as a match. It means the hub holds a package that seals no " + "repository password — the opposite of evidence that retrieval works") } } // ── SCENARIO F — no countdown → nothing about retrieval is claimed at all ─────────────────────── func TestR302_ScenarioF_NoCountdown_NoClaim(t *testing.T) { start := time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC) m, _, _ := abandonFixture(t, start) st := m.AbandonStatus() if st.Active { t.Fatal("no countdown was started, yet one is reported active") } if st.RetrievalStillOffered { t.Error("retrieval was offered with no countdown running — the flag must be meaningless " + "outside an abandonment, not default-true") } } // The pin is a hash of a secret. It must never reach a customer-facing surface or the report; this // pins that it is not accidentally exported through the read model. func TestR302_PinIsNotExposedThroughTheReadModel(t *testing.T) { m, _, _ := startedCountdown(t, pinnedHubKey) st := m.AbandonStatus() if st.RepoPath == pinnedHubKey { t.Fatal("the pin leaked into RepoPath") } // AbandonState carries a BOOLEAN verdict, never the fingerprint itself. if got := st.RetrievalStillOffered; got != true && got != false { t.Fatal("unreachable") } } // ── SCENARIO E, the case that actually bites — BOTH sides empty ───────────────────────────────── // // A legacy countdown (no pin) on a box whose hub reports an empty hash (a package sealing no repo // password). "" == "" is the equality that would quietly become a promise, and it is the ONLY state // where dropping the emptiness guards changes the answer — TestR302_ScenarioE above passes even with // them removed, because its pin is non-empty so the equality fails on its own. That test guards the // sentence; this one guards the claim. // // RED-PROOF: drop either `cur != ""` or `t.AbandonPinnedEscrowKeySHA256 != ""` and this fails. func TestR302_ScenarioE2_BothSidesEmpty_IsNotAMatch(t *testing.T) { m, sett, start := startedCountdown(t, pinnedHubKey) if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.AbandonPinnedEscrowKeySHA256 = "" // legacy countdown, no pin }); err != nil { t.Fatal(err) } if err := sett.SetHubEscrowKeySHA256("", start.Add(time.Hour).Format(time.RFC3339)); err != nil { t.Fatal(err) } st := m.AbandonStatus() if !st.Active { t.Fatal("countdown should still be running") } if st.RetrievalStillOffered { t.Error("two absences compared equal and became a promise. Empty means we could not see; two " + "things we could not see are not a match") } }