package backup import ( "os" "path/filepath" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // R-241 shape (c) — the recovery offer is driven by the comparison the box already makes. // // Scenarios C and D from the task, plus §7.2's two staleness cases. The point of shape (c) is that // it asks the real question — *does the hub hold a package for a key other than the one I am // using?* — rather than the two proxies that have each now been wrong in opposite directions. // offerFixture builds a manager holding a repository password, with the hub's cached facts settable. // Returns the local key's hash so a test can make the hub's hash match or differ deliberately. func offerFixture(t *testing.T, hubHoldsPackage bool) (*Manager, *settings.Settings, string) { t.Helper() m, sett, pwPath := mintGuardManager(t, false) // mint freely first if err := sett.SetOffboxTarget(&settings.OffboxTarget{ Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily", }); err != nil { t.Fatal(err) } if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil { t.Fatal(err) } if _, err := os.Stat(pwPath); err != nil { t.Fatalf("fixture should hold a repository password: %v", err) } local, ok := m.OffboxRepoPasswordHash() if !ok { t.Fatal("fixture should be able to hash its own key") } if err := sett.SetHubEscrowIdentityPresent(hubHoldsPackage); err != nil { t.Fatal(err) } return m, sett, local } const otherKeyHash = "9b4a9a9dcec7898e7544f35b18470aac77c3d9064e5d3a302897617fa62edd65" // ── SCENARIO C — a differing key offers recovery, whatever the reason for the difference ──────── // // This is the venue's exact state on 2026-08-07: a key present, no orphan recorded, escrow stuck // pending — and before shape (c), silence. func TestR241_ScenarioC_DifferingKeyOffersRecovery(t *testing.T) { m, sett, local := offerFixture(t, true) if local == otherKeyHash { t.Fatal("fixture precondition: the local key must differ from the hub's") } if err := sett.SetHubEscrowKeySHA256(otherKeyHash, "2026-08-07T03:28:03Z"); err != nil { t.Fatal(err) } // Neither proxy fires: a key EXISTS (so not shape (a)) and nothing is orphaned (so not shape (b)). if _, ok := m.OffboxRepoPasswordHash(); !ok { t.Fatal("precondition: shape (a) must be false") } if m.OffboxOrphaned() { t.Fatal("precondition: shape (b) must be false") } if !m.OffsiteRecoveryOffer() { t.Fatal("R-241: the hub holds a package for a DIFFERENT key and the screen was not offered — this is the defect") } } // ── SCENARIO D — a healthy box is never offered recovery ──────────────────────────────────────── // // RED-PROOF: drop the `hubHash != localHash` conjunct in shape (c) (make it `hubHash != ""`). A // healthy box is then offered recovery forever, and this test fails — which is how a screen stops // being read. func TestR241_ScenarioD_MatchingKeyOffersNothing(t *testing.T) { m, sett, local := offerFixture(t, true) if err := sett.SetHubEscrowKeySHA256(local, "2026-08-07T09:00:00Z"); err != nil { t.Fatal(err) } if m.OffsiteRecoveryOffer() { t.Fatal("a box whose key the hub's package covers must never be offered recovery") } } // A box the hub holds nothing for is never offered, even if a stale hash lingers in settings. Fact 1 // stays required — the spike's comment block calls dropping it "the plausible wrong fix". func TestR241_ShapeC_NeverHadOffsiteIsStillSilent(t *testing.T) { m, sett, _ := offerFixture(t, false) // the hub holds NOTHING if err := sett.SetHubEscrowKeySHA256(otherKeyHash, "2026-08-07T09:00:00Z"); err != nil { t.Fatal(err) } if m.OffsiteRecoveryOffer() { t.Fatal("a box that never had off-site backups must never be greeted by a recovery screen") } } // ── §7.2 — the staleness decision, both halves ────────────────────────────────────────────────── // A KNOWN DIFFERENCE OFFERS, however old the reading. Age is deliberately not gated on: gating would // make a box offline from the hub silently stop offering, which is the failure this session exists // to remove. func TestR241_StaleComparison_KnownDifferenceStillOffers(t *testing.T) { m, sett, _ := offerFixture(t, true) if err := sett.SetHubEscrowKeySHA256(otherKeyHash, "2020-01-01T00:00:00Z"); err != nil { // ancient t.Fatal(err) } if !m.OffsiteRecoveryOffer() { t.Fatal("a known difference must offer regardless of how old the reading is (§7.2)") } } // AN ABSENT HASH FALLS BACK TO (a)/(b) — it does not offer. "" is the hub positively saying its // package seals no repository password (legacy hash-less escrow); there is nothing to compare, and // offering would put a permanent screen in front of every legacy box. func TestR241_StaleComparison_AbsentHashFallsBackAndDoesNotOffer(t *testing.T) { m, sett, _ := offerFixture(t, true) if err := sett.SetHubEscrowKeySHA256("", ""); err != nil { t.Fatal(err) } if m.OffsiteRecoveryOffer() { t.Fatal("a hash never learned must fall back to (a)/(b), not offer (§7.2)") } // ...and the fallback still works: mark the repo orphaned and shape (b) fires as before. if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.RepoState = "orphaned" }); err != nil { t.Fatal(err) } if !m.OffsiteRecoveryOffer() { t.Fatal("shape (b) must still work when the hub's hash was never learned") } } // Shape (a) is untouched: a box with no key at all is still offered, which is the pristine rebuild. func TestR241_ShapeAStillWorks(t *testing.T) { m, sett, _ := offerFixture(t, true) if err := os.Remove(filepath.Join(m.cfg.Paths.DataDir, "offbox", "repo_password")); err != nil { t.Fatal(err) } if err := sett.SetHubEscrowKeySHA256(otherKeyHash, "2026-08-07T09:00:00Z"); err != nil { t.Fatal(err) } if !m.OffsiteRecoveryOffer() { t.Fatal("shape (a) — no repository password at all — must still offer") } }