8fb2f9ef9d
gates / gates (push) Successful in 23s
Two defects in v0.254.0's globe, both plain on a browser and neither catchable by anything that existed — every test read the MARKUP, and the fault was in which CSS file the browser fetched. The shells requested /static/style.css with NO ?v=, while layout.html has carried one since v0.166.0. A browser holding a copy from before v0.254.0 kept serving CSS with no .lang-globe rules, so the globe came out as a bare unstyled <details> — a stray triangle and two plain words at the edge of the window. It was FIVE shells, not the three named: both guest share pages have the same fault for any CSS change, and their visitor is the likeliest of all to be holding an old copy. And .Version was missing from three of those five data maps, which is exactly how the next one would be forgotten — it is now filled at the one choke point every shell renders through. The globe also floated outside the card, pinned to the corner of the VIEWPORT, reading as part of the browser rather than the page. It now sits inside the card, centred under the footer, with the menu opening upward via the shared rule — so the dashboard and the shells cannot drift. AND A THIRD, caught by a test that already existed: putting the version on the guest share pages would have printed the controller build onto a page a stranger with a capability URL can open. TestShareGuest_HeadersTilesNoAdminChrome refused it. Those two now take an opaque per-build tag — same cache-busting, no disclosure. The fill is ONE function shared with the parity harness, because a fixture rendered through a different data path is a picture of a page nobody serves, which the previous release got wrong twice. 15 shell fixtures re-captured; 91 identical, every dashboard page among them. 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
403 lines
16 KiB
Go
403 lines
16 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)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|