ac149c2aa4
- 11 templates converted (storage, storage_network, storage_init, storage_attach, sharing, login,
claim, launcher_shared, launcher_share_password, catchall, debug); every key translated.
Six ASCII-only Hungarian JS fragments the extractor missed were found by eye and converted by hand
(", majd a(z)", "FIGYELEM:", "jelenlegi:", "mp", "p", " db").
- login, claim, both guest share pages and the catch-all render through executeTemplateLang (the
household language; no session CSRF, no escrow reminder). renderLogin now takes the request.
- Page titles: TitleKey for storage, network storage, the two drive wizards and sharing.
TestHandlerTitleKeysMatchHungarianTitle pins handler literal == hu.json value for every TitleKey.
- Tests: TestDirectRenderHandlersFollowLanguage (real routes, en + hu),
TestI18nDirectRenderPagesHaveNoAdminChrome (escrow reminder due; none on the guest page).
- Fixture correction: the release C cases had invented page titles; the cases now carry the handlers'
real titles (and the wizard pages their real page name), and those 12 fixtures were RE-CAPTURED from
the unconverted templates at 0555091. The diff to the old fixtures is the <title> line (and the nav
highlight on the two wizard pages) only.
- i18n gate: HU_FORMAL_CEILING 12 -> 16 — four more „ön" forms in the converted copy, unchanged by rule (R-516).
Hungarian: byte-identical (TestI18nParity against fixtures from unconverted templates).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
500 lines
20 KiB
Go
500 lines
20 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()
|
|
}
|
|
|
|
hu := body("/launcher")
|
|
if strings.Contains(hu, `action="/settings/language"`) {
|
|
t.Error("a Hungarian household must not see the language switch in this release")
|
|
}
|
|
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 no switch")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
if err := s.executeTemplateLang(&buf, httptest.NewRequest(http.MethodGet, "/x", 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 :?= "([^"]+)", "([^"]+)"`),
|
|
}
|
|
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 _, 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|