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 }