149467c795
gates / gates (push) Successful in 23s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
756 lines
33 KiB
Go
756 lines
33 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)
|
|
}
|
|
}
|
|
// The two *MetaBadge builders (R-589, v0.258.0). i18n_go_keys.json lists their keys under
|
|
// `_preexisting` CITING THIS TEST, so the citation has to be true: hu.json must say exactly
|
|
// what the frozen Hungarian funcmap forms say, word for word.
|
|
huBadge := fm["updateBadge"].(func(stacks.Stack) *MetaBadge)
|
|
cur := stacks.Stack{Name: "a", Deployed: true,
|
|
Meta: stacks.Metadata{Slug: "a", CatalogSince: time.Now().UTC().AddDate(0, 0, -2).Format("2006-01-02")},
|
|
AppConfig: &stacks.AppConfig{InstalledImages: map[string]stacks.InstalledImage{"x": {Ref: "same"}}},
|
|
CatalogImages: map[string]string{"x": "same"}}
|
|
behind := cur
|
|
behind.CatalogImages = map[string]string{"x": "moved"}
|
|
if got, want := b.Msg("hu", "badge.update.current"), huBadge(cur).Label; got != want {
|
|
t.Errorf("badge.update.current: hu.json %q, funcmap %q", got, want)
|
|
}
|
|
if got, want := b.Msg("hu", "badge.update.current.title"), huBadge(cur).Title; got != want {
|
|
t.Errorf("badge.update.current.title: hu.json %q, funcmap %q", got, want)
|
|
}
|
|
if got, want := b.Msg("hu", "badge.update.behind")+fmt.Sprintf(b.Msg("hu", "badge.update.behind.days"), 2), huBadge(behind).Label; got != want {
|
|
t.Errorf("badge.update.behind(+age): hu.json %q, funcmap %q", got, want)
|
|
}
|
|
if got, want := b.Msg("hu", "badge.update.behind.title"), huBadge(behind).Title; got != want {
|
|
t.Errorf("badge.update.behind.title: hu.json %q, funcmap %q", got, want)
|
|
}
|
|
huLifecycle := fm["lifecycleBadge"].(func(stacks.Metadata) *MetaBadge)
|
|
ab := huLifecycle(stacks.Metadata{Lifecycle: stacks.LifecycleAbandoned})
|
|
if got, want := b.Msg("hu", "badge.lifecycle.abandoned"), ab.Label; got != want {
|
|
t.Errorf("badge.lifecycle.abandoned: hu.json %q, funcmap %q", got, want)
|
|
}
|
|
if got, want := b.Msg("hu", "badge.lifecycle.abandoned.title"), ab.Title; got != want {
|
|
t.Errorf("badge.lifecycle.abandoned.title: hu.json %q, funcmap %q", got, want)
|
|
}
|
|
|
|
// 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"},
|
|
{"setupgate", "setupgate", "waiting for its first setup"},
|
|
{"signupclosed", "signupclosed", "You cannot sign up on this 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── R-589 / R-573 (v0.258.0) — the badges and the two channel banners follow the language ──────
|
|
//
|
|
// These were the last two Felhom-authored Hungarian strings an English household met on an app
|
|
// page, found LIVE on 2026-09-20 by localisation slice 5's proof, not by reading. Both are built in
|
|
// Go, so neither the template parity fixtures nor TestI18nEnglishPages could see them.
|
|
|
|
// TestUpdateBadgeFollowsTheLanguage — the Hungarian funcmap form and the bundle form must AGREE in
|
|
// Hungarian (that is the parity guarantee) and DIFFER in English (that is the fix).
|
|
//
|
|
// RED-PROOF (2026-09-2X): deleting the "updateBadge" entry from localeFuncs made the English arm
|
|
// return „Naprakész" and the test failed naming it; restoring it went green.
|
|
func TestUpdateBadgeFollowsTheLanguage(t *testing.T) {
|
|
s := i18nTestServer(t)
|
|
behind := stacks.Stack{
|
|
Name: "app", Deployed: true,
|
|
Meta: stacks.Metadata{Slug: "app", CatalogSince: time.Now().UTC().AddDate(0, 0, -3).Format("2006-01-02")},
|
|
AppConfig: &stacks.AppConfig{InstalledImages: map[string]stacks.InstalledImage{"a": {Ref: "old"}}},
|
|
CatalogImages: map[string]string{"a": "new"},
|
|
}
|
|
current := behind
|
|
current.CatalogImages = map[string]string{"a": "old"}
|
|
// R-524: the box runs something the catalog has moved BACK from.
|
|
ahead := behind
|
|
ahead.AppConfig = &stacks.AppConfig{InstalledImages: map[string]stacks.InstalledImage{"a": {Ref: "privatebin/pdo:2.0.6"}}}
|
|
ahead.CatalogImages = map[string]string{"a": "privatebin/pdo:2.0.5"}
|
|
unknown := stacks.Stack{Name: "app", Deployed: false}
|
|
|
|
huFn := s.templateFuncMap()["updateBadge"].(func(stacks.Stack) *MetaBadge)
|
|
enFn := s.localeFuncs("en")["updateBadge"].(func(stacks.Stack) *MetaBadge)
|
|
|
|
// 1. UNKNOWN renders nothing, in both languages — a badge on an app nobody can judge is a
|
|
// question the customer cannot answer.
|
|
if huFn(unknown) != nil || enFn(unknown) != nil {
|
|
t.Error("an unjudgeable app must carry no badge in either language")
|
|
}
|
|
|
|
// 2. Hungarian is unchanged: the bundle form equals the frozen funcmap form.
|
|
for _, st := range []stacks.Stack{current, behind} {
|
|
hu := huFn(st)
|
|
viaBundle := s.localeFuncs("hu")
|
|
if viaBundle != nil {
|
|
t.Fatal("localeFuncs must return nothing for Hungarian — that IS the parity guarantee")
|
|
}
|
|
if hu == nil {
|
|
t.Fatal("a judgeable app must carry a badge")
|
|
}
|
|
}
|
|
if got, want := huFn(current).Label, "Naprakész"; got != want {
|
|
t.Errorf("hu current label = %q, want %q", got, want)
|
|
}
|
|
|
|
// 3. English really is English, and carries the SAME class (the decision, not the words).
|
|
enCur, enBehind := enFn(current), enFn(behind)
|
|
if enCur.Label != "Up to date" {
|
|
t.Errorf("en current label = %q", enCur.Label)
|
|
}
|
|
if enCur.Class != huFn(current).Class || enBehind.Class != huFn(behind).Class {
|
|
t.Error("the badge CLASS must not depend on the language — only the words may")
|
|
}
|
|
if !strings.HasPrefix(enBehind.Label, "Update available") || !strings.Contains(enBehind.Label, "3 days ago") {
|
|
t.Errorf("en behind label = %q, want the English age suffix", enBehind.Label)
|
|
}
|
|
// 4. R-524's ahead arm: the SAME word and the SAME class as current in both languages, with a
|
|
// title that differs from current's — so the household is told why, and never warned.
|
|
huAhead, enAhead := huFn(ahead), enFn(ahead)
|
|
if huAhead == nil || enAhead == nil {
|
|
t.Fatal("an app ahead of the catalog must carry a badge")
|
|
}
|
|
if huAhead.Label != huFn(current).Label || enAhead.Label != enCur.Label {
|
|
t.Errorf("ahead must wear the same word as current; hu %q / en %q", huAhead.Label, enAhead.Label)
|
|
}
|
|
if huAhead.Class != "tag-ok" || enAhead.Class != "tag-ok" {
|
|
t.Errorf("ahead must not be a warning; hu %q / en %q", huAhead.Class, enAhead.Class)
|
|
}
|
|
if huAhead.Title == huFn(current).Title || enAhead.Title == enCur.Title {
|
|
t.Error("ahead must say WHY in its title, not reuse current's sentence")
|
|
}
|
|
|
|
for _, b := range []*MetaBadge{enCur, enBehind, enAhead} {
|
|
if strings.ContainsAny(b.Label+b.Title, "áéíóöőúüűÁÉÍÓÖŐÚÜŰ") {
|
|
t.Errorf("an English badge still carries Hungarian: %q / %q", b.Label, b.Title)
|
|
}
|
|
}
|
|
|
|
// 4. The abandoned lifecycle badge, same shape.
|
|
huLife := s.templateFuncMap()["lifecycleBadge"].(func(stacks.Metadata) *MetaBadge)
|
|
enLife := s.localeFuncs("en")["lifecycleBadge"].(func(stacks.Metadata) *MetaBadge)
|
|
ab := stacks.Metadata{Lifecycle: stacks.LifecycleAbandoned}
|
|
if huLife(stacks.Metadata{}) != nil || enLife(stacks.Metadata{}) != nil {
|
|
t.Error("an available app must carry no lifecycle badge")
|
|
}
|
|
if got := huLife(ab).Label; got != "Nem karbantartott" {
|
|
t.Errorf("hu lifecycle label = %q", got)
|
|
}
|
|
if got := enLife(ab).Label; got != "No longer maintained" {
|
|
t.Errorf("en lifecycle label = %q", got)
|
|
}
|
|
}
|
|
|
|
// TestAgentChannelBannersFollowTheLanguage — R-573. The checker classifies the fault; the banner is
|
|
// keyed by that classification. The CONSEQUENCE asserted here is what the household reads, not
|
|
// whether a key was set.
|
|
//
|
|
// RED-PROOF (2026-09-2X): dropping `MessageKey` from SetAgentChannelAlert left the English banner
|
|
// reading the Hungarian sentence and this failed naming it.
|
|
func TestAgentChannelBannersFollowTheLanguage(t *testing.T) {
|
|
s := i18nTestServer(t)
|
|
b := s.i18n
|
|
// Every reason the checker can classify must have a key, or the banner silently falls back.
|
|
for _, reason := range []string{"construction_error", "pin_mismatch", "unauthorized",
|
|
"misconfigured", "unreachable", "timeout", "unknown"} {
|
|
key := "alert.agent_channel." + reason
|
|
if !b.Has("hu", key) {
|
|
t.Errorf("reason %q has no Hungarian banner key %q", reason, key)
|
|
continue
|
|
}
|
|
hu, en := b.Msg("hu", key), b.Msg("en", key)
|
|
if hu == en {
|
|
t.Errorf("%s: the English banner fell back to Hungarian: %q", key, en)
|
|
}
|
|
if strings.ContainsAny(en, "áéíóöőúüűÁÉÍÓÖŐÚÜŰ") {
|
|
t.Errorf("%s: the English banner carries Hungarian letters: %q", key, en)
|
|
}
|
|
}
|
|
if b.Msg("en", "alert.endpoint_drift") == b.Msg("hu", "alert.endpoint_drift") {
|
|
t.Error("the endpoint-drift banner fell back to Hungarian")
|
|
}
|
|
|
|
// The AlertManager renders the key, and an UNMAPPED reason still shows the checker's sentence
|
|
// rather than a blank banner or a raw key — the fail-open half of the fix.
|
|
am := NewAlertManager(nil)
|
|
am.SetAgentChannelAlert(true, "alert.agent_channel.timeout", "A tárolókezelő ügynök nem válaszol.")
|
|
if got := findAlert(am.GetAlerts("en"), "agent-channel-down"); got == nil {
|
|
t.Fatal("the banner did not render")
|
|
} else if got.Message != "The storage agent is not answering." {
|
|
t.Errorf("en banner = %q", got.Message)
|
|
}
|
|
am.SetAgentChannelAlert(true, "", "A tárolókezelő ügynök nem válaszol.")
|
|
if got := findAlert(am.GetAlerts("en"), "agent-channel-down"); got == nil || got.Message != "A tárolókezelő ügynök nem válaszol." {
|
|
t.Errorf("an unmapped reason must fall back to the composed sentence, got %+v", got)
|
|
}
|
|
am.SetAgentChannelAlert(false, "", "")
|
|
if findAlert(am.GetAlerts("en"), "agent-channel-down") != nil {
|
|
t.Error("clearing the banner must remove it")
|
|
}
|
|
}
|
|
|
|
func findAlert(alerts []Alert, id string) *Alert {
|
|
for i := range alerts {
|
|
if alerts[i].ID == id {
|
|
return &alerts[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|