fd28d70bff
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
456 lines
18 KiB
Go
456 lines
18 KiB
Go
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)
|
||
}
|