0b1486df8f
gates / gates (push) Successful in 24s
/recovery is in the AUTHENTICATED route table — its reader is the household, not a visitor. The first draft of v0.254.0 gave it the anonymous form, which sets the felhom_lang cookie that langFor deliberately ignores once there is a session: the button would have appeared to work and done nothing. Found by the live probe on demo-hp reporting no globe on /recovery (it 302s to /login without a session) and then reading the route table. executeTemplateLang now branches on hasSession: household form with its session CSRF, or the visitor form without. The parity harness and TestI18nDirectRenderPagesFollowLanguage carry the same branch, so the fixture is the form the real page serves — the trap this release already walked into once with the shells. A per-session CSRF token cannot be a fixture value, so it is blanked on both sides of every parity comparison, exactly as relative ages already were. What stays pinned is that the field is THERE and WHICH form it sits in — the half that says whether the globe writes the household's setting or the visitor's cookie. Evidence regenerated: 3 change shapes across 106 fixtures, 5 byte-identical (both guest share pages and the catch-all — the three that must not change). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
567 lines
24 KiB
Go
567 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.
|
|
//
|
|
// And the same SESSION state: /recovery is an authenticated route, so its reader is the
|
|
// household and its globe writes their setting; /login and /claim are met with no session.
|
|
// Rendering recovery session-less here would compare the real page against a form it never
|
|
// serves — the mistake this release already made once, in the parity harness.
|
|
req := httptest.NewRequest(http.MethodGet, "/i18n-fixture", nil)
|
|
if i18nSessionTemplates[p.tmpl] {
|
|
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: newTestSession(s)})
|
|
}
|
|
if err := s.executeTemplateLang(&buf, req, p.tmpl, c.data()); err != nil {
|
|
t.Fatalf("%s [%s]: %v", p.tmpl, lang, err)
|
|
}
|
|
got := normaliseI18nRender(buf.String())
|
|
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)
|
|
}
|
|
}
|
|
}
|