R-302: the abandon banner promises only what the box can still see is true
gates / gates (push) Successful in 10s
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -953,6 +953,11 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data["AbandonActive"] = true
|
||||
data["AbandonDaysLeft"] = st.DaysLeft
|
||||
data["AbandonDate"] = st.DueAt.Format("2006-01-02")
|
||||
// R-302: this block makes the SAME retrieval promise as the banner, under a different verb
|
||||
// („visszaszerezhetők" vs the banner's „visszaszerezheted"), which is why it was a fourth
|
||||
// instance nobody had counted. Same single derivation — fixing one surface and not the other
|
||||
// would leave the page contradicting the strip above it.
|
||||
data["AbandonRetrievalOffered"] = st.RetrievalStillOffered
|
||||
} else if st.PurgeRequested {
|
||||
// The store is deleted and the sealed package is on its way out. Say so rather than
|
||||
// showing nothing, or the page silently loses a thing the customer was watching.
|
||||
|
||||
@@ -626,6 +626,9 @@ func (s *Server) addRecoveryBanner(data map[string]interface{}, r *http.Request)
|
||||
if st.DaysLeft <= mark {
|
||||
data["RecoveryAbandonDays"] = st.DaysLeft
|
||||
data["RecoveryAbandonDate"] = st.DueAt.Format("2006-01-02")
|
||||
// R-302: the retrieval clause is conditional; the deletion sentence is not. Taken
|
||||
// from the read model so this surface cannot form its own opinion.
|
||||
data["RecoveryAbandonRetrievalOffered"] = st.RetrievalStillOffered
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,9 +188,14 @@
|
||||
<div class="card" style="border-left:3px solid var(--warn);margin:.75rem 0;padding:.75rem 1rem">
|
||||
<p style="margin:0 0 .35rem"><strong>A korábbi mentések törlése folyamatban</strong></p>
|
||||
<p class="form-hint" style="margin:0 0 .5rem">
|
||||
{{/* R-302: the FOURTH instance of the retrieval promise, under a different verb than the
|
||||
banner's — which is why no earlier guard counted it. Same condition, same single
|
||||
derivation: fixing the strip above and not this would leave one page contradicting the
|
||||
other. The deletion and its date are certain and always render. */}}
|
||||
A kérésed szerint a korábbi távoli mentéseidet <strong>{{.AbandonDate}}</strong> napján véglegesen töröljük
|
||||
(még <strong>{{.AbandonDaysLeft}} nap</strong>). Addig meggondolhatod magad: ha megvan a helyreállítási kódod,
|
||||
a mentéseid visszaszerezhetők, és a törlés elmarad.
|
||||
(még <strong>{{.AbandonDaysLeft}} nap</strong>).
|
||||
{{if .AbandonRetrievalOffered}}Addig meggondolhatod magad: ha megvan a helyreállítási kódod,
|
||||
a mentéseid visszaszerezhetők, és a törlés elmarad.{{else}}Hogy ezek még visszaszerezhetők-e a helyreállítási kóddal, azt innen nem tudjuk megállapítani — ha vissza szeretnéd kapni őket, <strong>írj nekünk a törlés előtt</strong>.{{end}}
|
||||
</p>
|
||||
<a href="/recovery" class="btn btn-sm btn-primary">Mégis visszaszerzem a kóddal</a>
|
||||
</div>
|
||||
|
||||
@@ -140,7 +140,14 @@
|
||||
<span class="alert-icon"><svg class="ico"><use href="#i-triangle-alert"/></svg></span>
|
||||
<span class="alert-message">
|
||||
{{if .RecoveryAbandonDays}}
|
||||
A korábbi távoli mentéseidet <strong>{{.RecoveryAbandonDays}} nap múlva</strong> ({{.RecoveryAbandonDate}}) véglegesen töröljük, a kérésed szerint. Addig még visszaszerezheted őket a helyreállítási kóddal.
|
||||
{{/* R-302: the DELETION and its date are certain and always render. The RETRIEVAL clause is
|
||||
conditional on the hub still holding the same sealed package it held when the customer
|
||||
decided — pinned then, compared now. It rendered unconditionally, and was false on a
|
||||
reachable state: a fresh escrow ceremony during the window replaces the package, which
|
||||
is the exact act that cost both demo boxes their history on 2026-08-04. A countdown
|
||||
started before this shipped carries no pin and takes the cautious branch. */}}
|
||||
A korábbi távoli mentéseidet <strong>{{.RecoveryAbandonDays}} nap múlva</strong> ({{.RecoveryAbandonDate}}) véglegesen töröljük, a kérésed szerint.
|
||||
{{if .RecoveryAbandonRetrievalOffered}}Addig még visszaszerezheted őket a helyreállítási kóddal.{{else}}Hogy ezek még visszaszerezhetők-e a helyreállítási kóddal, azt innen nem tudjuk megállapítani — ha vissza szeretnéd kapni őket, <strong>írj nekünk a törlés előtt</strong>.{{end}}
|
||||
{{else if ge .RecoveryReminderTier 14}}
|
||||
<strong>Két hete</strong> várnak rád a korábbi távoli mentéseid, és még nem adtad meg a helyreállítási kódodat. Amíg nem teszed, ezekhez a mentésekhez nem férsz hozzá.
|
||||
{{else if ge .RecoveryReminderTier 7}}
|
||||
|
||||
Reference in New Issue
Block a user