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 `` 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:], "") 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, ` — 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) } }) } } // 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, "\n "); 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) }