From 0fe04bb6d57f4427e0a81d788bb5fc7dc9d1358d Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 21 Sep 2026 07:45:56 +0200 Subject: [PATCH] =?UTF-8?q?v0.259.0=20=E2=80=94=20the=20claim=20page=20and?= =?UTF-8?q?=20the=20backup=20warnings=20answer=20in=20the=20reader's=20lan?= =?UTF-8?q?guage=20(R-596,=20R-598)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 50 ++++ REPORT.md | 123 ++++----- controller/internal/i18n/locales/en.json | 25 +- controller/internal/i18n/locales/hu.json | 25 +- controller/internal/web/backup_handlers.go | 29 +- .../internal/web/backup_language_test.go | 165 ++++++++++++ .../internal/web/backup_target_absent_test.go | 36 ++- .../internal/web/backup_target_offer.go | 57 ++-- .../internal/web/backup_target_offer_test.go | 2 +- .../internal/web/backup_target_render_test.go | 18 +- .../internal/web/backup_tier_view_test.go | 25 +- controller/internal/web/claim.go | 35 +-- .../internal/web/claim_language_test.go | 248 ++++++++++++++++++ controller/internal/web/handlers.go | 4 +- controller/scripts/i18n_go_keys.json | 37 ++- 15 files changed, 736 insertions(+), 143 deletions(-) create mode 100644 controller/internal/web/backup_language_test.go create mode 100644 controller/internal/web/claim_language_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 34078a1..9d5360a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,53 @@ +## v0.259.0 — the claim page and the backup warnings answer in the reader's language (2026-09-21, R-596/R-598) + +**MinAgent: 0.131.0** (unchanged). The Hungarian pages are byte-identical; the Go-parity gate +measures every new key against the frozen base capture and was red-proofed on a single added full +stop. + +The 2026-09-20 English drill ended one screen short. This release is that screen. + +- **R-596 (P1) — the claim page's ANSWERS.** The page was English and its answers were Hungarian: + a mistyped code was met with „Hibás vagy lejárt kód", and an English-speaking household could not + tell a typo from a dead code on the one screen between them and their machine. **Fourteen call + sites carrying nine distinct messages** now go through `s.msg(r, "claim.msg.*")`, so they follow + the request's language exactly as the template does. + - **`data["Title"]` was DEAD and is deleted, not translated.** R-596 named it as one of the + Hungarian strings on that page. It is not: `claim.html` is a standalone page with its own + `` (already bundle-backed), and `.Title` is read only by `layout.html`, which this page + never includes. Translating it would have left a permanent false signal that the page's title is + decided in the handler. + - The anonymous, cookie-less claim page resolves its language from `customer.language` in + `controller.yaml` — `langFor` → `settings.GetLanguage` → `configLanguage`. That chain was + verified at source before any edit and is now PINNED by + `TestAnonymousClaimPageFollowsTheCustomerLanguageWithNoCookie`; it was previously an unpinned + assumption. + - The lockout is unchanged and language-blind: the test asserts the COUNTER, not the text, in both + languages, so nobody gets a longer run by switching a cookie. +- **R-598 — the backup page's PROTECTION WARNINGS**, which are promises about whether the customer's + files are safe. `backupTargetDegradedText` / `OfferText` / `AbsentText` are now bundle KEYS; + `degradedMessageFor` returns the key (the decision stays language-free and in one place) and the + caller that knows the reader resolves the words. `buildTierViews`, `backupTargetLabel` and + `loadGuestBackup` take the reader's language the same way `buildDataPathCards` already did. + - The English says exactly what the Hungarian says, and a test pins the NEGATION — + "protects against corrupted files, **but not against a disk failure**". An English sentence that + promised disk-failure protection would be worse than no translation. + - **An apostrophe cost a render.** The first English absent-drive sentence read "The system + backup's drive…" and never matched on the page: `html/template` escapes `'` to `'`. Caught + by the render test, not by review. All 23 new English values are now free of `' " < > &`. +- **The existing copy-contract tests were kept, not weakened.** Six assertions that compared + Hungarian WORDS would have silently become assertions about key spelling; each now resolves the key + through the real embedded bundle (`huText`), so they still convict on a reworded sentence and now + also convict if a key is ever dropped from `hu.json`. +- **This is the third and fourth instance of one defect class**, after R-573 and R-590: *a composed + sentence handed to a renderer as page data*. No template-parity fixture and no + `TestI18nEnglishPages` can see it, because the template renders the field faithfully — only the + value is wrong. Both new test files drive the real handlers and read the HTML, which is the only + instrument that can. + +Red-proofs run this session: the wrong-code literal restored (the English test convicted on both +halves — the English absent AND the Hungarian present); one added full stop in `hu.json` (the +go-parity gate named both sides). + ## v0.258.0 — the last four Hungarian things an English household met (2026-09-20, R-589/R-590/R-572/R-573) **MinAgent: 0.131.0** (unchanged). The Hungarian pages are byte-identical — the parity fixtures and diff --git a/REPORT.md b/REPORT.md index defa667..3be1d95 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,83 +1,66 @@ -# REPORT — v0.258.0: the last four Hungarian things, and the walk that judged them (R-561 slice 6) +# REPORT — controller v0.259.0: the claim page and the backup warnings in the reader's language -2026-09-20 · `main` at the v0.258.0 commit · image `gitea.dooplex.hu/admin/felhom-controller:0.258.0` -· live on demo-hp guest 9201 and on the drill box built from scratch the same day. MinAgent 0.131.0, -unchanged. Hungarian byte-identical. +**R-596 (P1), R-598.** Base `f6909492cc6e` → v0.259.0. MinAgent 0.131.0 unchanged. -Architecture read first and named: `felhom.eu/documentation/architecture/10-localisation.md` §1 (the -Hungarian must not move), §4 (fallback), §5 (voice per language), §10.6b (this slice). +## Claims in the task that turned out wrong + +1. **"Sixteen Hungarian literals reach that page."** Fifteen literal sites reach it, carrying **ten** + distinct messages (four are repeats). Of the fifteen, **one is dead**: `data["Title"]` is read only + by `layout.html`, and `claim.html` is standalone with its own bundle-backed `<title>`. So + **fourteen live sites, nine messages** were converted and the dead one was deleted. +2. **"`backup_handlers.go` (12 Hungarian literals)."** Nine are code; the other three are Hungarian + inside COMMENTS (`"Mentés most"`, `"Rendszermentés"`, a label quoted in a struct doc). The offer + file's ten is right. +3. **"the recovery code (10 words — find its caller)" in the hub.** The hub does not mint it. + **felhom-agent** does, in `internal/escrow`, from the **EFF large wordlist** — so the recovery code + **has always been English**, ten words, ≈129 bits. R-597's recovery-code leg needed no work and + this repo does not own that secret. No row was added for it: inventing a second definition here is + how two sources of truth start. +4. **"the mail says 'three words'."** No claim mail states a count; they say `Setup code: %s`. The + only count wording in the product is the **bind page's** passphrase hint/placeholder ("five + words" / „öt szó"). The English pair is now count-free; the Hungarian is untouched. +5. **§16's phone-safe filter** ("no two words differing by one letter within the first six") was + measured before adoption: it removes **5270 of 7772** words, 12.92 → 11.29 bits/word. **Not + adopted** — see the hub REPORT for the three reasons and what replaced it. +6. **Line numbers** in the task were accurate. **"29 633 words"** is right to the line (29 634 lines, + 29 609 after dedup). **"`customer.language` reaches the anonymous claim page"** is TRUE and is now + pinned by a test rather than assumed. +7. **"the box checks a hash and needs no change" (for R-597)** is TRUE — verified and pinned by + `TestClaimAcceptsAnEnglishWordCode`. ## What shipped -Four rows, all of them Go-side — which is why neither the template parity fixtures nor -`TestI18nEnglishPages` could see them, and why **slice 5's LIVE proof is what found them**. +- `internal/web/claim.go` — 14 sites → `s.msg(r, "claim.msg.*")`; the dead `Title` deleted with the + reason recorded in place. +- `internal/web/backup_target_offer.go` — three copy constants → bundle keys; `degradedMessageFor` + returns the KEY (decision language-free, in one place); `backupTargetView(ctx, lang)`. +- `internal/web/backup_handlers.go` — `buildTierViews`/`backupTargetLabel`/`loadGuestBackup` take the + reader's language, the `buildDataPathCards` shape. +- 23 new keys in both bundles; all 23 listed in `scripts/i18n_go_keys.json`. -| row | what | how | -|---|---|---| -| **R-589** | the update badge („Naprakész" + its tooltip) and the abandoned-lifecycle badge | bundle-backed forms in `localeFuncs`, reusing `compareInstalledToTemplate` and `EffectiveLifecycle` so **the decision cannot drift between languages — only the words do**; the badge's `Class` is asserted equal across languages for the same stack | -| **R-590** | the data-folder card's backup sentence — **a promise about the customer's files** | `consequenceFor` takes its lookup as a parameter; the test asserts the CONSEQUENCE in both languages, and that the two languages do not produce the same string | -| **R-573** | the agent-channel and endpoint-drift banners | keyed by the checker's own CLASSIFICATION, with the composed sentence kept as a **fail-open** fallback: an unmapped reason renders it verbatim rather than a blank banner or a raw key | -| **R-572** | **not what the row said** | the row claimed a template renders „vasárnap" on an English page. Measured: **no template and no Go file called `pruneLabel` or `nextPruneLabel`.** Dead func-map entries returning Hungarian — **deleted**, not translated | +## Evidence -`lifecycleBadge` was not in any row. It is the same builder one function from `updateBadge`, and -leaving a known identical defect there would have been a choice. - -## Why deleting was the right direction for R-572, and how that was proven - -Translating dead code would have added machinery with no reader and a test pinning a fiction. -Deletion is the **fail-loud** direction: `html/template` refuses to parse a template naming an -undefined function, and `loadTemplates` parses every template once per language at startup. Proven -rather than asserted — a template naming the removed function makes the parity test panic with -`function "pruneLabel" not defined`. `fmtDuration`, named in the same row, produces no Hungarian at -all ("< 1s", "5s", "2m 3s") and was left alone. - -## Red-proofs — five, each seen to fail and then revert - -| what was broken | what convicted | +| Check | Result | |---|---| -| the `updateBadge` override removed from `localeFuncs` | English read „Naprakész" | -| `MessageKey` dropped from `SetAgentChannelAlert` | English read the Hungarian sentence | -| the folder sentence hard-coded back to Hungarian | the English arm convicted on three assertions | -| one Hungarian byte changed in a badge key | `TestLocaleFuncsHungarianBundleMatchesFuncMap` named both sides | -| a template naming a deleted helper | startup panic in the parity test | +| `go build ./... && go vet ./... && go test ./...` | green | +| `controller_gates.py --fast` (17 gates) | all OK | +| `scripts/i18n_go_parity.py` | OK — 718 keys, byte-for-byte against the frozen base | +| `scripts/i18n_missing_gate.py` | English missing **0** (ceiling 0); Hungarian formal 18 (ceiling 18) | +| `scripts/test_gate_decoys.py` | 23/23 | -**The fourth says something about the gate, not the code.** `i18n_go_parity.py` did NOT convict that -changed byte, because `localeFuncs` keys sit under `_preexisting` and are deliberately not measured -against the base capture. The citation to the test is what carries them — so the test had to be made -real, and it was extended to compare `hu.json` against the frozen funcmap forms word for word. +**Red-proofs, both seen failing:** +- One added full stop on `claim.msg.bad_code` in `hu.json` → go-parity named both sides. +- The wrong-code Hungarian literal restored in `claim.go` → the English test convicted twice (the + English sentence absent AND the Hungarian sentence present on the English page). -## Proven live, twice +## Two things the tests caught that review did not -- **demo-hp (0.258.0):** the English app page shows "Up to date" with an English tooltip while the - Hungarian page still says „Naprakész"; the folder sentence is English on one and unchanged on the - other; the free-space line reads "47.9 GB free" / „47.9 GB szabad". After the fixes, a scan of the - English app pages and the Apps list finds **zero** Felhom-authored Hungarian — the only hit is the - language picker naming itself „Magyar", which is correct. -- **A box installed from scratch that day**, walked by an English speaker: the update badge and the - time helpers were English there too, on hardware that had never run an older controller. +- **An apostrophe never renders.** `"The system backup's drive…"` is escaped to `'` by + `html/template` and no `strings.Contains` on the page ever matched it. Reworded; all 23 English + values are now free of `' " < > &`. +- **"please" is not this product's voice.** The i18n gate convicted two of my English sentences for + pleading. Rewritten to plain second person. -## What the walk then found, which this release does not fix +## Open -The walk is `felhom.eu/documentation/audits/DRILL-first-hour-en-0258-2026-09-20.md`. Its verdict is -**not yet ready for an English-speaking tester**, and the blocker is in this repo: - -- **R-596 (P1)** — `internal/web/claim.go` carries **sixteen** raw Hungarian literals, every one a - message the claim page shows. The page's chrome is English; its answers are not. Same shape as - R-573, one screen earlier in the journey, on the one screen between a household and their box. -- **R-598** — `backup_handlers.go` (12) and `backup_target_offer.go` (10): the Backup page's two - protection warnings and its two target names. - -Both are the *composed-sentence-into-page-data* shape. That shape now has three instances on file -(R-573, R-596, R-598) and is worth naming as a class the next converter looks for. - -## Needs the operator - -**Raise the fleet floor to 0.258.0.** Nothing breaks if it waits — the four fixes simply do not reach -the other boxes. The drill box already ran 0.258.0, because the golden baked today is 0.258.0 and a -golden above the floor is served normally (`managed floor SERVED … floor 0.257.0 … golden 0.258.0`). - -## Teardown - -Nothing installed or removed on demo-hp; guest 9201 restarted onto the new image, which is the -deploy. The drill VM and its hub customer are torn down in three layers — see the drill document. -Every staged credential was shredded from both hosts and both guests. +Nothing from this release. The live proof and the floor are in the felhom.eu report. diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index f831e1b..51ed2ed 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2307,5 +2307,28 @@ "alert.agent_channel.unknown": "The storage agent cannot be reached.", "alert.endpoint_drift": "The storage agent's address in the settings is out of date.", "badge.update.behind.days.one": " — %d day ago", - "badge.update.behind.days.other": " — %d days ago" + "badge.update.behind.days.other": " — %d days ago", + "claim.msg.state_unreadable": "The setup state cannot be read right now — try again in a few minutes.", + "claim.msg.invalid_form": "Invalid form — reload the page.", + "claim.msg.too_many": "Too many attempts — try again in 15 minutes.", + "claim.msg.no_active_code": "No active code — request a new one with the button below.", + "claim.msg.bad_code": "Wrong or expired code", + "claim.msg.password_too_short": "The password must be at least %d characters long", + "claim.msg.password_mismatch": "The two passwords do not match", + "claim.msg.save_failed": "Internal error while saving the password", + "claim.msg.code_sent": "If the e-mail address is registered, we have sent the code.", + "backup.target.degraded": "The system backup is currently on the same disk as the system — so it protects against corrupted files, but not against a disk failure. Attach a second drive for full protection.", + "backup.target.offer": "You can choose this drive as the place for the system backup — then you can restore the system even after a disk failure.", + "backup.target.absent": "The drive for the system backup cannot be reached — until you reconnect it, the full system backup is not made.", + "backup.target.err.bad_request": "invalid request", + "backup.target.err.missing_drive": "missing drive", + "backup.target.err.agent_unreachable": "the host agent cannot be reached", + "backup.target.err.assign_failed": "setting the backup target failed: ", + "backup.tier.pbs": "Backup server – separate hardware (PBS)", + "backup.tier.local": "Local storage (%s)", + "backup.tier.local_plain": "Local storage", + "backup.guest.agent_unconfigured": "The host agent is not configured on this machine.", + "backup.guest.agent_unreachable": "The host agent cannot be reached at the moment.", + "backup.guest.err.unavailable": "the system backup is not available on this machine", + "backup.guest.err.in_progress": "a backup is already running" } diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index a521072..5cb1646 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2295,5 +2295,28 @@ "alert.agent_channel.timeout": "A tárolókezelő ügynök nem válaszol.", "alert.agent_channel.unknown": "A tárolókezelő ügynök nem elérhető.", "alert.endpoint_drift": "A tárolókezelő ügynök címe elavult a beállításokban.", - "badge.update.behind.days": " — %d napja" + "badge.update.behind.days": " — %d napja", + "claim.msg.state_unreadable": "A beállító állapot most nem olvasható — próbáld újra néhány perc múlva.", + "claim.msg.invalid_form": "Érvénytelen űrlap — töltsd újra az oldalt.", + "claim.msg.too_many": "Túl sok próbálkozás — próbáld újra 15 perc múlva.", + "claim.msg.no_active_code": "Nincs aktív kód — kérj újat az alábbi gombbal.", + "claim.msg.bad_code": "Hibás vagy lejárt kód", + "claim.msg.password_too_short": "A jelszónak legalább %d karakter hosszúnak kell lennie", + "claim.msg.password_mismatch": "A két jelszó nem egyezik", + "claim.msg.save_failed": "Belső hiba a jelszó mentésekor", + "claim.msg.code_sent": "Ha az e-mail cím regisztrálva van, elküldtük a kódot.", + "backup.target.degraded": "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.", + "backup.target.offer": "Ezt a meghajtót kijelölheted a rendszermentés helyéül — így egy lemezhiba után is vissza tudod állítani a rendszert.", + "backup.target.absent": "A rendszermentés meghajtója nem érhető el — amíg vissza nem csatlakoztatod, a teljes rendszermentés nem készül el.", + "backup.target.err.bad_request": "érvénytelen kérés", + "backup.target.err.missing_drive": "hiányzó meghajtó", + "backup.target.err.agent_unreachable": "a host-ügynök nem elérhető", + "backup.target.err.assign_failed": "a mentési cél beállítása nem sikerült: ", + "backup.tier.pbs": "Biztonsági szerver – külön hardver (PBS)", + "backup.tier.local": "Helyi tároló (%s)", + "backup.tier.local_plain": "Helyi tároló", + "backup.guest.agent_unconfigured": "A host-ügynök nincs konfigurálva ezen a gépen.", + "backup.guest.agent_unreachable": "A host-ügynök jelenleg nem elérhető.", + "backup.guest.err.unavailable": "a rendszermentés nem érhető el ezen a gépen", + "backup.guest.err.in_progress": "mentés már folyamatban van" } diff --git a/controller/internal/web/backup_handlers.go b/controller/internal/web/backup_handlers.go index f27820f..2c7d148 100644 --- a/controller/internal/web/backup_handlers.go +++ b/controller/internal/web/backup_handlers.go @@ -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) diff --git a/controller/internal/web/backup_language_test.go b/controller/internal/web/backup_language_test.go new file mode 100644 index 0000000..cb77529 --- /dev/null +++ b/controller/internal/web/backup_language_test.go @@ -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") + } +} diff --git a/controller/internal/web/backup_target_absent_test.go b/controller/internal/web/backup_target_absent_test.go index 9a0f64f..3237fb7 100644 --- a/controller/internal/web/backup_target_absent_test.go +++ b/controller/internal/web/backup_target_absent_test.go @@ -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"}, diff --git a/controller/internal/web/backup_target_offer.go b/controller/internal/web/backup_target_offer.go index 14ead12..2db02bd 100644 --- a/controller/internal/web/backup_target_offer.go +++ b/controller/internal/web/backup_target_offer.go @@ -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 diff --git a/controller/internal/web/backup_target_offer_test.go b/controller/internal/web/backup_target_offer_test.go index 0d2c626..418534d 100644 --- a/controller/internal/web/backup_target_offer_test.go +++ b/controller/internal/web/backup_target_offer_test.go @@ -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"}, diff --git a/controller/internal/web/backup_target_render_test.go b/controller/internal/web/backup_target_render_test.go index 96f8e42..189e6e1 100644 --- a/controller/internal/web/backup_target_render_test.go +++ b/controller/internal/web/backup_target_render_test.go @@ -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) } } diff --git a/controller/internal/web/backup_tier_view_test.go b/controller/internal/web/backup_tier_view_test.go index 200bc78..4a28ce5 100644 --- a/controller/internal/web/backup_tier_view_test.go +++ b/controller/internal/web/backup_tier_view_test.go @@ -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) } diff --git a/controller/internal/web/claim.go b/controller/internal/web/claim.go index 4af2f40..c0ac4a2 100644 --- a/controller/internal/web/claim.go +++ b/controller/internal/web/claim.go @@ -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. diff --git a/controller/internal/web/claim_language_test.go b/controller/internal/web/claim_language_test.go new file mode 100644 index 0000000..1f5dab4 --- /dev/null +++ b/controller/internal/web/claim_language_test.go @@ -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 +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 60252c0..b27cbe1 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -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) diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index 25b59eb..e3eb4a2 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -967,5 +967,40 @@ "datapath.consequence.excluded": "Ez a mappa átmeneti, és nem készül róla biztonsági mentés.", "datapath.consequence.kept": "Itt tárolódnak a fájljaid. Biztonsági mentés készül róla.", "datapath.free_space": "%.1f GB szabad", - "alert.endpoint_drift": "A tárolókezelő ügynök címe elavult a beállításokban." + "alert.endpoint_drift": "A tárolókezelő ügynök címe elavult a beállításokban.", + "claim.msg.state_unreadable": "A beállító állapot most nem olvasható — próbáld újra néhány perc múlva.", + "claim.msg.invalid_form": "Érvénytelen űrlap — töltsd újra az oldalt.", + "claim.msg.too_many": "Túl sok próbálkozás — próbáld újra 15 perc múlva.", + "claim.msg.no_active_code": "Nincs aktív kód — kérj újat az alábbi gombbal.", + "claim.msg.bad_code": "Hibás vagy lejárt kód", + "claim.msg.password_too_short": "A jelszónak legalább %d karakter hosszúnak kell lennie", + "claim.msg.password_mismatch": "A két jelszó nem egyezik", + "claim.msg.save_failed": "Belső hiba a jelszó mentésekor", + "claim.msg.code_sent": "Ha az e-mail cím regisztrálva van, elküldtük a kódot.", + "backup.target.degraded": [ + "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." + ], + "backup.target.offer": [ + "Ezt a meghajtót kijelölheted a rendszermentés helyéül — így egy lemezhiba ", + "után is vissza tudod állítani a rendszert." + ], + "backup.target.absent": [ + "A rendszermentés meghajtója nem érhető el — amíg vissza nem ", + "csatlakoztatod, a teljes rendszermentés nem készül el." + ], + "backup.target.err.bad_request": "érvénytelen kérés", + "backup.target.err.missing_drive": "hiányzó meghajtó", + "backup.target.err.agent_unreachable": "a host-ügynök nem elérhető", + "backup.target.err.assign_failed": "a mentési cél beállítása nem sikerült: ", + "backup.tier.pbs": "Biztonsági szerver – külön hardver (PBS)", + "backup.tier.local": [ + "Helyi tároló (", + ")" + ], + "backup.tier.local_plain": "Helyi tároló", + "backup.guest.agent_unconfigured": "A host-ügynök nincs konfigurálva ezen a gépen.", + "backup.guest.agent_unreachable": "A host-ügynök jelenleg nem elérhető.", + "backup.guest.err.unavailable": "a rendszermentés nem érhető el ezen a gépen", + "backup.guest.err.in_progress": "mentés már folyamatban van" }