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
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:
@@ -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
|
||||
`<title>` (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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user