Files
felhom-controller/controller/internal/web/i18n_wiring_test.go
T
admin 9cc7ffcf3b
gates / gates (push) Failing after 44s
A version number is pushed once, and every build addresses its CSS differently
MEASURED FIRST (audits/app-page-2026-10-15b/P0/VERDICT.md). The tab bar rendered unstyled through
the public address for one reason, and it was not the box: exactly one guest runs 0.315.0 and it
runs the registry's current digest (8887343011d6, identical to the registry's). The floor is still
0.314.0, so nothing else could have taken it.

What it was, in four measured facts:
  1. the stylesheet URL is /static/style.css?v={{.Version}} -> ?v=0.315.0 for all THREE different
     0.315.0 builds (e66d466, fe0228c, db52f5a), the first of which had no .app-tabs rules at all;
  2. the origin sends NO validator at all (ETag present = 0, Last-Modified present = 0), so a
     cached copy cannot be revalidated - it can only expire;
  3. Cloudflare caches that URL (cf-cache-status HIT, age climbing) and rewrites the browser TTL
     from the origin's 3600 to 14400 - four hours;
  4. the session's first request returned cf-cache-status EXPIRED, which only happens when the edge
     holds an entry that has aged out.

Origin, edge and box now agree byte-for-byte. The stale object itself is gone - that first probe
forced its revalidation - and the REPORT says so rather than claiming a HIT nobody can reproduce.

THE FIX IS IN TWO PARTS, NEITHER OF WHICH IS PURGING A CACHE:

build.sh refuses to push a version that already exists, before building, for --push and
--multiarch. A registry that cannot be asked is a refusal too: proving the tag is new is the point,
and "I could not ask" is not that proof. Local builds are untouched - reusing a version while
iterating is normal. scripts/test_build_push_once.sh drives the real build.sh with a fake docker on
PATH over six cases; red-proved twice (check deleted -> a present tag builds and pushes; an
unreachable registry treated as absent -> two cases fail).

assetTag() now hashes version + GitCommit + BuildTime, and EVERY shell's stylesheet link uses it
(seven of the nine asked for ?v={{.Version}}; only the two stranger-facing share pages already had
it). It stays opaque and 8 hex, because the share pages are reached by a capability URL and naming
the build to whoever holds one is a gift to somebody hunting a known fault. Cache-Control is
unchanged: the new address is the fix, not a shorter cache.

The parity harness normalises the asset tag on BOTH sides of the comparison, as it already does for
the per-session CSRF token, so all 148 fixtures stay byte-identical on disk and stop depending on
which build rendered them. The same normalisation is applied at the second comparison site in
i18n_wiring_test.go, which had the same assumption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:08:01 +02:00

764 lines
34 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"},
{"familygate", "familygate", "Open this app with your family name and password."},
}
// 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)
}
// Both sides through the normaliser, as TestI18nParity does and for the same reason:
// the per-build asset tag, the per-session CSRF token and a relative age are not
// copy, and the function is idempotent.
if got != normaliseI18nRender(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 titles built around an app name. v0.315.0: the tier-2 one went with
// its page (the panel is now a section of the app page's data tab) and the two new tab titles
// took its place. 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.app_data", "page.title.app_guide"}
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.app_data", app + " — Adatok és mentés"},
{"page.title.app_guide", app + " — Útmutató"},
}
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
}