R-302: the abandon banner promises only what the box can still see is true
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.
This commit is contained in:
2026-08-12 15:27:29 +02:00
parent 1b66010298
commit 89712563a0
11 changed files with 554 additions and 3 deletions
@@ -0,0 +1,117 @@
package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// ── R-302 — RENDERED, at the boundary the defect lives at ───────────────────────────────────────
//
// The condition is unit-tested in internal/backup; these assert the SENTENCES, because the defect was
// always copy that disagreed with what the box could see, and only the rendered bytes show that.
// abandonBannerData renders a page carrying the countdown strip. `offered` is the R-302 verdict.
func abandonBannerData(offered bool, repoState string) map[string]interface{} {
d := splitTestData()
d["Offbox"] = &settings.OffboxTarget{
Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo",
EscrowState: "escrowed", RepoState: repoState, QuotaGB: 50, StatsKnown: true,
}
d["OffboxQuotaPct"] = 0
d["RecoveryBanner"] = true
d["RecoveryAbandonDays"] = 3
d["RecoveryAbandonDate"] = "2026-08-26"
d["RecoveryAbandonRetrievalOffered"] = offered
return d
}
const (
promiseClause = "visszaszerezheted őket a helyreállítási kóddal"
cautiousClause = "nem tudjuk megállapítani"
deletionClause = "véglegesen töröljük"
writeToUs = "írj nekünk a törlés előtt"
)
// ── SCENARIO A — package unchanged → the clause stands, byte-identical in meaning to before ─────
func TestR302_Render_A_PromiseKeptWhenStillTrue(t *testing.T) {
html := renderBackupPage(t, "backups_remote", abandonBannerData(true, "ok"))
if !strings.Contains(html, deletionClause) {
t.Fatal("the deletion sentence is missing — that half is certain and must always render")
}
if !strings.Contains(html, promiseClause) {
t.Error("R-302: a customer who can genuinely still change their mind lost the retrieval clause. " +
"The grace period is explicitly NOT decorative; hedging a true sentence is its own dishonesty")
}
}
// ── SCENARIO B/D/E (rendered) — cautious branch says what is true and names a route ─────────────
//
// RED-PROOF: remove the `{{if .RecoveryAbandonRetrievalOffered}}` conditional from layout.html and
// this fails on the first assertion — the false promise returns.
func TestR302_Render_B_CautiousBranchWhenNotKnowable(t *testing.T) {
html := renderBackupPage(t, "backups_remote", abandonBannerData(false, "ok"))
if strings.Contains(html, promiseClause) {
t.Error("R-302: the banner still promises retrieval when the box cannot see that it is true — " +
"this is the sentence a customer reads after giving up their history")
}
if !strings.Contains(html, cautiousClause) {
t.Error("R-302: the cautious branch does not say we cannot determine it — silence is not the " +
"same as declining a claim")
}
if !strings.Contains(html, writeToUs) {
t.Error("R-302: the cautious branch names no route, and it is time-bounded — the customer must " +
"be told to write in BEFORE the deletion date")
}
// The certain half is unconditional.
if !strings.Contains(html, deletionClause) {
t.Error("R-302: the deletion sentence was lost with the promise — it is the part we DO know")
}
}
// ── SCENARIO C — THE CO-RENDER. The card and the banner must not contradict each other ──────────
//
// Reachable per yesterday's reading: ResetOrphanedRepo clears RepoState then starts the countdown, but
// markOrphaned (offbox.go:804) has NO guard against an active countdown, so a later run finding the
// FRESH store unopenable re-raises the card while the countdown runs.
//
// RED-PROOF — THE ONE THAT MATTERS: replace the condition with the rejected proxy (hub fingerprint vs
// the CURRENT key, compared at render). In this state those differ, so the proxy answers "promise it"
// and the false promise returns on the very screen the card is declining it. That is why the pin was
// chosen over the obvious condition.
func TestR302_Render_C_CoRenderDoesNotContradictItself(t *testing.T) {
html := renderBackupPage(t, "backups_remote", abandonBannerData(false, "orphaned"))
if !strings.Contains(html, "offbox-orphan-card") {
t.Fatal("the orphan card did not render — this test would then prove nothing about the co-render")
}
if !strings.Contains(html, deletionClause) {
t.Fatal("the banner did not render — likewise")
}
// The card says we cannot tell. The banner must not say the opposite one strip above it.
if strings.Contains(html, promiseClause) {
t.Error("R-302 CO-RENDER: the orphan card says we cannot determine whether the set-aside copies " +
"can be opened, and the banner above it tells the customer they can still retrieve them. " +
"One page, two answers, and the confident one is the wrong one")
}
}
// ── SCENARIO F — no countdown → the undecided reminder ladder is untouched ──────────────────────
func TestR302_Render_F_NoCountdownLeavesTheLadderAlone(t *testing.T) {
d := splitTestData()
d["RecoveryBanner"] = true
d["RecoveryReminderTier"] = 14 // an undecided box, two weeks waiting
html := renderBackupPage(t, "backups_remote", d)
for _, s := range []string{deletionClause, promiseClause, cautiousClause} {
if strings.Contains(html, s) {
t.Errorf("abandonment copy %q leaked onto a box with no countdown running", s)
}
}
if !strings.Contains(html, "Két hete") {
t.Error("the undecided reminder ladder changed — it is not in scope and must be byte-identical")
}
}