Files
felhom-controller/controller/internal/web/lang_visitor_test.go
T
admin 48f3336956
gates / gates (push) Successful in 23s
v0.254.0 — the saved notes follow the language, and the switch becomes a globe (R-557 slice 2 release C; SLICE 2 CLOSED)
The notes a background run SAVES — last night's backup line, the last error, the proof
result, the restore outcome — are written in the BOX's language at the moment they are
written. A household that switches sees the previous run's note in the old language until
the next run rewrites it: the operator's §16 option 1, stated rather than hidden.
EndRestoreOp no longer receives a Hungarian literal from anywhere.

The language switch is a globe. Two text links wrapped in the sidebar footer and asked the
reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user
already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is
<details>/<summary> — a menu with no script, drawn inline because the icon sprite lives
only in layout.html and the visitor pages have their own shell.

Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only
felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household
can never inherit a language a previous visitor picked in the same browser. POST /lang is
CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the
language of the page the victim's own browser shows them — and safeBackPath refuses
//evil.example as well as https://, because "starts with /" alone is not the test. §16 taken:
a successful claim carries the cookie into the household's setting.

TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change
shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the
three pages that must not change.

I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the
settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex
is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings
lock. Fixed by resolving the language before the callback, and guarded by a test that names
the file and line in a second instead of hanging for 25 minutes.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 14:19:31 +02:00

376 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// Localisation slice 2 release C (R-557), scenarios S2–S4.
//
// The sign-in, claim and recovery pages are met by someone who has not signed in. They have no
// setting to read and must not be able to write the household's — a box's sign-in page is reachable
// by anyone who can reach the box. So their choice lives in their own browser, and the household's
// setting is untouched until the one moment an anonymous visitor BECOMES the household: a successful
// claim (§16).
func langReq(method, path string, cookies ...*http.Cookie) *http.Request {
r := httptest.NewRequest(method, path, nil)
for _, c := range cookies {
r.AddCookie(c)
}
return r
}
func langCookie(v string) *http.Cookie { return &http.Cookie{Name: langCookieName, Value: v} }
// newTestSession mints a real session on a Server built by testServer, whose session map the
// production constructor would have made. A REAL session (not a made-up cookie value) because
// langFor asks isValidSession, and a test that handed it an invalid one would be testing the
// no-session path while claiming to test the session path.
func newTestSession(s *Server) string {
s.sessionsMu.Lock()
if s.sessions == nil {
s.sessions = map[string]*session{}
}
s.sessionsMu.Unlock()
return s.createSession()
}
// TestLangForPrecedence — the order is fixed and each step exists for a different reader. A table,
// because the interesting failures are the CROSSINGS: a signed-in household inheriting a visitor's
// cookie, or a visitor's `?lang=` leaking into the setting.
//
// RED-PROOF (REPORT): drop the `!s.hasSession(r)` guard in langFor → the "session wins over cookie"
// rows fail, which is the row that protects the household.
func TestLangForPrecedence(t *testing.T) {
cases := []struct {
name string
query string
session bool
cookie string
saved string
want string
}{
{name: "nothing at all → Hungarian", saved: "hu", want: "hu"},
{name: "the household's saved setting", saved: "en", want: "en"},
{name: "?lang= overrides the setting (testing door)", query: "en", saved: "hu", want: "en"},
{name: "?lang= overrides a session too", query: "hu", session: true, saved: "en", want: "hu"},
{name: "?lang= with an unsupported value is ignored", query: "de", saved: "hu", want: "hu"},
{name: "no session → the visitor's cookie wins", cookie: "en", saved: "hu", want: "en"},
{name: "no session, unsupported cookie → the setting", cookie: "de", saved: "hu", want: "hu"},
{name: "no session, no cookie → the setting", saved: "en", want: "en"},
// The one that matters: a household that signed in must never read a language a previous
// visitor picked in the same browser.
{name: "SESSION → the household's setting, cookie NOT read", session: true, cookie: "en", saved: "hu", want: "hu"},
{name: "SESSION → the household's setting, the other way round", session: true, cookie: "hu", saved: "en", want: "en"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := testServer(t)
if err := s.settings.SetLanguage(tc.saved); err != nil {
t.Fatal(err)
}
path := "/launcher"
if tc.query != "" {
path += "?lang=" + tc.query
}
var cookies []*http.Cookie
if tc.cookie != "" {
cookies = append(cookies, langCookie(tc.cookie))
}
if tc.session {
cookies = append(cookies, &http.Cookie{Name: sessionCookieName, Value: newTestSession(s)})
}
if got := s.langFor(langReq(http.MethodGet, path, cookies...)); got != tc.want {
t.Errorf("langFor = %q, want %q", got, tc.want)
}
// Whatever happened, reading a page never writes the household's setting.
if got := s.settings.GetLanguage(); got != tc.saved {
t.Errorf("the household's saved language changed to %q — reading a page must never write it", got)
}
})
}
}
// TestLangCookieHandler — POST /lang. The whole of what it may do, and the whole of what it must
// refuse.
func TestLangCookieHandler(t *testing.T) {
t.Run("a supported language sets the cookie and returns to the page", func(t *testing.T) {
s := testServer(t)
if err := s.settings.SetLanguage("hu"); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/lang", strings.NewReader("lang=en&back=%2Flogin"))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
s.langCookieHandler(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status %d, want 303", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/login" {
t.Errorf("Location %q, want /login", loc)
}
var found *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == langCookieName {
found = c
}
}
if found == nil {
t.Fatal("no felhom_lang cookie was set")
}
if found.Value != "en" || !found.HttpOnly || found.SameSite != http.SameSiteLaxMode || found.Path != "/" {
t.Errorf("cookie attributes wrong: %+v", found)
}
// THE POINT: the household's setting is untouched by an anonymous request.
if got := s.settings.GetLanguage(); got != "hu" {
t.Errorf("an anonymous POST changed the household's language to %q", got)
}
})
t.Run("an unsupported language is refused and sets nothing", func(t *testing.T) {
s := testServer(t)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/lang", strings.NewReader("lang=de&back=%2Flogin"))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
s.langCookieHandler(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("status %d, want 400 — silently writing Hungarian would hide the bug", w.Code)
}
if len(w.Result().Cookies()) != 0 {
t.Errorf("a cookie was set for an unsupported language: %v", w.Result().Cookies())
}
})
t.Run("back may only be a same-origin path", func(t *testing.T) {
for _, tc := range []struct{ back, want string }{
{"/login", "/login"},
{"/settings/security", "/settings/security"},
{"", "/"},
{"https://evil.example/x", "/"},
{"//evil.example/x", "/"}, // protocol-relative: a browser reads this as another origin
{"http://evil.example", "/"},
{"/x\r\nSet-Cookie: a=b", "/"}, // header injection through the redirect
{`/x\..\y`, "/"},
} {
s := testServer(t)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/lang", strings.NewReader("lang=en&back="+urlQueryEscape(tc.back)))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
s.langCookieHandler(w, r)
if loc := w.Header().Get("Location"); loc != tc.want {
t.Errorf("back=%q → Location %q, want %q", tc.back, loc, tc.want)
}
}
})
}
// TestGlobeOnAnonymousShells — the three pages a visitor meets carry the globe, it posts to /lang with
// NO CSRF field, and `<html lang>` follows the visitor's cookie rather than the household's setting.
func TestGlobeOnAnonymousShells(t *testing.T) {
cases := map[string]i18nCase{}
for _, c := range i18nCases() {
cases[c.name] = c
}
for _, p := range i18nDirectPages {
if p.tmpl == "launcher_shared" || p.tmpl == "launcher_share_password" || p.tmpl == "catchall" {
continue // deliberately NO globe — a share visitor is a stranger (R-577), and the
// not-found page has nothing to do
}
c := cases[p.caseName]
t.Run(p.tmpl, func(t *testing.T) {
s := i18nTestServer(t)
if err := s.settings.SetLanguage("hu"); err != nil {
t.Fatal(err)
}
var b strings.Builder
r := langReq(http.MethodGet, "/i18n-fixture", langCookie("en"))
if err := s.executeTemplateLang(&b, r, p.tmpl, c.data()); err != nil {
t.Fatal(err)
}
got := b.String()
if !strings.Contains(got, `class="shell-lang"`) {
t.Error("the page carries no language globe")
}
if !strings.Contains(got, `action="/lang"`) {
t.Error("the globe does not post to /lang — a visitor must not write the household's setting")
}
// Scoped to the GLOBE block. The claim page carries its own pre-auth CSRF field for the
// claim form itself, so a page-wide search would convict the wrong form — and did, on the
// first run.
gi := strings.Index(got, `class="shell-lang"`)
ge := strings.Index(got[gi:], "</details></div>")
if gi < 0 || ge < 0 {
t.Fatal("could not isolate the globe block")
}
globe := got[gi : gi+ge]
if strings.Contains(globe, `name="_csrf"`) {
t.Error("the anonymous globe carries a CSRF field; there is no session to mint one from")
}
if !strings.Contains(globe, `action="/lang"`) {
t.Error("the globe block does not post to /lang")
}
// The cookie decided the language, not the household's `hu`.
if !strings.Contains(got, `<html lang="en"`) {
t.Error("the visitor's cookie did not decide the language")
}
if got := s.settings.GetLanguage(); got != "hu" {
t.Errorf("rendering the page changed the household's language to %q", got)
}
})
}
}
// TestGuestSharePagesHaveNoGlobe — the pages a STRANGER meets. Their language is not the household's
// to lend and not theirs to keep here; that is R-577, an operator decision about what the share
// feature promises. Pinned so it stays a decision rather than an oversight.
func TestGuestSharePagesHaveNoGlobe(t *testing.T) {
cases := map[string]i18nCase{}
for _, c := range i18nCases() {
cases[c.name] = c
}
for _, name := range []string{"launcher_shared_apps", "launcher_share_password", "catchall_unknown"} {
c, ok := cases[name]
if !ok {
t.Fatalf("no parity case %q — this test no longer covers what it names", name)
}
s := i18nTestServer(t)
var b strings.Builder
if err := s.executeTemplateLang(&b, langReq(http.MethodGet, "/x"), c.tmpl, c.data()); err != nil {
t.Fatal(err)
}
if strings.Contains(b.String(), "lang-globe") {
t.Errorf("%s carries a language globe — R-577 is the decision that would put one there", name)
}
}
}
// TestFooterDoesNotWrap — the sidebar footer holds version, globe and sign-out in ONE flex row, in
// that order. The old switch was two text links that wrapped at the sidebar's width; the globe is one
// icon, and this pins that the three items stay siblings of a single flex container rather than
// drifting into a second line's worth of markup.
//
// It reads the MARKUP, which is what a test without a browser can read; the visual half is the
// operator's click-through.
func TestFooterDoesNotWrap(t *testing.T) {
s := i18nTestServer(t)
cases := map[string]i18nCase{}
for _, c := range i18nCases() {
cases[c.name] = c
}
c := cases["launcher_full"]
got := renderI18nCase(t, s, "hu", c)
i := strings.Index(got, `class="sidebar-footer"`)
if i < 0 {
t.Fatal("no sidebar footer in the rendered page")
}
foot := got[i:]
if j := strings.Index(foot, "</div>\n </div>"); j > 0 {
foot = foot[:j]
}
for _, want := range []string{`class="version"`, `class="lang-globe"`, `class="logout-link"`} {
if !strings.Contains(foot, want) {
t.Errorf("the footer is missing %s", want)
}
}
// Order: version, then globe, then sign-out.
v, g, l := strings.Index(foot, `class="version"`), strings.Index(foot, `class="lang-globe"`), strings.Index(foot, `class="logout-link"`)
if !(v < g && g < l) {
t.Errorf("footer order is version=%d globe=%d logout=%d, want version < globe < logout", v, g, l)
}
// And the OLD two-link switch is gone — otherwise both could be present and the test would pass.
if strings.Contains(got, "lang-switch-btn") {
t.Error("the old two-text-link switch is still rendered beside the globe")
}
}
// TestClaimCarriesLanguage — §16. A visitor who switched the claim page to English and then claimed
// the box gets an English dashboard. Only on SUCCESS, and only here: this is the one moment an
// anonymous visitor becomes the household.
func TestClaimCarriesLanguage(t *testing.T) {
t.Run("a successful claim carries the cookie into the setting", func(t *testing.T) {
s := testServer(t)
if err := s.settings.SetLanguage("hu"); err != nil {
t.Fatal(err)
}
// The carry is one block in handleClaimSubmit, after every gate. Exercised here through the
// same two calls it makes, because a full claim needs a live code the fixture cannot mint.
r := langReq(http.MethodPost, "/claim", langCookie("en"))
if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) {
if err := s.settings.SetLanguage(c.Value); err != nil {
t.Fatal(err)
}
}
if got := s.settings.GetLanguage(); got != "en" {
t.Errorf("the household's language is %q, want en", got)
}
})
t.Run("an unsupported cookie is ignored", func(t *testing.T) {
s := testServer(t)
if err := s.settings.SetLanguage("hu"); err != nil {
t.Fatal(err)
}
r := langReq(http.MethodPost, "/claim", langCookie("de"))
if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) {
t.Fatal("an unsupported cookie was accepted")
}
if got := s.settings.GetLanguage(); got != "hu" {
t.Errorf("the household's language changed to %q", got)
}
})
// The SOURCE half: the carry is inside handleClaimSubmit, AFTER the failure paths return, so a
// failed claim cannot reach it. A behaviour test cannot show that without a live claim code; the
// position in the function is what makes it true, and the position is what this reads.
t.Run("the carry sits after every refusal", func(t *testing.T) {
src := mustReadSource(t, "claim.go")
fn := src[strings.Index(src, "func (s *Server) handleClaimSubmit"):]
carry := strings.Index(fn, "claim: household language set to")
clear := strings.Index(fn, "s.claimClearFailures(ip)")
if carry < 0 || clear < 0 {
t.Fatal("the claim carry or the success marker moved — this test no longer reads what it names")
}
if carry < clear {
t.Error("the language carry runs BEFORE the failures are cleared — a failed claim could reach it")
}
})
}
// mustReadSource reads a file in this package, for the few tests whose claim is about WHERE code sits
// rather than what it returns.
func mustReadSource(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// noteServer builds a Server whose saved-note helpers work — a note is written in the BOX's language
// (release C), so a Server with no settings would render every note as its key. Hungarian, because
// these tests assert the sentence a Hungarian household reads: the parity half.
func noteServer(t *testing.T) *Server {
t.Helper()
s := i18nTestServer(t)
if err := s.settings.SetLanguage("hu"); err != nil {
t.Fatal(err)
}
return s
}
// noteHU is the Hungarian text of a saved-note key, for a test that used to compare against a Go
// constant. Same claim, now measured against the bundle instead of restated beside it.
func noteHU(t *testing.T, key string) string {
t.Helper()
return noteServer(t).note(key)
}