Files
felhom-controller/controller/internal/web/lang_visitor_test.go
T
admin fd28d70bff v0.308.0: controller batch 2026-10-12 — clear-address warning (R-922), secret sweep over every page (R-255), read_failed (R-266), no NVMe heat band (R-333), share globe (R-577), Hungarian dates and numbers (R-562), lost-drive option pinned (R-521)
Operator rulings 2026-10-12, 09 §3 decisions 196, 204-207, 223, 227. MinAgent 0.131.0 unchanged.
R-255's sweep found one leak, fixed here: an optional-config secret_input was rendered into its
settings page; it is now revealed on demand. Hungarian parity fixtures re-captured once for R-562.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 15:13:59 +02:00

456 lines
18 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")
}
// v0.255.0 — WHERE the globe sits, and whether the page can be styled at all.
//
// Both halves were real defects, seen on a browser: the stylesheet was requested with no
// `?v=`, so a browser that had it cached from before v0.254.0 kept serving a file with no
// .lang-globe rules in it and the globe rendered as a bare, unstyled <details> — a stray
// triangle and two plain words. And even styled, it was pinned to the corner of the
// VIEWPORT, outside the card, which reads as part of the browser rather than the page.
if !strings.Contains(got, `href="/static/style.css?v=`) {
t.Error("the stylesheet is requested without a version, so a cached copy from before " +
"the globe existed keeps being served and the globe renders unstyled")
}
if strings.Contains(got, `href="/static/style.css?v="`) {
t.Error("the cache-buster is EMPTY — that busts a cache once and never again")
}
cardAt := strings.Index(got, `class="login-card"`)
globeAt := strings.Index(got, `class="shell-lang"`)
if cardAt < 0 || globeAt < 0 {
t.Fatal("card or globe missing from the page")
}
if globeAt < cardAt {
t.Error("the globe is rendered BEFORE the card, so it floats outside it")
}
// …and at the END of the card: after the footer where one exists, last in the card where
// none does (the recovery page has no footer paragraph).
if foot := strings.Index(got, `class="login-footer"`); foot >= 0 && globeAt < foot {
t.Error("the globe is above the footer; it belongs under it")
}
if got := s.settings.GetLanguage(); got != "hu" {
t.Errorf("rendering the page changed the household's language to %q", got)
}
})
}
}
// TestGuestSharePagesHaveGlobe — R-577 (operator ruling 2026-10-12, `09` §3 decision 207): the pages a
// STRANGER meets through a share link carry the language globe, and it is the VISITOR's: it posts to /lang
// (their own display cookie, no CSRF, never the household's setting). The catch-all page is not a share
// page and stays without one.
//
// RED-PROOF (REPORT.md): remove the globe line from launcher_shared.html → the launcher_shared_apps case
// fails with "carries no language globe".
func TestGuestSharePagesHaveGlobe(t *testing.T) {
cases := map[string]i18nCase{}
for _, c := range i18nCases() {
cases[c.name] = c
}
for _, tc := range []struct {
name string
globe bool
}{{"launcher_shared_apps", true}, {"launcher_share_password", true}, {"catchall_unknown", false}} {
c, ok := cases[tc.name]
if !ok {
t.Fatalf("no parity case %q — this test no longer covers what it names", tc.name)
}
s := i18nTestServer(t)
var b strings.Builder
if err := s.executeTemplateLang(&b, langReq(http.MethodGet, "/s/tok"), c.tmpl, c.data()); err != nil {
t.Fatal(err)
}
got := b.String()
if !tc.globe {
if strings.Contains(got, "lang-globe") {
t.Errorf("%s carries a language globe — it is not a share page", tc.name)
}
continue
}
if !strings.Contains(got, "lang-globe") {
t.Errorf("%s carries no language globe (R-577)", tc.name)
continue
}
if !strings.Contains(got, `action="/lang"`) || strings.Contains(got, `action="/settings/language"`) {
t.Errorf("%s: the globe does not post to the visitor's own switch (/lang)", tc.name)
}
if !strings.Contains(got, `name="back" value="/s/tok"`) {
t.Errorf("%s: the globe does not bring the visitor back to the share page", tc.name)
}
}
}
// R-577: a share visitor's own cookie decides the page's language, over the household's setting, on the
// real route (/s/<token> through the full mux). A Hungarian household; the visitor picked English.
func TestGuestSharePage_HonoursVisitorCookie(t *testing.T) {
s := shareTestServer(t)
if err := s.settings.SetLauncherShareToken(testShareToken); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
cookie, wantLang string
}{{"", "hu"}, {"en", "en"}, {"hu", "hu"}} {
req := httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil)
if tc.cookie != "" {
req.AddCookie(langCookie(tc.cookie))
}
rr := httptest.NewRecorder()
s.fullMux().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /s/<token> = %d", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `<html lang="`+tc.wantLang+`"`) {
t.Errorf("cookie %q: the page is not in %q", tc.cookie, tc.wantLang)
}
if !strings.Contains(body, "lang-globe") {
t.Errorf("cookie %q: the share page carries no globe", tc.cookie)
}
}
if got := s.settings.GetLanguage(); got != "hu" {
t.Errorf("a share visitor's page changed the household's language to %q", got)
}
}
// 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)
}