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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user