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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user