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
558 lines
24 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|