Files
admin 0fe04bb6d5
gates / gates (push) Successful in 28s
v0.259.0 — the claim page and the backup warnings answer in the reader's language (R-596, R-598)
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
2026-09-21 07:45:56 +02:00

249 lines
9.9 KiB
Go

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
}