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