R-304: the recovery screen says 'we do not know' when earlier packages were not checked (hu + en)
gates / gates (push) Successful in 54s

Unreleased; ships with tomorrow's release.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-08 07:53:09 +02:00
parent 6d07ca2be4
commit 75b3b39254
7 changed files with 94 additions and 1 deletions
+12
View File
@@ -210,6 +210,10 @@ const (
// out loud — a true sentence about our own incuriosity that a customer reads as a statement about
// their code.
RecoveryCodeOpensRetained
// RecoveryOlderUnchecked — the code did not open the current package, no earlier package opened it, and
// NOT every earlier package the hub holds was tried (R-304, agent 424). So whether the code is wrong is
// NOT known. The customer must not be told to check their typing as if it were.
RecoveryOlderUnchecked
)
// ClassifyRecoveryFailure maps an unlock error to its class, from the VALUE and never the text.
@@ -251,6 +255,12 @@ func ClassifyRecoveryFailure(err error, trustRefusal, trustRetained bool) Recove
return RecoveryCodeOpensRetained
}
return RecoveryUnknown
case http.StatusFailedDependency:
// R-304. NOT version-gated, deliberately — unlike 400 and 422 above. Those two make a claim about the
// customer's code (wrong / correct), so a status we did not design must not be read as one. This class
// claims only „not everything was checked; contact support", which is the same direction as
// RecoveryUnknown: misreading a stray 424 here can never accuse anyone.
return RecoveryOlderUnchecked
case http.StatusBadRequest:
if trustRefusal {
return RecoveryAskedAndRefused
@@ -276,6 +286,8 @@ func (f RecoveryFailure) String() string {
return "bundle-too-old"
case RecoveryCodeOpensRetained:
return "code-opens-retained"
case RecoveryOlderUnchecked:
return "older-unchecked"
default:
return "unknown"
}
+1
View File
@@ -1895,6 +1895,7 @@
"recovery.meret": "Size",
"recovery.most_nem": "Not now",
"recovery.nem_kerem_vissza_a_korabbi": "<a href=\"/recovery?setaside=1\">I do not want the earlier data back</a>.",
"recovery.older_unchecked": "This code did not open the package we keep for this machine now. We also keep earlier packages for this machine, but we could not try all of them with your code — so we do not know whether your code is wrong. It can be a typing mistake: check that you typed all ten words exactly, separated by spaces, and try again. Or your code can belong to an earlier package. If it still does not work, keep your code and contact Felhom support: tell them that your recovery code did not open your backups. Nothing has changed.",
"recovery.semmi_nem_valtozott_varj_nehany": "Nothing changed. Wait a few seconds and try again — you will still need your code,",
"recovery.tiz_szo_szokozokkel_elvalasztva": "ten words, separated by spaces",
"recovery.tovabb_a_visszaallitashoz": "Continue to restore",
+1
View File
@@ -1883,6 +1883,7 @@
"recovery.meret": "Méret",
"recovery.most_nem": "Most nem",
"recovery.nem_kerem_vissza_a_korabbi": "<a href=\"/recovery?setaside=1\">nem kérem vissza a korábbi adatokat</a>.",
"recovery.older_unchecked": "Ez a kód nem nyitotta meg azt a csomagot, amit most őrzünk ehhez a géphez. Ehhez a géphez korábbi csomagokat is őrzünk, de ezeket nem tudtuk mind kipróbálni a kódoddal — ezért nem tudjuk, hogy a kódod hibás-e. Lehet elgépelés: ellenőrizd, hogy mind a tíz szót pontosan, szóközökkel elválasztva írtad-e be, és próbáld újra. Lehet az is, hogy a kódod egy korábbi csomaghoz tartozik. Ha újrapróbálod és úgy sem megy, a kódodat tedd el, és keresd a Felhom ügyfélszolgálatát: írd meg, hogy a helyreállítási kódod nem nyitotta meg a mentéseidet. Semmi nem változott.",
"recovery.semmi_nem_valtozott_varj_nehany": "Semmi nem változott. Várj néhány másodpercet, és próbáld újra — a kódodra továbbra is szükséged lesz,",
"recovery.tiz_szo_szokozokkel_elvalasztva": "tíz szó, szóközökkel elválasztva",
"recovery.tovabb_a_visszaallitashoz": "Tovább a visszaállításhoz",
@@ -446,6 +446,17 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[INFO] [web] recovery: the code opened a RETAINED package — the customer is not at fault")
s.renderRecovery(w, r, "A kódod helyes, de egy korábbi csomagot nyit meg, nem azt, amit most őrzünk ehhez a géphez. A géped időközben új mentési kulcsot kapott. A korábbi csomagot"+when+" nem töröltük, megőrizzük — a mostani mentéseidet ez nem érinti, azokkal semmi nem történt. A régebbi előzményed visszanyitásához a Felhom ügyfélszolgálatának segítsége kell: írj nekik, és add meg, hogy a régi mentéseidhez szeretnél hozzáférni. A kódodat tedd el, szükség lesz rá.", "", nil)
return
case agentapi.RecoveryOlderUnchecked:
// ── R-304 (2026-10-08) — NOT EVERY EARLIER PACKAGE WAS TRIED. ────────────────────────────
//
// The current package refused the code and no earlier package opened it, but the hub holds
// earlier packages the agent could not try (no key material, over a cap, unreadable list). So we
// do NOT know the code is wrong, and the message says exactly that, names the mistype as one
// possibility (R-226: a new-code holder may be typing), and names the route. Pinned by
// TestR304_OlderUnchecked_IsNotAWrongCode.
s.logger.Printf("[WARN] [web] recovery: earlier sealed packages were not all checked — not reported as a wrong code (R-304)")
s.renderRecovery(w, r, s.msg(r, "recovery.older_unchecked"), "", nil)
return
case agentapi.RecoveryAskedAndRefused:
// The bundle was fetched and the code did not open it. THIS is the only class from which
// the customer may be told to check their typing — see the two messages below.
@@ -0,0 +1,59 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// ── R-304 (2026-10-08) — NOT EVERY EARLIER PACKAGE WAS CHECKED, SO THE CODE IS NOT CALLED WRONG ───
//
// The agent answers 424 when the current package refused the code, no earlier package opened it, and some
// earlier package the hub holds was never tried. The page must not accuse („nem fogadtuk el"), must say that
// we do not know whether the code is wrong, and must name the route — in both languages.
//
// RED-PROOF: delete the `agentapi.RecoveryOlderUnchecked` case from recoveryUnlockHandler → the 424 falls into
// the safe default („nem tudjuk biztosan, miért") → this FAILS on the „do not know whether wrong" sentence.
func TestR304_OlderUnchecked_IsNotAWrongCode(t *testing.T) {
for _, tc := range []struct {
lang, mustSay, route, mustNotSay string
}{
{"hu", "nem tudjuk, hogy a kódod hibás-e", "ügyfélszolgálat", "nem fogadtuk el"},
{"en", "we do not know whether your code is wrong", "contact Felhom support", "nem tudjuk"},
} {
t.Run(tc.lang, func(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.failWith = refusal(http.StatusFailedDependency, "the recovery code did not open the current sealed package, and earlier packages the hub holds were not all checked")
form := url.Values{"recovery_code": {testRecoveryCode}}
req := httptest.NewRequest(http.MethodPost, "/recovery/unlock", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: langCookieName, Value: tc.lang})
rr := httptest.NewRecorder()
f.s.recoveryUnlockHandler(rr, req)
body := rr.Body.String()
if !strings.Contains(body, tc.mustSay) {
t.Fatalf("[%s] the page must say we do not know whether the code is wrong; got %q", tc.lang, firstAlert(body))
}
if !strings.Contains(body, tc.route) {
t.Errorf("[%s] the page must name the route (support); got %q", tc.lang, firstAlert(body))
}
if strings.Contains(body, tc.mustNotSay) {
t.Errorf("[%s] the page must not say %q; got %q", tc.lang, tc.mustNotSay, firstAlert(body))
}
})
}
}
// The classifier maps 424 to its own class, never to the accusing one, with or without the version gates.
func TestR304_Classify424(t *testing.T) {
err := refusal(http.StatusFailedDependency, "x")
for _, trust := range []bool{false, true} {
if got := agentapi.ClassifyRecoveryFailure(err, trust, trust); got != agentapi.RecoveryOlderUnchecked {
t.Fatalf("trust=%v: 424 classified %s, want older-unchecked", trust, got)
}
}
}