Files
felhom-controller/controller/internal/web/i18n_wiring_test.go
T
admin 48f3336956
gates / gates (push) Successful in 23s
v0.254.0 — the saved notes follow the language, and the switch becomes a globe (R-557 slice 2 release C; SLICE 2 CLOSED)
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
2026-09-18 14:19:31 +02:00

558 lines
24 KiB
Go

package web
import (
"fmt"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// ── i18n (v0.247.0) — wiring, keys and context safety ─────────────────────────────────────────────
var i18nScriptRe = regexp.MustCompile(`(?s)<script\b[^>]*>(.*?)</script>`)
var i18nMarkerRe = regexp.MustCompile(`\{\{\s*T\s+"([A-Za-z0-9_.\-]+)"\s*\}\}`)
func templateSources(t *testing.T) map[string]string {
t.Helper()
names, err := fs.Glob(templateFS, "templates/*.html")
if err != nil || len(names) == 0 {
t.Fatalf("no templates: %v", err)
}
out := map[string]string{}
for _, n := range names {
b, err := templateFS.ReadFile(n)
if err != nil {
t.Fatal(err)
}
out[n] = string(b)
}
return out
}
// Every key a template or the Go side names exists in Hungarian. (The loader refuses an undefined
// template key too; this test names the key instead of a parse error, and covers the Go-side keys the
// loader never sees.)
func TestBundleKeysUsedExistInHungarian(t *testing.T) {
b, err := i18n.Shared()
if err != nil {
t.Fatal(err)
}
used := 0
for name, src := range templateSources(t) {
for _, m := range i18nMarkerRe.FindAllStringSubmatch(src, -1) {
used++
if !b.Has(i18n.Default, m[1]) {
t.Errorf("%s: marker key %q is not in hu.json", name, m[1])
}
}
}
if used < 200 {
t.Fatalf("only %d markers found — the scan is not reading the converted templates", used)
}
goKeys := []string{"page.title.launcher", "page.title.backups", "func.time.now", "func.time.minutes_ago",
"func.time.hours_ago", "func.time.yesterday", "func.time.days_ago", "func.time.today_at", "func.time.tomorrow_at"}
for _, st := range []string{"pending", "restoring", "done", "failed", "skipped"} {
goKeys = append(goKeys, "func.restore_status."+st)
}
for _, st := range allContainerStates() {
goKeys = append(goKeys, stateLabelKey(st))
}
for _, k := range goKeys {
if !b.Has(i18n.Default, k) {
t.Errorf("Go-side key %q is not in hu.json", k)
}
}
}
func allContainerStates() []stacks.ContainerState {
return []stacks.ContainerState{stacks.StateRunning, stacks.StateStarting, stacks.StateDeploying, stacks.StateUnhealthy,
stacks.StateDegraded, stacks.StateStopped, stacks.StateExited, stacks.StateRestarting, stacks.StateNotDeployed,
stacks.StatePaused, stacks.ContainerState("something-new")}
}
// Expansion is textual, so a translation lands in the source exactly where the Hungarian was. Inside a
// JS string literal a bare quote or backslash ends or corrupts the string; inside a double-quoted
// attribute a `"` ends the attribute. Neither is caught by html/template, which sees the expanded text as
// the author's own source. So: a value may carry a quote character only where the Hungarian carries the
// same one (the author already made it safe there).
func TestI18nJSContextValuesAreSafe(t *testing.T) {
b, err := i18n.Shared()
if err != nil {
t.Fatal(err)
}
checked := 0
check := func(where, key string, forbidden string) {
hu, _, _ := b.Text(i18n.Default, key)
for _, lang := range i18n.Supported {
for _, form := range []string{key, key + ".one", key + ".other"} {
v, fellBack, ok := b.Text(lang, form)
if !ok || fellBack {
continue
}
checked++
for _, c := range forbidden {
if strings.ContainsRune(v, c) && !strings.ContainsRune(hu, c) {
t.Errorf("%s: %s %q contains %q, which breaks its context", where, lang, form, c)
}
}
}
}
}
for name, src := range templateSources(t) {
for _, sm := range i18nScriptRe.FindAllStringSubmatch(src, -1) {
for _, m := range i18nMarkerRe.FindAllStringSubmatch(sm[1], -1) {
check(name+" <script>", m[1], "'\"\\\n")
}
}
for _, am := range regexp.MustCompile(`=\s*"((?:[^"{]|\{\{[^}]*\}\})*)"`).FindAllStringSubmatch(src, -1) {
for _, m := range i18nMarkerRe.FindAllStringSubmatch(am[1], -1) {
check(name+" attribute", m[1], "\"")
}
}
}
if checked < 50 {
t.Fatalf("only %d context-sensitive values checked — the scan is not finding the script markers", checked)
}
}
// The Hungarian bundle carries the same words the Hungarian template funcs return — so English is a
// translation of what a household actually reads, and the bundle can become the funcs' single source
// later without a byte moving.
func TestLocaleFuncsHungarianBundleMatchesFuncMap(t *testing.T) {
s := i18nTestServer(t)
b := s.i18n
fm := s.templateFuncMap()
stateLabel := fm["stateLabel"].(func(stacks.ContainerState) string)
for _, st := range allContainerStates() {
if got, want := b.Msg("hu", stateLabelKey(st)), stateLabel(st); got != want {
t.Errorf("state %q: hu.json %q, funcmap %q", st, got, want)
}
}
statusText := fm["statusText"].(func(string) string)
for _, st := range []string{"pending", "restoring", "done", "failed", "skipped"} {
if got, want := b.Msg("hu", "func.restore_status."+st), statusText(st); got != want {
t.Errorf("restore status %q: hu.json %q, funcmap %q", st, got, want)
}
}
timeAgo := fm["timeAgo"].(func(time.Time) string)
now := time.Now()
for d, key := range map[time.Duration]string{5 * time.Minute: "func.time.minutes_ago", 3 * time.Hour: "func.time.hours_ago", 100 * time.Hour: "func.time.days_ago"} {
n := map[string]int{"func.time.minutes_ago": 5, "func.time.hours_ago": 3, "func.time.days_ago": 4}[key]
if got, want := fmt.Sprintf(b.Msg("hu", key), n), timeAgo(now.Add(-d)); got != want {
t.Errorf("%s: hu.json gives %q, funcmap %q", key, got, want)
}
}
if got, want := b.Msg("hu", "func.time.now"), timeAgo(now); got != want {
t.Errorf("now: %q vs %q", got, want)
}
if got, want := b.Msg("hu", "func.time.yesterday"), timeAgo(now.Add(-30*time.Hour)); got != want {
t.Errorf("yesterday: %q vs %q", got, want)
}
// infraMeta (slice 1): the Hungarian bundle carries the same curated words as inframeta.go.
for name, m := range infraMetaMap {
if got := b.Msg("hu", "func.infra."+name+".description"); got != m.Description {
t.Errorf("infra %s description: hu.json %q, inframeta.go %q", name, got, m.Description)
}
if k := "func.infra." + name + ".display"; b.Has("hu", k) && b.Msg("hu", k) != m.DisplayName {
t.Errorf("infra %s display: hu.json %q, inframeta.go %q", name, b.Msg("hu", k), m.DisplayName)
}
}
// And the English funcs really are English.
en := s.localeFuncs("en")
if got := en["timeAgo"].(func(time.Time) string)(now.Add(-time.Minute - time.Second)); got != "1 minute ago" {
t.Errorf("en timeAgo(1m) = %q", got)
}
if got := en["stateLabel"].(func(stacks.ContainerState) string)(stacks.StateRunning); got != "Running" {
t.Errorf("en stateLabel(running) = %q", got)
}
}
func postForm(t *testing.T, s *Server, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
s.ServeHTTP(rec, req)
return rec
}
// THE CONSEQUENCE, through the real handler and router: a Hungarian household sees no switch and no
// English; the ?lang= door shows the switch; saving English makes the real launcher English with its
// title; an unsupported value is refused and changes nothing; switching back hides the switch again.
//
// RED-PROOF (REPORT.md): delete `s.addLanguageData(data, r, lang)` from executeTemplate → the English
// assertions fail (the page stays Hungarian and the switch never appears).
func TestLanguageSwitch_EndToEnd(t *testing.T) {
s := newDashboardServer(t, time.Time{})
body := func(path string) string {
rec := getPage(t, s, path)
if rec.Code != 200 {
t.Fatalf("GET %s = %d", path, rec.Code)
}
return rec.Body.String()
}
// v0.250.0: the switch is offered to everyone — a Hungarian household with no ?lang= sees the form.
// RED-PROOF: put back the „show only when not Hungarian or ?lang=" condition in addLanguageData.
hu := body("/launcher")
if !strings.Contains(hu, `action="/settings/language"`) || !strings.Contains(hu, `name="lang" value="en"`) {
t.Error("a Hungarian household with no ?lang= must see the language switch (form and the English choice)")
}
if !strings.Contains(hu, "<h2>Indítópult</h2>") || !strings.Contains(hu, `<html lang="hu"`) {
t.Error("default launcher is not Hungarian")
}
door := body("/launcher?lang=en")
if !strings.Contains(door, `action="/settings/language"`) || !strings.Contains(door, "<h2>Launcher</h2>") {
t.Error("?lang=en must render English and show the switch")
}
if s.settings.GetLanguage() != "hu" {
t.Error("?lang= is a per-request override and must never persist")
}
rec := postForm(t, s, "/settings/language", url.Values{"lang": {"en"}, "back": {"/launcher?lang=en"}})
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher" {
t.Fatalf("POST en = %d Location %q, want 302 /launcher (the ?lang= must be dropped)", rec.Code, rec.Header().Get("Location"))
}
if s.settings.GetLanguage() != "en" {
t.Fatalf("language not saved: %q", s.settings.GetLanguage())
}
en := body("/launcher")
for _, want := range []string{"<h2>Launcher</h2>", "<title>Launcher — Felhom.eu</title>", `<html lang="en"`, `action="/settings/language"`, ">Dashboard</a>"} {
if !strings.Contains(en, want) {
t.Errorf("saved English: launcher lacks %q", want)
}
}
if strings.Contains(en, "<h2>Indítópult</h2>") {
t.Error("saved English: launcher heading still Hungarian")
}
rec = postForm(t, s, "/settings/language", url.Values{"lang": {"de"}})
if rec.Code != http.StatusBadRequest || s.settings.GetLanguage() != "en" {
t.Errorf("unsupported language: code %d, language %q — must be 400 and unchanged", rec.Code, s.settings.GetLanguage())
}
postForm(t, s, "/settings/language", url.Values{"lang": {"hu"}, "back": {"//evil.example/"}})
if s.settings.GetLanguage() != "hu" {
t.Fatal("switch back to hu not saved")
}
if back := body("/launcher"); !strings.Contains(back, `action="/settings/language"`) || !strings.Contains(back, "<h2>Indítópult</h2>") {
t.Error("back on Hungarian: the page must be Hungarian, with the switch still offered")
}
}
// huTemplateSource returns an embedded template AS THE HUNGARIAN SET PARSES IT — markers expanded. Tests
// that assert on a template's Hungarian wording read this, never the raw file: since slice 1 the
// wording lives in hu.json, and a raw read would find a marker where the sentence used to be.
func huTemplateSource(name string) ([]byte, error) {
raw, err := templateFS.ReadFile(name)
if err != nil {
return nil, err
}
b, err := i18n.Shared()
if err != nil {
return nil, err
}
out, _ := b.Expand(i18n.Default, string(raw))
return []byte(out), nil
}
// ── Slice 1: pages rendered OUTSIDE executeTemplate follow the language (executeTemplateLang) ───────
//
// i18nDirectPages lists them with a parity case and a phrase that must appear in English. Release B adds
// recovery; release C adds login, claim, the two guest share pages and the catch-all.
var i18nDirectPages = []struct{ tmpl, caseName, enProbe string }{
{"recovery", "recovery_locked_can", "Unlock backups"},
{"login", "login", "Forgot password"},
{"claim", "claim_setup_code", "Setup code"},
{"launcher_shared", "launcher_shared_apps", "<title>Launcher</title>"},
{"launcher_share_password", "launcher_share_password", "This page is protected by a password."},
{"catchall", "catchall_app", "Manage app"},
}
// TestI18nDirectRenderPagesFollowLanguage — with the household language saved as English the page is
// English (`<html lang="en">` and a known English phrase); saved as Hungarian it is byte-identical to the
// fixture captured from the unconverted template.
func TestI18nDirectRenderPagesFollowLanguage(t *testing.T) {
cases := map[string]i18nCase{}
for _, c := range i18nCases() {
cases[c.name] = c
}
for _, p := range i18nDirectPages {
c, ok := cases[p.caseName]
if !ok {
t.Fatalf("%s: no parity case %q", p.tmpl, p.caseName)
}
for _, lang := range []string{"hu", "en"} {
s := i18nTestServer(t)
if err := s.settings.SetLanguage(lang); err != nil {
t.Fatal(err)
}
var buf strings.Builder
// The SAME request path the parity harness renders with. Since v0.254.0 the page carries a
// language globe whose `back` is the path the visitor is on, so a different path here would
// differ from the fixture in one attribute and say nothing about the language.
if err := s.executeTemplateLang(&buf, httptest.NewRequest(http.MethodGet, "/i18n-fixture", nil), p.tmpl, c.data()); err != nil {
t.Fatalf("%s [%s]: %v", p.tmpl, lang, err)
}
got := relativeAgeRe.ReplaceAllString(buf.String(), "# $1")
if lang == "hu" {
want, err := os.ReadFile(filepath.Join("testdata", "i18n_parity", p.caseName+".html"))
if err != nil {
t.Fatal(err)
}
if got != string(want) {
t.Errorf("%s: Hungarian render through executeTemplateLang differs from fixture %s", p.tmpl, p.caseName)
}
continue
}
if !strings.Contains(got, `<html lang="en"`) || !strings.Contains(got, p.enProbe) {
t.Errorf("%s: saved English but the page is not English (lang=en: %v, %q: %v)", p.tmpl,
strings.Contains(got, `<html lang="en"`), p.enProbe, strings.Contains(got, p.enProbe))
}
}
}
}
// TestRecoveryHandlerFollowsLanguage — the REAL render path (renderRecovery), not the helper: saved
// English reaches the recovery page. RED-PROOF: put `s.tmpl.ExecuteTemplate` back in renderRecoveryState.
func TestRecoveryHandlerFollowsLanguage(t *testing.T) {
s := newDashboardServer(t, time.Time{})
if err := s.settings.SetLanguage("en"); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
s.renderRecovery(rec, httptest.NewRequest(http.MethodGet, "/recovery", nil), "", "", nil)
body := rec.Body.String()
if rec.Code != 200 || !strings.Contains(body, `<html lang="en"`) || !strings.Contains(body, "Unlock backups") {
t.Errorf("recovery page with English saved: code %d, lang=en %v, English phrase %v", rec.Code,
strings.Contains(body, `<html lang="en"`), strings.Contains(body, "Unlock backups"))
}
}
// The Hungarian page title lives twice: as the literal a handler passes to baseData (what hu renders)
// and as the hu.json value of the TitleKey next to it (the key English translates). This pins them
// equal at every handler that sets a TitleKey, and pins that every page.title.* key has such a handler
// — so a reworded title cannot drift from its key, and a key cannot be left with no page.
func TestHandlerTitleKeysMatchHungarianTitle(t *testing.T) {
b, err := i18n.Load()
if err != nil {
t.Fatal(err)
}
pairs := []*regexp.Regexp{
// data := s.baseData("page", "Title") … data["TitleKey"] = "key" (next line)
regexp.MustCompile(`(?:baseData|settingsBaseData|backupsCommonData)\("[^"]*", "([^"]+)"(?:, r)?\)\n\s*data\["TitleKey"\] = "([^"]+)"`),
// "Title": "Title", … "TitleKey": "key" (map literal)
regexp.MustCompile(`"Title":\s+"([^"]+)",\n\s*"TitleKey":\s+"([^"]+)"`),
// title, titleKey := "Title", "key"
regexp.MustCompile(`title, titleKey :?= "([^"]+)", "([^"]+)"`),
}
// R-566, v0.252.0 — the three titles built around an app name, plus the tier-2 one. Their
// Hungarian is a CONCATENATION, so there is no single literal to compare with; the pair is pinned
// instead by TestParameterisedPageTitles (the rendered title) and by scripts/i18n_go_parity.py
// (the key's text against the base-commit fragment). Collected here so the "every page.title.* key
// has a handler" half below still accounts for them.
withArgs := regexp.MustCompile(`data\["TitleKey"\], data\["TitleArgs"\] = (?:pageTitleKey|"([^"]+)")`)
argKeys := []string{"page.title.logs", "page.title.deploy", "page.title.app_settings", "page.title.tier2_config"}
files, _ := filepath.Glob("*.go")
seen := map[string]bool{}
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, m := range withArgs.FindAllStringSubmatch(string(src), -1) {
if m[1] != "" {
seen[m[1]] = true
}
}
for _, re := range pairs {
for _, m := range re.FindAllStringSubmatch(string(src), -1) {
seen[m[2]] = true
if hu := b.Msg(i18n.Default, m[2]); hu != m[1] {
t.Errorf("%s: handler title %q, but hu.json %s = %q", f, m[1], m[2], hu)
}
}
}
}
// The two keys a `pageTitleKey` variable carries cannot be read off the assignment line; they are
// named here and proven by TestParameterisedPageTitles.
for _, k := range argKeys {
seen[k] = true
}
for _, k := range b.Keys(i18n.Default) {
if strings.HasPrefix(k, "page.title.") && !seen[k] {
t.Errorf("%s has no handler setting it next to its Hungarian title", k)
}
}
if len(seen) < 16 {
t.Errorf("only %d title pairs found — the source patterns no longer match the handlers", len(seen))
}
}
// TestDirectRenderHandlersFollowLanguage — the REAL routes, not the helper: with English saved, the
// sign-in page, the claim page, both guest share pages and the catch-all answer in English, and with
// Hungarian saved they answer in Hungarian. RED-PROOF: put `s.tmpl.ExecuteTemplate` back at any of the
// five call sites and its row fails on English.
func TestDirectRenderHandlersFollowLanguage(t *testing.T) {
type page struct {
name string
srv func(t *testing.T) *Server
get func(s *Server) *httptest.ResponseRecorder
en, hu string
wantCode int
}
viaMux := func(path string) func(s *Server) *httptest.ResponseRecorder {
return func(s *Server) *httptest.ResponseRecorder {
rr := httptest.NewRecorder()
s.fullMux().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
return rr
}
}
shared := func(password bool) func(t *testing.T) *Server {
return func(t *testing.T) *Server {
s := shareTestServer(t)
if err := s.settings.SetLauncherShareToken(testShareToken); err != nil {
t.Fatal(err)
}
if password {
hash, _ := bcrypt.GenerateFromPassword([]byte("guest-secret"), bcrypt.MinCost)
if err := s.settings.SetLauncherSharePasswordHash(string(hash)); err != nil {
t.Fatal(err)
}
}
return s
}
}
pages := []page{
{"login", shareTestServer, viaMux("/login"), "Forgot password", "Elfelejtett jelsz", 200},
{"claim", func(t *testing.T) *Server { s, _, _ := claimTestServer(t); return s }, viaMux("/claim"), "Setup code", "Be\u00e1ll\u00edt\u00f3 k\u00f3d", 200},
{"launcher_shared", shared(false), viaMux("/s/" + testShareToken), "<title>Launcher</title>", "<title>Ind\u00edt\u00f3pult</title>", 200},
{"launcher_share_password", shared(true), viaMux("/s/" + testShareToken), "This page is protected by a password.", "Ez az oldal jelsz\u00f3val v\u00e9dett", 200},
{"catchall", shareTestServer, func(s *Server) *httptest.ResponseRecorder {
rr := httptest.NewRecorder()
s.serveCatchAll(rr, httptest.NewRequest(http.MethodGet, "/", nil), "nope.demo-felhom.eu")
return rr
}, "Dashboard</a>", "Vez\u00e9rl\u0151pult</a>", 404},
}
for _, p := range pages {
for _, lang := range []string{"en", "hu"} {
s := p.srv(t)
if err := s.settings.SetLanguage(lang); err != nil {
t.Fatal(err)
}
rr := p.get(s)
body := rr.Body.String()
want, htmlLang := p.en, `<html lang="en"`
if lang == "hu" {
want, htmlLang = p.hu, `<html lang="hu"`
}
if rr.Code != p.wantCode || !strings.Contains(body, htmlLang) || !strings.Contains(body, want) {
t.Errorf("%s with %s saved: code %d (want %d), %s %v, %q %v", p.name, lang, rr.Code, p.wantCode,
htmlLang, strings.Contains(body, htmlLang), want, strings.Contains(body, want))
}
}
}
}
// TestI18nDirectRenderPagesHaveNoAdminChrome — the pages outside the dashboard never receive what
// executeTemplate adds for a signed-in household: no escrow reminder bar (R-543) and no session CSRF
// token. The box here has the reminder genuinely DUE (off-site on, escrow pending, auth off so every
// visitor counts as signed in — the case where executeTemplate WOULD add it), which makes the absence
// a measurement, not a default.
//
// Two layers. The data layer: executeTemplateLang leaves no EscrowBanner / CSRFField / CSRFToken in the
// page data — RED-PROOF: add `s.addEscrowBanner(data, r)` to executeTemplateLang and this fails. The page
// layer: the real guest share page, in both languages, carries neither the bar's sentence nor its link.
// The standalone guest template has no slot for the bar, so the page layer alone would stay green under
// that mutation; the data layer is the one that bites.
func TestI18nDirectRenderPagesHaveNoAdminChrome(t *testing.T) {
s := escrowServer(t, "pending")
if !s.escrowBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) {
t.Fatal("fixture invalid: the escrow reminder is not due on this box, so its absence measures nothing")
}
for _, p := range i18nDirectPages {
data := map[string]interface{}{}
if err := s.executeTemplateLang(io.Discard, httptest.NewRequest(http.MethodGet, "/x", nil), p.tmpl, data); err != nil {
t.Fatalf("%s: %v", p.tmpl, err)
}
for _, k := range []string{"EscrowBanner", "EscrowBannerBack", "CSRFField", "CSRFToken"} {
if _, ok := data[k]; ok {
t.Errorf("%s: executeTemplateLang put %s into the page data — dashboard chrome on a page outside the dashboard", p.tmpl, k)
}
}
}
if err := s.settings.SetLauncherShareToken(testShareToken); err != nil {
t.Fatal(err)
}
for _, lang := range []string{"hu", "en"} {
if err := s.settings.SetLanguage(lang); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.fullMux().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil))
body := rr.Body.String()
if rr.Code != 200 || !strings.Contains(body, `<html lang="`+lang+`"`) {
t.Fatalf("guest share page [%s]: code %d, lang attribute present %v", lang, rr.Code, strings.Contains(body, `<html lang="`+lang+`"`))
}
for _, leak := range []string{escrowBarSentence, escrowBarLink, "recovery code", `name="_csrf"`, "csrf-token"} {
if strings.Contains(body, leak) {
t.Errorf("guest share page [%s] carries %q — a household reminder or session token shown to a guest", lang, leak)
}
}
}
}
// TestParameterisedPageTitles — R-566, v0.252.0. Three page titles are built in Go AROUND an app
// name („<app> — Naplók", „<app> — Telepítés" / „— Beállítások", „2. mentés beállítása — <app>").
// Slice 1 gave every STATIC title a key and left these three Hungarian in the browser tab while the
// page body was English, because one static message cannot hold a name.
//
// What this pins: rendering the key with the app name produces EXACTLY the string the handler's
// concatenation produced before, and English differs. The first half is the parity rule for a title;
// the second is the reason the row exists.
func TestParameterisedPageTitles(t *testing.T) {
b, err := i18n.Load()
if err != nil {
t.Fatal(err)
}
const app = "PrivateBin"
cases := []struct {
key, wantHU string
}{
{"page.title.logs", app + " — Naplók"},
{"page.title.deploy", app + " — Telepítés"},
{"page.title.app_settings", app + " — Beállítások"},
{"page.title.tier2_config", "2. mentés beállítása — " + app},
}
for _, c := range cases {
if got := b.Msgf(i18n.Default, c.key, app); got != c.wantHU {
t.Errorf("%s in Hungarian:\n got %q\n want %q (the concatenation this replaced)", c.key, got, c.wantHU)
}
en := b.Msgf("en", c.key, app)
if !strings.Contains(en, app) {
t.Errorf("%s in English lost the app name: %q", c.key, en)
}
if en == c.wantHU {
t.Errorf("%s was never translated — English still reads %q", c.key, en)
}
}
}