89712563a0
gates / gates (push) Successful in 10s
The retrieval clause rendered unconditionally on every page and is false on a reachable state - the same screen where the orphan card says we cannot tell. The condition is a fingerprint PINNED at the decision, not a comparison against the current key. The obvious proxy asks about the wrong key: the set-aside copies were written under an older key the box no longer has, so on a twice-rebuilt box the proxy promises about copies nothing can open. Demonstrated - under the proxy, the replaced-package and legacy cases both flip back to promising. The pin is a recorded assumption and says so: nothing on the box records which key wrote those copies. Empty is not a match. A countdown started before this carries no pin and takes the cautious branch, not a backfill. A sweep of all 36 templates found a fourth instance (backups page, same condition applied) and a fifth (the confirmation screen, correctly left alone - true at the moment of the decision). New retrieval_promise_gate registers each claim with a reason rather than banning a verb: a string ban failed twice, and the honest replacement copy contains the stem.
209 lines
9.6 KiB
Go
209 lines
9.6 KiB
Go
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")
|
|
}
|
|
}
|