0fe04bb6d5
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
249 lines
9.9 KiB
Go
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
|
|
}
|