v0.259.0 — the claim page and the backup warnings answer in the reader's language (R-596, R-598)
gates / gates (push) Successful in 28s

The 2026-09-20 English drill ended one screen short: the claim page was English
and its answers were Hungarian, so a household who mistyped the code from their
e-mail could not tell a typo from a dead code. Fourteen call sites carrying nine
messages now go through s.msg; the backup page's two protection warnings — which
are promises about whether the customer's files are safe — follow the same route.

Hungarian is byte-identical, proved structurally by the go-parity gate against the
frozen base capture and red-proofed on a single added full stop.

data["Title"] was DEAD (claim.html is standalone; .Title is layout.html's) and is
deleted rather than translated — a translated dead field is a permanent false
signal about where the page's title comes from.

Six existing copy-contract tests were kept, not weakened: each now resolves its key
through the real bundle, so it still convicts on a reworded Hungarian sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-21 07:45:56 +02:00
parent f6909492cc
commit 0fe04bb6d5
15 changed files with 736 additions and 143 deletions
+15 -14
View File
@@ -151,16 +151,16 @@ func tierWindow(cadenceSecs int64) time.Duration {
// buildTierViews turns the agent's per-tier state into page rows and derives the three legacy tile
// fields from SUCCESSES only: HasBackup/Success/Size/Target/StartedAt from the primary tier's newest
// success; Due when any set-up tier has no current success; Offsite only on a current PBS success.
func buildTierViews(v *guestBackupView, tiers []agentapi.TierBackupState, cadence map[string]int64, now time.Time) {
func buildTierViews(v *guestBackupView, tiers []agentapi.TierBackupState, cadence map[string]int64, now time.Time, msg func(key string, a ...interface{}) string) {
v.Tiers = nil
v.Due, v.Offsite, v.HasBackup = false, false, false
var newestOK time.Time
for _, t := range tiers {
tv := guestTierView{Target: t.Target, IsPBS: strings.Contains(strings.ToLower(t.Target), "pbs")}
if tv.IsPBS {
tv.Label = "Biztonsági szerver – külön hardver (PBS)"
tv.Label = msg("backup.tier.pbs")
} else {
tv.Label = "Helyi tároló (" + t.Target + ")"
tv.Label = msg("backup.tier.local", t.Target)
}
tv.NotSetUp = t.Storage == "absent"
if t.LastSuccess != nil {
@@ -200,16 +200,17 @@ func buildTierViews(v *guestBackupView, tiers []agentapi.TierBackupState, cadenc
// loadGuestBackup fetches the agent's whole-guest backup view (best-effort). Returns a view with
// Available=false (+ a note) when the agent isn't configured/reachable — the page still renders.
func (s *Server) loadGuestBackup(ctx context.Context) *guestBackupView {
func (s *Server) loadGuestBackup(ctx context.Context, lang string) *guestBackupView {
msg := func(key string, a ...interface{}) string { return s.msgLang(lang, key, a...) }
v := &guestBackupView{CanTrigger: s.backupTrigger != nil}
client, err := s.agentClient()
if err != nil {
v.Note = "A host-ügynök nincs konfigurálva ezen a gépen."
v.Note = msg("backup.guest.agent_unconfigured")
return v
}
st, err := client.BackupStatus(ctx)
if err != nil {
v.Note = "A host-ügynök jelenleg nem elérhető."
v.Note = msg("backup.guest.agent_unreachable")
return v
}
v.Available = true
@@ -222,7 +223,7 @@ func (s *Server) loadGuestBackup(ctx context.Context) *guestBackupView {
v.Archive = st.Backup.Archive
v.Mode = st.Backup.Mode
v.StopMode = st.Backup.Mode == "stop"
v.Target = backupTargetLabel(st.Backup)
v.Target = backupTargetLabel(st.Backup, msg)
v.Offsite = backupIsPBS(st.Backup)
if t, perr := time.Parse(time.RFC3339, st.Backup.StartedAt); perr == nil {
v.StartedAt = t
@@ -245,7 +246,7 @@ func (s *Server) loadGuestBackup(ctx context.Context) *guestBackupView {
cadence[t.Target] = t.CadenceSeconds
}
}
buildTierViews(v, st.Tiers, cadence, time.Now())
buildTierViews(v, st.Tiers, cadence, time.Now(), msg)
}
// Restore-test (the "verified restorable" trust signal; nil until one runs).
if rt, rerr := client.RestoreTestStatus(ctx); rerr == nil && rt != nil {
@@ -270,14 +271,14 @@ func backupIsPBS(b *agentapi.BackupRecord) bool {
// backupTargetLabel maps the agent's backup target to a customer-facing Hungarian label. The PBS
// case calls out that the backup is on SEPARATE HARDWARE (real disaster recovery — survives a host
// disk/hardware failure), which is the whole point of re-pointing the backup offsite.
func backupTargetLabel(b *agentapi.BackupRecord) string {
func backupTargetLabel(b *agentapi.BackupRecord, msg func(key string, a ...interface{}) string) string {
if backupIsPBS(b) {
return "Biztonsági szerver – külön hardver (PBS)"
return msg("backup.tier.pbs")
}
if b.TargetID != "" {
return "Helyi tároló (" + b.TargetID + ")"
return msg("backup.tier.local", b.TargetID)
}
return "Helyi tároló"
return msg("backup.tier.local_plain")
}
// ServeBackupAPI dispatches /api/guest-backup/* (whole-guest manual trigger + status poll). A
@@ -298,12 +299,12 @@ func (s *Server) ServeBackupAPI(w http.ResponseWriter, r *http.Request) {
// returns immediately (the backup runs async, minutes); the page polls /api/backup/status.
func (s *Server) handleBackupTriggerAPI(w http.ResponseWriter, r *http.Request) {
if s.backupTrigger == nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, "a rendszermentés nem érhető el ezen a gépen", nil)
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.msg(r, "backup.guest.err.unavailable"), nil)
return
}
if err := s.backupTrigger.TriggerNow(); err != nil {
if errors.Is(err, quiesce.ErrBackupInProgress) {
writeDiskJSON(w, http.StatusConflict, false, "mentés már folyamatban van", nil)
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "backup.guest.err.in_progress"), nil)
return
}
s.logger.Printf("[ERROR] [web] backup trigger failed: %v", err)
@@ -0,0 +1,165 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// R-598 — THE TWO SENTENCES THAT SAY WHETHER THE CUSTOMER'S FILES ARE SAFE.
//
// The 2026-09-20 English drill found the Backup page's protection warnings still Hungarian on an
// otherwise English page. They are not decoration: they say "only one copy is being made" and "the
// system backup is on the same disk as the system, so it protects against bad files but not against
// a disk failure". A household who cannot read them believes they are protected when they are not.
//
// These tests render the REAL /backups page through the real handler in both languages, because
// that is the only instrument that can see this defect — the sentences arrive as struct fields, so
// the template parity fixtures render them faithfully whatever language they are in.
// renderBackupsPageLang is renderBackupsPage with a language cookie. Same production handler.
func renderBackupsPageLang(t *testing.T, lang, primary string, disks []agentapi.DiskInfo, registerPath string) string {
t.Helper()
s := absentHarness(t, primary, disks, registerPath)
s.loadTemplates()
req := httptest.NewRequest("GET", "/backups", nil)
if lang != "" {
req.AddCookie(&http.Cookie{Name: langCookieName, Value: lang})
}
rr := httptest.NewRecorder()
s.backupsHandler(rr, req)
if rr.Code != 200 {
t.Fatalf("backups page returned %d, want 200", rr.Code)
}
return rr.Body.String()
}
func enText(t *testing.T, key string) string {
t.Helper()
b, err := i18n.Shared()
if err != nil {
t.Fatal(err)
}
got := b.Msg("en", key)
if got == key {
t.Fatalf("en.json does not know %q", key)
}
return got
}
// S2 — the degraded warning and the offer follow the reader, in the state the drill actually saw:
// never configured, backup on the system disk, one drive attached.
func TestBackupWarningsFollowLanguage(t *testing.T) {
disks := []agentapi.DiskInfo{{
Name: "hdd1", MountPath: "/mnt/hdd1", GuestPath: "/mnt/felhom-drives/hdd1", Role: "user-data",
}}
const reg = "/mnt/felhom-drives/hdd1"
huHTML := renderBackupsPageLang(t, "hu", builtinLocalTarget, disks, reg)
for _, key := range []string{backupTargetDegradedKey, backupTargetOfferKey} {
want := huText(t, key)
if !strings.Contains(huHTML, want) {
t.Errorf("the HUNGARIAN backups page lost %q — the pre-v0.259.0 wording must be byte-identical", want)
}
}
enHTML := renderBackupsPageLang(t, "en", builtinLocalTarget, disks, reg)
for _, key := range []string{backupTargetDegradedKey, backupTargetOfferKey} {
if want := enText(t, key); !strings.Contains(enHTML, want) {
t.Errorf("the ENGLISH backups page is missing %q", want)
}
// The decisive half: the Hungarian sentence must be GONE, not merely accompanied.
if hu := huText(t, key); strings.Contains(enHTML, hu) {
t.Errorf("the HUNGARIAN sentence %q is still on the ENGLISH backups page", hu)
}
}
}
// The absent-drive state has the OPPOSITE remedy, and its English must promise exactly what the
// Hungarian promises: the FACT, the CONSEQUENCE, the REMEDY, in that order (§8's retrieval-promise
// rule applied to the English side).
func TestAbsentDriveWarningFollowsLanguageAndKeepsThePromise(t *testing.T) {
html := renderBackupsPageLang(t, "en", "felhom-backup",
[]agentapi.DiskInfo{theVanishedDrive}, "/mnt/felhom-drives/mentes2")
en := enText(t, backupTargetAbsentKey)
if !strings.Contains(html, en) {
t.Fatalf("the English absent-drive copy never reached the page; want %q", en)
}
if strings.Contains(html, huText(t, backupTargetAbsentKey)) {
t.Error("the Hungarian absent-drive copy is still on the English page")
}
for _, want := range []struct{ frag, why string }{
{"cannot be reached", "the FACT — the drive cannot be reached"},
{"is not made", "the CONSEQUENCE — the full system backup does not happen"},
{"reconnect it", "the REMEDY — reconnect THAT drive"},
} {
if !strings.Contains(en, want.frag) {
t.Errorf("the English absent copy is missing %s (%q); got: %s", want.why, want.frag, en)
}
}
// The English must not accidentally offer a different drive either.
if strings.Contains(html, `id="backup-target-assign"`) {
t.Error("an offer control rendered on the English page while the configured target is absent")
}
}
// The degraded sentence is a PROMISE about protection. Its English must draw the same distinction
// the Hungarian draws — corrupted files yes, disk failure no. An English text that promised
// protection against a disk failure would be worse than no translation at all.
func TestEnglishDegradedWarningPromisesExactlyWhatTheHungarianPromises(t *testing.T) {
en := enText(t, backupTargetDegradedKey)
for _, want := range []string{"same disk as the system", "corrupted files", "not against a disk failure", "second drive"} {
if !strings.Contains(en, want) {
t.Errorf("the English degraded warning is missing %q; got: %s", want, en)
}
}
// Hungarian says „hibás fájlok ellen véd, lemezhiba ellen nem" — protection is CONDITIONAL. A
// bare "protects against a disk failure" would invert it, so pin the negation's presence.
if !strings.Contains(en, "but not against a disk failure") {
t.Errorf("the English warning does not NEGATE disk-failure protection; got: %s", en)
}
}
// The tier labels on the same page: the builder is language-free now, and the words come from the
// reader's bundle. Asserted through buildTierViews rather than the page so a box with no agent
// still exercises it.
func TestTierLabelsFollowLanguage(t *testing.T) {
tiers := []agentapi.TierBackupState{
{Target: "local", Primary: true, Storage: "present"},
{Target: "felhom-pbs", Storage: "present"},
}
b, err := i18n.Shared()
if err != nil {
t.Fatal(err)
}
for _, lang := range []string{"hu", "en"} {
v := &guestBackupView{}
msg := func(key string, a ...interface{}) string {
if len(a) == 0 {
return b.Msg(lang, key)
}
return b.Msgf(lang, key, a...)
}
buildTierViews(v, tiers, map[string]int64{"local": 86400, "felhom-pbs": 604800}, tvNow, msg)
if len(v.Tiers) != 2 {
t.Fatalf("[%s] want 2 tier rows, got %d", lang, len(v.Tiers))
}
wantLocal, wantPBS := b.Msgf(lang, "backup.tier.local", "local"), b.Msg(lang, "backup.tier.pbs")
if v.Tiers[0].Label != wantLocal {
t.Errorf("[%s] local tier label = %q, want %q", lang, v.Tiers[0].Label, wantLocal)
}
if v.Tiers[1].Label != wantPBS {
t.Errorf("[%s] PBS tier label = %q, want %q", lang, v.Tiers[1].Label, wantPBS)
}
}
// And the two languages must actually differ — otherwise the loop above passes on an
// untranslated bundle and proves nothing.
if b.Msg("hu", "backup.tier.pbs") == b.Msg("en", "backup.tier.pbs") {
t.Error("backup.tier.pbs is identical in both languages — untranslated")
}
}
@@ -6,6 +6,7 @@ import (
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
@@ -48,6 +49,28 @@ func absentHarness(t *testing.T, primary string, disks []agentapi.DiskInfo, regi
return s
}
// huText renders a bundle KEY into the Hungarian a household actually reads.
//
// v0.259.0 (R-598) turned the three backup-protection constants into bundle keys, so every
// assertion below would otherwise have quietly become an assertion about key SPELLING — the exact
// hollowing this repo has paid for before. Resolving through the real embedded bundle keeps them
// assertions about the WORDS, and fails loudly if a key is ever dropped from hu.json.
func huText(t *testing.T, key string) string {
t.Helper()
if key == "" {
return ""
}
b, err := i18n.Shared()
if err != nil {
t.Fatalf("i18n bundle: %v", err)
}
got := b.Msg("hu", key)
if got == key {
t.Fatalf("the bundle does not know %q — it would render the KEY onto the page", key)
}
return got
}
// theVanishedDrive is the E-2d shape: registered, still carrying its configured MountPath, user-data,
// but no longer claiming the backup target because its device is gone.
var theVanishedDrive = agentapi.DiskInfo{
@@ -73,7 +96,7 @@ func TestConfiguredButAbsentTargetDoesNotClaimTheSystemDisk(t *testing.T) {
t.Fatalf("TargetAbsent is false for target %q with no disk claiming it — the configured-but-gone "+
"state fell back into never-configured, which is the R-114 bug", st.TargetID)
}
msg := degradedMessageFor(st)
msg := huText(t, degradedMessageFor(st))
if strings.Contains(msg, "ugyanazon a lemezen") {
t.Errorf("the customer is told the backup is on the SYSTEM DISK, which is false — the target is "+
"%q, a drive that has vanished. Got: %s", st.TargetID, msg)
@@ -130,7 +153,7 @@ func TestNeverConfiguredStillSaysSystemDiskAndStillOffers(t *testing.T) {
if !st.Degraded {
t.Fatal("a backup on the system disk must still read degraded")
}
if msg := degradedMessageFor(st); !strings.Contains(msg, "ugyanazon a lemezen") {
if msg := huText(t, degradedMessageFor(st)); !strings.Contains(msg, "ugyanazon a lemezen") {
t.Errorf("the never-configured copy changed; got: %s", msg)
}
if st.OfferPath == "" {
@@ -164,7 +187,7 @@ func TestHealthyTargetIsNeitherDegradedNorAbsent(t *testing.T) {
if st.Degraded || st.TargetAbsent {
t.Fatalf("a healthy target reported degraded=%v absent=%v", st.Degraded, st.TargetAbsent)
}
if msg := degradedMessageFor(st); msg != "" {
if msg := huText(t, degradedMessageFor(st)); msg != "" {
t.Errorf("healthy produced copy %q — a working box must look normal", msg)
}
}
@@ -176,11 +199,12 @@ func TestHealthyTargetIsNeitherDegradedNorAbsent(t *testing.T) {
func TestAbsentCopyMatchesTheHubEmailWordForWord(t *testing.T) {
const hubCopy = "A rendszermentés meghajtója nem érhető el — amíg vissza nem " +
"csatlakoztatod, a teljes rendszermentés nem készül el."
if backupTargetAbsentText != hubCopy {
banner := huText(t, backupTargetAbsentKey)
if banner != hubCopy {
t.Errorf("the banner and the email now tell the customer different stories about one drive.\n"+
" banner: %s\n email : %s", backupTargetAbsentText, hubCopy)
" banner: %s\n email : %s", banner, hubCopy)
}
msg := degradedMessageFor(BackupTargetState{Known: true, Degraded: true, TargetAbsent: true})
msg := huText(t, degradedMessageFor(BackupTargetState{Known: true, Degraded: true, TargetAbsent: true}))
for _, want := range []struct{ frag, why string }{
{"nem érhető el", "the FACT — the drive cannot be reached"},
{"nem készül el", "the CONSEQUENCE — the full system backup does not happen"},
+37 -20
View File
@@ -159,20 +159,29 @@ func baseName(p string) string {
// HEALTHY RENDERS NOTHING. There is deliberately no "your backup is safe" banner: a working
// configuration must look normal, or every customer's dashboard grows a permanent notice and the
// warning stops meaning anything (E-2 Scenario E).
//
// R-598 — THESE ARE PROMISES ABOUT WHETHER THE CUSTOMER'S FILES ARE SAFE, and until v0.259.0 they
// were composed in Go as finished Hungarian sentences and handed to the page as DATA. That is the
// defect class R-573 and R-590 already cost: no template-parity fixture and no English-page test can
// see a Hungarian sentence arriving as a struct field. So the constants below are now KEYS, and
// degradedMessageFor returns the key; the one place that has a language resolves it.
//
// The Hungarian text moved to the bundle byte-for-byte (scripts/i18n_go_parity.py measures that
// against the frozen base capture), so a Hungarian household reads exactly the same words.
const (
backupTargetDegradedText = "A rendszermentés jelenleg ugyanazon a lemezen van, mint a rendszer — " +
"így hibás fájlok ellen véd, lemezhiba ellen nem. Csatlakoztass egy második meghajtót a teljes védelemhez."
backupTargetOfferText = "Ezt a meghajtót kijelölheted a rendszermentés helyéül — így egy lemezhiba " +
"után is vissza tudod állítani a rendszert."
backupTargetDegradedKey = "backup.target.degraded"
backupTargetOfferKey = "backup.target.offer"
// backupTargetAbsentText (R-114) is the CONFIGURED-BUT-GONE state. It is VERBATIM the hub's
// customerMessages["backup_target_absent"] (felhom.eu hub/internal/notify/templates.go:93) so the
// banner a customer reads on the page and the email they receive say exactly the same thing — a
// customer who is told two different stories about one drive trusts neither.
//
// DRIFT RISK, filed not fixed: this string now lives in two repos with nothing binding them. If
// one is reworded the other silently disagrees.
backupTargetAbsentText = "A rendszermentés meghajtója nem érhető el — amíg vissza nem " +
"csatlakoztatod, a teljes rendszermentés nem készül el."
// one is reworded the other silently disagrees. (v0.259.0: the HUNGARIAN side of the drift risk is
// unchanged — hu.json carries the same bytes the const carried. The ENGLISH side is new: the hub's
// English `mail.event.backup_target_absent` and this key must say the same thing too, and nothing
// binds those either. Same row, now twice as wide.)
backupTargetAbsentKey = "backup.target.absent"
)
// degradedMessageFor is the single decision point for "does the customer see anything?" — extracted
@@ -182,6 +191,10 @@ const (
// Returns "" for BOTH healthy and unknown. They are different states with the same rendering, and
// collapsing them here is deliberate: unknown means we could not ask, which is not evidence of
// degradation (the absence-read-as-a-value mistake R-88 Part 2 closed).
// It returns a bundle KEY, not a sentence (R-598). The decision — "does the customer see anything,
// and which fact is it?" — is language-free and stays here; the words are chosen by the caller that
// knows who is reading. Returning a key also means an untranslated state is a visible key on the
// page rather than a Hungarian sentence on an English one, which is the failure mode worth having.
func degradedMessageFor(st BackupTargetState) string {
if !st.Known || !st.Degraded {
return ""
@@ -189,9 +202,9 @@ func degradedMessageFor(st BackupTargetState) string {
if st.TargetAbsent {
// R-114: configured, drive gone. A different fact with a different remedy, so a different
// sentence — routed through here so there is still exactly one place that decides copy.
return backupTargetAbsentText
return backupTargetAbsentKey
}
return backupTargetDegradedText
return backupTargetDegradedKey
}
// ---- the render (R-112) ------------------------------------------------------------------------
@@ -220,15 +233,19 @@ type BackupTargetView struct {
// backupTargetView resolves the state and reduces it to what the page renders, or nil for the two
// states that render NOTHING — healthy and unknown. Returning nil rather than an empty struct means
// a template typo cannot accidentally decorate a working box.
func (s *Server) backupTargetView(ctx context.Context) *BackupTargetView {
//
// `lang` is the reader's, resolved once by the handler (s.langFor(r)) — the same shape
// buildDataPathCards and buildStorageBars already use. The KEY comes from degradedMessageFor; the
// WORDS are chosen here, and nowhere else.
func (s *Server) backupTargetView(ctx context.Context, lang string) *BackupTargetView {
st := s.resolveBackupTargetState(ctx)
msg := degradedMessageFor(st)
if msg == "" {
key := degradedMessageFor(st)
if key == "" {
return nil // healthy or unknown — a working configuration must look normal
}
v := &BackupTargetView{Message: msg}
v := &BackupTargetView{Message: s.msgLang(lang, key)}
if st.OfferPath != "" {
v.OfferPath, v.OfferLabel, v.OfferText = st.OfferPath, st.OfferLabel, backupTargetOfferText
v.OfferPath, v.OfferLabel, v.OfferText = st.OfferPath, st.OfferLabel, s.msgLang(lang, backupTargetOfferKey)
}
return v
}
@@ -244,11 +261,11 @@ func (s *Server) handleBackupTargetState(w http.ResponseWriter, r *http.Request)
out["target"] = st.TargetID
out["label"] = st.Label
if st.Degraded {
out["message"] = degradedMessageFor(st)
out["message"] = s.msg(r, degradedMessageFor(st))
if st.OfferPath != "" {
out["offer_path"] = st.OfferPath
out["offer_label"] = st.OfferLabel
out["offer_message"] = backupTargetOfferText
out["offer_message"] = s.msg(r, backupTargetOfferKey)
}
}
}
@@ -266,17 +283,17 @@ func (s *Server) handleBackupTargetAssign(w http.ResponseWriter, r *http.Request
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "backup.target.err.bad_request"), nil)
return
}
stable := strings.TrimSpace(req.Path)
if stable == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "hiányzó meghajtó", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "backup.target.err.missing_drive"), nil)
return
}
agent, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, "a host-ügynök nem elérhető", nil)
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.msg(r, "backup.target.err.agent_unreachable"), nil)
return
}
// The agent operates on the RAW host mount path, not our stable guest path — the same asymmetry
@@ -289,7 +306,7 @@ func (s *Server) handleBackupTargetAssign(w http.ResponseWriter, r *http.Request
res, err := agent.SetBackupTarget(r.Context(), raw)
if err != nil {
s.logger.Printf("[WARN] [web] backup-target assign %s (raw %s): %v", stable, raw, err)
writeDiskJSON(w, http.StatusBadGateway, false, "a mentési cél beállítása nem sikerült: "+s.errText(r, err), nil)
writeDiskJSON(w, http.StatusBadGateway, false, s.msg(r, "backup.target.err.assign_failed")+s.errText(r, err), nil)
return
}
// Record the INTENT only after the agent accepted, so our flag can never claim a target the agent
@@ -35,7 +35,7 @@ func TestDegradedStateKeepsReportingOnEveryVisit(t *testing.T) {
// The copy must carry FACT → CONSEQUENCE → REMEDY. A customer told only "your backup is on the same
// disk" cannot act; the sentence has to say what that costs them and what fixes it.
func TestDegradedCopyNamesConsequenceAndRemedy(t *testing.T) {
msg := degradedMessageFor(BackupTargetState{Known: true, Degraded: true})
msg := huText(t, degradedMessageFor(BackupTargetState{Known: true, Degraded: true}))
for _, want := range []struct{ frag, why string }{
{"ugyanazon a lemezen", "the FACT — it shares the disk with the system"},
{"lemezhiba ellen nem", "the CONSEQUENCE — it does not survive a disk failure"},
@@ -42,11 +42,11 @@ func renderBackupsPage(t *testing.T, primary string, disks []agentapi.DiskInfo,
func TestBackupsPageRendersTheAbsentDriveCopy(t *testing.T) {
html := renderBackupsPage(t, "felhom-backup", []agentapi.DiskInfo{theVanishedDrive}, "/mnt/felhom-drives/mentes2")
if !strings.Contains(html, backupTargetAbsentText) {
if !strings.Contains(html, huText(t, backupTargetAbsentKey)) {
t.Error("the absent-drive copy never reached the page — the customer is told nothing while " +
"their backup drive is missing (R-112: the state had no consumer at all)")
}
if strings.Contains(html, backupTargetDegradedText) {
if strings.Contains(html, huText(t, backupTargetDegradedKey)) {
t.Error("the page shows the SYSTEM-DISK copy for an absent configured drive (R-114)")
}
// Assert the CONTROL's markup, not the bare id: the page script always contains
@@ -68,10 +68,10 @@ func TestBackupsPageRendersTheOfferWhenNeverConfigured(t *testing.T) {
Name: "hdd1", MountPath: "/mnt/hdd1", GuestPath: "/mnt/felhom-drives/hdd1", Role: "user-data",
}}, "/mnt/felhom-drives/hdd1")
if !strings.Contains(html, backupTargetDegradedText) {
if !strings.Contains(html, huText(t, backupTargetDegradedKey)) {
t.Error("the degraded copy never reached the page")
}
if !strings.Contains(html, backupTargetOfferText) {
if !strings.Contains(html, huText(t, backupTargetOfferKey)) {
t.Error("the offer copy never reached the page")
}
if !strings.Contains(html, `id="backup-target-assign"`) {
@@ -116,9 +116,9 @@ func TestBackupsPageRendersNothingWhenAgentUnreachable(t *testing.T) {
func assertNoBackupTargetCopy(t *testing.T, html, why string) {
t.Helper()
for _, s := range []struct{ frag, name string }{
{backupTargetDegradedText, "the system-disk copy"},
{backupTargetAbsentText, "the absent-drive copy"},
{backupTargetOfferText, "the offer copy"},
{huText(t, backupTargetDegradedKey), "the system-disk copy"},
{huText(t, backupTargetAbsentKey), "the absent-drive copy"},
{huText(t, backupTargetOfferKey), "the offer copy"},
} {
if strings.Contains(html, s.frag) {
t.Errorf("%s rendered: %s", s.name, why)
@@ -135,11 +135,11 @@ func TestBackupTargetViewIsNilWhenNothingShouldRender(t *testing.T) {
healthy := absentHarness(t, "felhom-backup", []agentapi.DiskInfo{{
MountPath: "/mnt/mentes2", GuestPath: "/mnt/felhom-drives/mentes2", Role: "user-data", BackupTarget: true,
}}, "")
if v := healthy.backupTargetView(context.Background()); v != nil {
if v := healthy.backupTargetView(context.Background(), "hu"); v != nil {
t.Errorf("healthy returned a non-nil view %+v — nil is what makes the template render nothing", v)
}
unknown := testServer(t)
if v := unknown.backupTargetView(context.Background()); v != nil {
if v := unknown.backupTargetView(context.Background(), "hu"); v != nil {
t.Errorf("unknown returned a non-nil view %+v", v)
}
}
@@ -7,6 +7,7 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// R-517 — the whole-guest tile speaks from SUCCESSES per tier. The BIGNIGHT page read, after a local
@@ -18,6 +19,24 @@ var tvNow = time.Date(2026, 9, 14, 19, 15, 36, 0, time.UTC)
// RED-PROOF (run 2026-09-15, recorded in REPORT.md): with buildTierViews reading a tier's "success"
// from LastAttempt instead of LastSuccess, the shown backup lost its size and this failed at "the
// successful local backup is not the one shown: has=true size=0".
// huMsg is the Hungarian side of buildTierViews' message seam (v0.259.0, R-598). The tier labels
// used to be Hungarian literals inside the builder; they are bundle keys now, and this renders them
// exactly as a Hungarian household's request does — so the label assertions below still compare the
// WORDS, not a key.
func huMsg(t *testing.T) func(string, ...interface{}) string {
t.Helper()
return func(key string, a ...interface{}) string {
b, err := i18n.Shared()
if err != nil {
t.Fatalf("i18n bundle: %v", err)
}
if len(a) == 0 {
return b.Msg("hu", key)
}
return b.Msgf("hu", key, a...)
}
}
func TestBuildTierViews_BignightFailedPBSOnAbsentStorage(t *testing.T) {
v := &guestBackupView{}
tiers := []agentapi.TierBackupState{
@@ -27,7 +46,7 @@ func TestBuildTierViews_BignightFailedPBSOnAbsentStorage(t *testing.T) {
{Target: "felhom-pbs", Storage: "absent",
LastAttempt: &agentapi.TierAttempt{StartedAt: "2026-09-14T19:09:59Z", Success: false, Error: "storage 'felhom-pbs' does not exist"}},
}
buildTierViews(v, tiers, map[string]int64{"local": 86400, "felhom-pbs": 604800}, tvNow)
buildTierViews(v, tiers, map[string]int64{"local": 86400, "felhom-pbs": 604800}, tvNow, huMsg(t))
if v.Offsite {
t.Fatalf("remote tick shown without a PBS success: %+v", v.Tiers)
@@ -55,7 +74,7 @@ func TestBuildTierViews_PBSFailedUnderOldSuccess(t *testing.T) {
LastSuccess: &agentapi.BackupRecord{Success: true, SizeBytes: 20, StartedAt: "2026-08-20T01:00:00Z"},
LastAttempt: &agentapi.TierAttempt{StartedAt: "2026-09-14T02:00:00Z", Success: false}},
}
buildTierViews(v, tiers, map[string]int64{"local": 86400, "felhom-pbs": 604800}, tvNow)
buildTierViews(v, tiers, map[string]int64{"local": 86400, "felhom-pbs": 604800}, tvNow, huMsg(t))
pbs := v.Tiers[1]
if !pbs.HasSuccess || !pbs.FailedAfter || pbs.Current {
t.Fatalf("pbs row: want old success kept, failure shown under it, not current: %+v", pbs)
@@ -72,7 +91,7 @@ func TestBuildTierViews_SuccessFromStorageAfterRestart(t *testing.T) {
{Target: "local", Primary: true, Storage: "present", LastSuccessSource: "storage",
LastSuccess: &agentapi.BackupRecord{Success: true, StartedAt: "2026-09-14T19:03:23Z"}},
}
buildTierViews(v, tiers, map[string]int64{"local": 86400}, tvNow)
buildTierViews(v, tiers, map[string]int64{"local": 86400}, tvNow, huMsg(t))
if !v.HasBackup || v.Due || v.Tiers[0].SizeKnown {
t.Fatalf("after restart: want the local success shown, up to date, size unknown: %+v / due=%v", v.Tiers[0], v.Due)
}
+20 -15
View File
@@ -278,12 +278,17 @@ func (s *Server) handleClaimPage(w http.ResponseWriter, r *http.Request, errorMs
s.logger.Printf("[ERROR] [web] claim: cannot read the persisted claim state while rendering the claim page: %v", cerr)
hash = ""
if errorMsg == "" {
errorMsg = "A beállító állapot most nem olvasható — próbáld újra néhány perc múlva."
errorMsg = s.msg(r, "claim.msg.state_unreadable")
}
}
reset := s.authEnabled() // a set password means this is the reset flow, not first-claim
// `Title` USED TO BE HERE, carrying the Hungarian "A szerver beállítása", and R-596 named it as
// one of the strings an English household reads. It is not: claim.html is a STANDALONE page with
// its own <title> (already `claim.jelszo_visszaallitasa` / `claim.a_szerver_beallitasa`), and
// `.Title` is consumed only by layout.html, which this page never includes. So the field was dead
// — rendered by nothing, in either language. Deleted rather than translated: a translated dead
// field would read, for ever after, as evidence that this page's title is handled here.
data := map[string]interface{}{
"Title": "A szerver beállítása",
"CustomerName": s.cfg.Customer.Name,
"Domain": s.cfg.Customer.Domain,
"Version": s.version,
@@ -307,18 +312,18 @@ func (s *Server) handleClaimPage(w http.ResponseWriter, r *http.Request, errorMs
func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if !s.validClaimCSRF(r) {
s.handleClaimPage(w, r, "Érvénytelen űrlap — töltsd újra az oldalt.", "")
s.handleClaimPage(w, r, s.msg(r, "claim.msg.invalid_form"), "")
return
}
wasReset := s.authEnabled() // a password already set → this is a reset, not a first-claim
ip := clientIP(r)
if locked, _ := s.claimRateLocked(); locked {
s.handleClaimPage(w, r, "Túl sok próbálkozás — próbáld újra 15 perc múlva.", "")
s.handleClaimPage(w, r, s.msg(r, "claim.msg.too_many"), "")
return
}
if locked, _ := s.claimSourceLocked(ip); locked {
s.handleClaimPage(w, r, "Túl sok próbálkozás — próbáld újra 15 perc múlva.", "")
s.handleClaimPage(w, r, s.msg(r, "claim.msg.too_many"), "")
return
}
@@ -331,11 +336,11 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
// FAIL CLOSED: refuse the claim rather than validate against a possibly-superseded cache.
// NOT counted as a failed attempt — the customer typed nothing wrong.
s.logger.Printf("[ERROR] [web] claim: refusing the submission — the persisted claim state is unreadable: %v", cerr)
s.handleClaimPage(w, r, "A beállító állapot most nem olvasható — próbáld újra néhány perc múlva.", "")
s.handleClaimPage(w, r, s.msg(r, "claim.msg.state_unreadable"), "")
return
}
if hash == "" {
s.handleClaimPage(w, r, "Nincs aktív kód — kérj újat az alábbi gombbal.", "")
s.handleClaimPage(w, r, s.msg(r, "claim.msg.no_active_code"), "")
return
}
@@ -356,32 +361,32 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
if !valid {
if s.claimRegisterFailure(ip) {
s.reportClaimLockout(ip)
s.handleClaimPage(w, r, "Túl sok próbálkozás — próbáld újra 15 perc múlva.", "")
s.handleClaimPage(w, r, s.msg(r, "claim.msg.too_many"), "")
return
}
s.handleClaimPage(w, r, "Hibás vagy lejárt kód", "")
s.handleClaimPage(w, r, s.msg(r, "claim.msg.bad_code"), "")
return
}
// Password rules (min length, match).
if len(newPassword) < claimMinPassword {
s.handleClaimPage(w, r, fmt.Sprintf("A jelszónak legalább %d karakter hosszúnak kell lennie", claimMinPassword), "")
s.handleClaimPage(w, r, s.msg(r, "claim.msg.password_too_short", claimMinPassword), "")
return
}
if newPassword != confirm {
s.handleClaimPage(w, r, "A két jelszó nem egyezik", "")
s.handleClaimPage(w, r, s.msg(r, "claim.msg.password_mismatch"), "")
return
}
pwHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10)
if err != nil {
s.logger.Printf("[ERROR] [web] claim: hashing new password: %v", err)
s.handleClaimPage(w, r, "Belső hiba a jelszó mentésekor", "")
s.handleClaimPage(w, r, s.msg(r, "claim.msg.save_failed"), "")
return
}
if err := s.settings.SetPasswordHash(string(pwHash)); err != nil {
s.logger.Printf("[ERROR] [web] claim: saving password: %v", err)
s.handleClaimPage(w, r, "Belső hiba a jelszó mentésekor", "")
s.handleClaimPage(w, r, s.msg(r, "claim.msg.save_failed"), "")
return
}
// Consume the generation (single-use) + mark claimed (set-only). Order: consume BEFORE
@@ -441,11 +446,11 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleClaimRequestNewCode(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if !s.validClaimCSRF(r) {
s.handleClaimPage(w, r, "Érvénytelen űrlap — töltsd újra az oldalt.", "")
s.handleClaimPage(w, r, s.msg(r, "claim.msg.invalid_form"), "")
return
}
go s.requestHubResetCode() // fire-and-forget; the neutral response never reveals the outcome
s.handleClaimPage(w, r, "", "Ha az e-mail cím regisztrálva van, elküldtük a kódot.")
s.handleClaimPage(w, r, "", s.msg(r, "claim.msg.code_sent"))
}
// requestHubResetCode calls POST /api/v1/claim/reset-request with the box's own report key.
@@ -0,0 +1,248 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// R-596 — THE ONE SCREEN THAT STOPPED AN ENGLISH-SPEAKING HOUSEHOLD.
//
// The 2026-09-20 drill (felhom.eu audits/DRILL-first-hour-en-0258-2026-09-20.md) walked a fresh box
// as an English speaker. Every page was English except this one: the claim page's CHROME was English
// and its ANSWERS were Hungarian, because each answer was a Hungarian literal composed in Go and
// handed to the renderer as page DATA. A person who mistypes the code from their e-mail is told
// „Hibás vagy lejárt kód" and cannot tell a typo from a dead code — on the single screen between
// them and their machine.
//
// The defect class is `composed-sentence-into-page-data` (R-573, R-590, R-596, R-598): a template
// parity fixture cannot see it, because the template renders `{{.Error}}` correctly in both
// languages; only the VALUE is wrong. So these tests drive the real handlers and read the HTML.
// claimPage POSTs form to /claim (or GETs it when form is nil) with the language cookie set to lang,
// and returns the HTML the browser receives. The full CSRF pair is set exactly as the page does.
func claimPage(t *testing.T, s *Server, lang string, form url.Values) string {
t.Helper()
tok := s.claimCSRFToken()
var req *http.Request
if form == nil {
req = httptest.NewRequest(http.MethodGet, "/claim", nil)
} else {
form.Set(csrfFormField, tok)
req = httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: tok})
if lang != "" {
req.AddCookie(&http.Cookie{Name: langCookieName, Value: lang})
}
rr := httptest.NewRecorder()
if form == nil {
s.handleClaimPage(rr, req, "", "")
} else {
s.handleClaimSubmit(rr, req)
}
return rr.Body.String()
}
// want is one answer in both languages: the Hungarian that must be byte-identical to what the box
// said before v0.259.0, and the English an English-speaking household must get instead.
type claimAnswer struct {
what string
form url.Values
hu string
en string
}
func claimAnswers() []claimAnswer {
good := func(code, pw string) url.Values {
return url.Values{"code": {code}, "new_password": {pw}, "confirm_password": {pw}}
}
return []claimAnswer{
{
what: "a wrong code — the drill's own screen",
form: good("nem-ez-az", "correct-horse-battery"),
hu: "Hibás vagy lejárt kód",
en: "Wrong or expired code",
},
{
what: "a password under the minimum",
form: good("alma-korte-szilva", "short"),
hu: "A jelszónak legalább 12 karakter hosszúnak kell lennie",
en: "The password must be at least 12 characters long",
},
{
what: "the two passwords disagree",
form: url.Values{"code": {"alma-korte-szilva"}, "new_password": {"correct-horse-battery"}, "confirm_password": {"correct-horse-batteryX"}},
hu: "A két jelszó nem egyezik",
en: "The two passwords do not match",
},
}
}
// S1 — the claim page answers in the reader's language, and the Hungarian is unchanged.
func TestClaimAnswersFollowTheReadersLanguage(t *testing.T) {
for _, c := range claimAnswers() {
t.Run(c.what, func(t *testing.T) {
s, _, _ := claimTestServer(t)
if html := claimPage(t, s, "hu", c.form); !strings.Contains(html, c.hu) {
t.Errorf("Hungarian answer CHANGED for %s.\n want the page to contain: %q", c.what, c.hu)
}
s2, _, _ := claimTestServer(t)
html := claimPage(t, s2, "en", c.form)
if !strings.Contains(html, c.en) {
t.Errorf("an English household is not told %q for %s — this is the screen the drill "+
"stopped on", c.en, c.what)
}
// The decisive assertion: the Hungarian sentence must be GONE from the English page.
// Asserting only that the English is present would pass a page carrying both.
if strings.Contains(html, c.hu) {
t.Errorf("the HUNGARIAN answer %q is still on the ENGLISH page for %s", c.hu, c.what)
}
})
}
}
// The lockout answer needs five failures, so it gets its own case — and the counter is asserted
// separately from the text, because the lockout is language-blind by design (§8).
func TestClaimLockoutAnswersInEnglishAndCountsTheSame(t *testing.T) {
const (
hu = "Túl sok próbálkozás — próbáld újra 15 perc múlva."
en = "Too many attempts — try again in 15 minutes."
)
for _, tc := range []struct{ lang, want, notWant string }{
{"hu", hu, en},
{"en", en, hu},
} {
s, _, _ := claimTestServer(t)
var html string
for i := 0; i < claimMaxAttempts; i++ {
html = claimPage(t, s, tc.lang, url.Values{
"code": {"nem-ez-az"}, "new_password": {"correct-horse-battery"},
"confirm_password": {"correct-horse-battery"},
})
}
if !strings.Contains(html, tc.want) {
t.Errorf("[%s] the lockout answer is missing %q", tc.lang, tc.want)
}
if strings.Contains(html, tc.notWant) {
t.Errorf("[%s] the lockout answer still carries the other language's text %q", tc.lang, tc.notWant)
}
// The LOCKOUT ITSELF, not its wording: exactly the same number of wrong codes locks the
// page in either language. A guesser must not get a longer run by switching the cookie.
//
// (The first version of this assertion named 192.0.2.1 as "a source that never submitted"
// and failed: httptest.NewRequest gives every request RemoteAddr 192.0.2.1:1234, so that IS
// the submitting source. Kept as a different address, because the point stands — the
// lockout must be per-source, not global-only.)
if locked, _ := s.claimSourceLocked("198.51.100.7"); locked {
t.Errorf("[%s] a source that never submitted is locked", tc.lang)
}
if locked, _ := s.claimSourceLocked("192.0.2.1"); !locked {
t.Errorf("[%s] the submitting source is not locked after %d wrong codes", tc.lang, claimMaxAttempts)
}
if locked, _ := s.claimRateLocked(); !locked {
t.Errorf("[%s] %d wrong codes did not trip the global lockout", tc.lang, claimMaxAttempts)
}
}
}
// An unclaimed box has no household session and may have no cookie either — the very first screen a
// stranger meets. Its language must come from `customer.language` in controller.yaml, which is what
// the operator set when creating the customer (slice 3 Part B).
//
// §3 of the closing task asked this to be CONFIRMED before any work: the chain is
// langFor → settings.GetLanguage → configLanguage ← main.go's SetConfigLanguage(cfg.Customer.Language).
// This test is the pin, so the chain cannot be broken without something failing.
func TestAnonymousClaimPageFollowsTheCustomerLanguageWithNoCookie(t *testing.T) {
s, _, sett := claimTestServer(t)
sett.SetConfigLanguage("en")
if got := s.langFor(httptest.NewRequest(http.MethodGet, "/claim", nil)); got != "en" {
t.Fatalf("a cookieless anonymous request resolved to %q, want \"en\" — the operator's "+
"creation-time language never reaches the first screen a stranger sees", got)
}
html := claimPage(t, s, "", url.Values{
"code": {"nem-ez-az"}, "new_password": {"correct-horse-battery"}, "confirm_password": {"correct-horse-battery"},
})
if !strings.Contains(html, "Wrong or expired code") {
t.Error("an English customer with no cookie yet is answered in Hungarian on their first screen")
}
}
// A code made of ENGLISH words must be accepted exactly as a Hungarian one (S3's box half). The box
// compares a bcrypt hash of whatever the hub minted, so this is a guard against anyone "helping" by
// validating the shape of a code.
func TestClaimAcceptsAnEnglishWordCode(t *testing.T) {
s, _, sett := claimTestServer(t)
sett.SetConfigLanguage("en")
const englishCode = "abacus-abdomen-ratio-wreath"
if err := setClaimCodeTo(t, sett, englishCode); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
tok := s.claimCSRFToken()
form := url.Values{"code": {englishCode}, "new_password": {"correct-horse-battery"},
"confirm_password": {"correct-horse-battery"}, csrfFormField: {tok}}
req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: tok})
s.handleClaimSubmit(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("an English-word code was not accepted: got %d, want 302\nbody: %s", rr.Code, rr.Body.String())
}
if !sett.GetClaimed() {
t.Error("the box did not record itself as claimed after an English-word code")
}
}
// Nothing in this package may answer the claim page with a literal again. The bundle is the only
// legal source, so every key the handlers name must exist in BOTH languages — an absent English key
// falls back to Hungarian silently, which is precisely the bug being closed.
func TestClaimMessageKeysExistInBothLanguages(t *testing.T) {
b, err := i18n.Shared()
if err != nil {
t.Fatal(err)
}
keys := []string{
"claim.msg.state_unreadable", "claim.msg.invalid_form", "claim.msg.too_many",
"claim.msg.no_active_code", "claim.msg.bad_code", "claim.msg.password_too_short",
"claim.msg.password_mismatch", "claim.msg.save_failed", "claim.msg.code_sent",
}
for _, k := range keys {
hu, en := b.Msg("hu", k), b.Msg("en", k)
if hu == k {
t.Errorf("hu.json does not know %q", k)
}
if en == k {
t.Errorf("en.json does not know %q", k)
}
if hu == en {
t.Errorf("%q is the same string in both languages (%q) — an untranslated key", k, hu)
}
}
}
// setClaimCodeTo installs a specific plaintext code at a fresh generation (the hub's job in
// production). Extracted so the English-code test cannot accidentally test the fixture's code.
func setClaimCodeTo(t *testing.T, sett claimCodeSetter, code string) error {
t.Helper()
h, err := bcrypt.GenerateFromPassword([]byte(code), 10)
if err != nil {
return err
}
return sett.SetClaimCode(string(h), 9, time.Now().UTC().Format(time.RFC3339))
}
type claimCodeSetter interface {
SetClaimCode(hash string, generation int, issuedAt string) error
}
+2 -2
View File
@@ -1001,12 +1001,12 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
data["StorageBars"] = s.buildStorageBars(s.langFor(r))
// Whole-guest backup view (agent-sourced, read-only) for the "Rendszermentés" section.
data["GuestBackup"] = s.loadGuestBackup(r.Context())
data["GuestBackup"] = s.loadGuestBackup(r.Context(), s.langFor(r))
// R-112: the backup-target banner finally has a consumer. nil in the healthy and unknown states,
// so the template renders nothing at all — no badge, no reassurance. This is the seam whose
// absence made E-2's degraded banner and offer invisible to every customer.
data["BackupTarget"] = s.backupTargetView(r.Context())
data["BackupTarget"] = s.backupTargetView(r.Context(), s.langFor(r))
// Customer-configurable backup window (v0.168.0): effective start + derived leg/gate times.
s.backupWindowData(data)