e66d4669d6
gates / gates (push) Failing after 47s
An app was six pages — details, install/settings, logs, export, the per-app backup panel and a
details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way
back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/,
one shared header and one set of banners.
settings Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the
household + the install-time and optional settings. The default for an installed app,
RENDERED at the bare URL and never redirected to.
data the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export
guide tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs
link — and the WHOLE page for an app that is not installed
logs the log, and the only tab whose poller runs
The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not
run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons.
The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser
caches beyond anyone's reach — to the tab that now holds them, query string carried through, with
name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is
untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs.
A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true`
and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can
never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and
logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard
unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a
POST for an undeclared field then reached Plex and opened the setup gate.
TestAfterStart_RegistryAloneIsNotAnInvitation pins it.
New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by
field and rendered with the household's own address filled in. It renders only when checked_version
names the image tag its author clicked through. No template carries one yet.
The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box
on 0.314.0 would read "csak x86" on every app (R-1067).
template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not
become vacuous.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
761 lines
33 KiB
Go
761 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"},
|
|
{"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)
|
|
}
|
|
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 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
|
|
}
|