48f3336956
gates / gates (push) Successful in 23s
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
376 lines
15 KiB
Go
376 lines
15 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")
|
||
}
|
||
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)
|
||
}
|