i18n slice 1 release C: storage, sharing, sign-in, guest, catch-all and debug pages in English

- 11 templates converted (storage, storage_network, storage_init, storage_attach, sharing, login,
  claim, launcher_shared, launcher_share_password, catchall, debug); every key translated.
  Six ASCII-only Hungarian JS fragments the extractor missed were found by eye and converted by hand
  (", majd a(z)", "FIGYELEM:", "jelenlegi:", "mp", "p", " db").
- login, claim, both guest share pages and the catch-all render through executeTemplateLang (the
  household language; no session CSRF, no escrow reminder). renderLogin now takes the request.
- Page titles: TitleKey for storage, network storage, the two drive wizards and sharing.
  TestHandlerTitleKeysMatchHungarianTitle pins handler literal == hu.json value for every TitleKey.
- Tests: TestDirectRenderHandlersFollowLanguage (real routes, en + hu),
  TestI18nDirectRenderPagesHaveNoAdminChrome (escrow reminder due; none on the guest page).
- Fixture correction: the release C cases had invented page titles; the cases now carry the handlers'
  real titles (and the wizard pages their real page name), and those 12 fixtures were RE-CAPTURED from
  the unconverted templates at 0555091. The diff to the old fixtures is the <title> line (and the nav
  highlight on the two wizard pages) only.
- i18n gate: HU_FORMAL_CEILING 12 -> 16 — four more „ön" forms in the converted copy, unchanged by rule (R-516).

Hungarian: byte-identical (TestI18nParity against fixtures from unconverted templates).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-17 18:29:53 +02:00
parent 0555091f83
commit ac149c2aa4
36 changed files with 1696 additions and 559 deletions
+169 -2
View File
@@ -2,17 +2,20 @@ package web
import (
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"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"
)
@@ -272,6 +275,11 @@ func huTemplateSource(name string) ([]byte, error) {
// recovery; release C adds login, claim, the two guest share pages and the catch-all.
var i18nDirectPages = []struct{ tmpl, caseName, enProbe string }{
{"recovery", "recovery_locked_can", "Unlock backups"},
{"login", "login", "Forgot password"},
{"claim", "claim_setup_code", "Setup code"},
{"launcher_shared", "launcher_shared_apps", "<title>Launcher</title>"},
{"launcher_share_password", "launcher_share_password", "This page is protected by a password."},
{"catchall", "catchall_app", "Manage app"},
}
// TestI18nDirectRenderPagesFollowLanguage — with the household language saved as English the page is
@@ -330,3 +338,162 @@ func TestRecoveryHandlerFollowsLanguage(t *testing.T) {
strings.Contains(body, `<html lang="en"`), strings.Contains(body, "Unlock backups"))
}
}
// The Hungarian page title lives twice: as the literal a handler passes to baseData (what hu renders)
// and as the hu.json value of the TitleKey next to it (the key English translates). This pins them
// equal at every handler that sets a TitleKey, and pins that every page.title.* key has such a handler
// — so a reworded title cannot drift from its key, and a key cannot be left with no page.
func TestHandlerTitleKeysMatchHungarianTitle(t *testing.T) {
b, err := i18n.Load()
if err != nil {
t.Fatal(err)
}
pairs := []*regexp.Regexp{
// data := s.baseData("page", "Title") … data["TitleKey"] = "key" (next line)
regexp.MustCompile(`(?:baseData|settingsBaseData|backupsCommonData)\("[^"]*", "([^"]+)"(?:, r)?\)\n\s*data\["TitleKey"\] = "([^"]+)"`),
// "Title": "Title", … "TitleKey": "key" (map literal)
regexp.MustCompile(`"Title":\s+"([^"]+)",\n\s*"TitleKey":\s+"([^"]+)"`),
// title, titleKey := "Title", "key"
regexp.MustCompile(`title, titleKey :?= "([^"]+)", "([^"]+)"`),
}
files, _ := filepath.Glob("*.go")
seen := map[string]bool{}
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, re := range pairs {
for _, m := range re.FindAllStringSubmatch(string(src), -1) {
seen[m[2]] = true
if hu := b.Msg(i18n.Default, m[2]); hu != m[1] {
t.Errorf("%s: handler title %q, but hu.json %s = %q", f, m[1], m[2], hu)
}
}
}
}
for _, k := range b.Keys(i18n.Default) {
if strings.HasPrefix(k, "page.title.") && !seen[k] {
t.Errorf("%s has no handler setting it next to its Hungarian title", k)
}
}
if len(seen) < 16 {
t.Errorf("only %d title pairs found — the source patterns no longer match the handlers", len(seen))
}
}
// TestDirectRenderHandlersFollowLanguage — the REAL routes, not the helper: with English saved, the
// sign-in page, the claim page, both guest share pages and the catch-all answer in English, and with
// Hungarian saved they answer in Hungarian. RED-PROOF: put `s.tmpl.ExecuteTemplate` back at any of the
// five call sites and its row fails on English.
func TestDirectRenderHandlersFollowLanguage(t *testing.T) {
type page struct {
name string
srv func(t *testing.T) *Server
get func(s *Server) *httptest.ResponseRecorder
en, hu string
wantCode int
}
viaMux := func(path string) func(s *Server) *httptest.ResponseRecorder {
return func(s *Server) *httptest.ResponseRecorder {
rr := httptest.NewRecorder()
s.fullMux().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
return rr
}
}
shared := func(password bool) func(t *testing.T) *Server {
return func(t *testing.T) *Server {
s := shareTestServer(t)
if err := s.settings.SetLauncherShareToken(testShareToken); err != nil {
t.Fatal(err)
}
if password {
hash, _ := bcrypt.GenerateFromPassword([]byte("guest-secret"), bcrypt.MinCost)
if err := s.settings.SetLauncherSharePasswordHash(string(hash)); err != nil {
t.Fatal(err)
}
}
return s
}
}
pages := []page{
{"login", shareTestServer, viaMux("/login"), "Forgot password", "Elfelejtett jelsz", 200},
{"claim", func(t *testing.T) *Server { s, _, _ := claimTestServer(t); return s }, viaMux("/claim"), "Setup code", "Be\u00e1ll\u00edt\u00f3 k\u00f3d", 200},
{"launcher_shared", shared(false), viaMux("/s/" + testShareToken), "<title>Launcher</title>", "<title>Ind\u00edt\u00f3pult</title>", 200},
{"launcher_share_password", shared(true), viaMux("/s/" + testShareToken), "This page is protected by a password.", "Ez az oldal jelsz\u00f3val v\u00e9dett", 200},
{"catchall", shareTestServer, func(s *Server) *httptest.ResponseRecorder {
rr := httptest.NewRecorder()
s.serveCatchAll(rr, httptest.NewRequest(http.MethodGet, "/", nil), "nope.demo-felhom.eu")
return rr
}, "Dashboard</a>", "Vez\u00e9rl\u0151pult</a>", 404},
}
for _, p := range pages {
for _, lang := range []string{"en", "hu"} {
s := p.srv(t)
if err := s.settings.SetLanguage(lang); err != nil {
t.Fatal(err)
}
rr := p.get(s)
body := rr.Body.String()
want, htmlLang := p.en, `<html lang="en"`
if lang == "hu" {
want, htmlLang = p.hu, `<html lang="hu"`
}
if rr.Code != p.wantCode || !strings.Contains(body, htmlLang) || !strings.Contains(body, want) {
t.Errorf("%s with %s saved: code %d (want %d), %s %v, %q %v", p.name, lang, rr.Code, p.wantCode,
htmlLang, strings.Contains(body, htmlLang), want, strings.Contains(body, want))
}
}
}
}
// TestI18nDirectRenderPagesHaveNoAdminChrome — the pages outside the dashboard never receive what
// executeTemplate adds for a signed-in household: no escrow reminder bar (R-543) and no session CSRF
// token. The box here has the reminder genuinely DUE (off-site on, escrow pending, auth off so every
// visitor counts as signed in — the case where executeTemplate WOULD add it), which makes the absence
// a measurement, not a default.
//
// Two layers. The data layer: executeTemplateLang leaves no EscrowBanner / CSRFField / CSRFToken in the
// page data — RED-PROOF: add `s.addEscrowBanner(data, r)` to executeTemplateLang and this fails. The page
// layer: the real guest share page, in both languages, carries neither the bar's sentence nor its link.
// The standalone guest template has no slot for the bar, so the page layer alone would stay green under
// that mutation; the data layer is the one that bites.
func TestI18nDirectRenderPagesHaveNoAdminChrome(t *testing.T) {
s := escrowServer(t, "pending")
if !s.escrowBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) {
t.Fatal("fixture invalid: the escrow reminder is not due on this box, so its absence measures nothing")
}
for _, p := range i18nDirectPages {
data := map[string]interface{}{}
if err := s.executeTemplateLang(io.Discard, httptest.NewRequest(http.MethodGet, "/x", nil), p.tmpl, data); err != nil {
t.Fatalf("%s: %v", p.tmpl, err)
}
for _, k := range []string{"EscrowBanner", "EscrowBannerBack", "CSRFField", "CSRFToken"} {
if _, ok := data[k]; ok {
t.Errorf("%s: executeTemplateLang put %s into the page data — dashboard chrome on a page outside the dashboard", p.tmpl, k)
}
}
}
if err := s.settings.SetLauncherShareToken(testShareToken); err != nil {
t.Fatal(err)
}
for _, lang := range []string{"hu", "en"} {
if err := s.settings.SetLanguage(lang); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.fullMux().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil))
body := rr.Body.String()
if rr.Code != 200 || !strings.Contains(body, `<html lang="`+lang+`"`) {
t.Fatalf("guest share page [%s]: code %d, lang attribute present %v", lang, rr.Code, strings.Contains(body, `<html lang="`+lang+`"`))
}
for _, leak := range []string{escrowBarSentence, escrowBarLink, "recovery code", `name="_csrf"`, "csrf-token"} {
if strings.Contains(body, leak) {
t.Errorf("guest share page [%s] carries %q — a household reminder or session token shown to a guest", lang, leak)
}
}
}
}