diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go
index 996b6f8..4b9af7d 100644
--- a/controller/internal/web/i18n_parity_test.go
+++ b/controller/internal/web/i18n_parity_test.go
@@ -306,19 +306,43 @@ func renderI18nCase(t *testing.T, s *Server, lang string, c i18nCase) string {
// is exactly what the first attempt at this release did, and what the diff caught.
if i18nDirectTemplates[c.tmpl] {
data["Lang"] = lang
- addLangOptions(data, lang, langCookiePath, "/i18n-fixture", "")
+ // And WITHIN the direct-render set, two shapes again: /recovery is an AUTHENTICATED route, so
+ // its globe writes the household's setting; /login and /claim are met with no session, so
+ // theirs writes the visitor's cookie. The fixture has to be the one the real page serves.
+ if i18nSessionTemplates[c.tmpl] {
+ addLangOptions(data, lang, "/settings/language", "/i18n-fixture", s.csrfField(r))
+ } else {
+ addLangOptions(data, lang, langCookiePath, "/i18n-fixture", "")
+ }
} else {
s.addLanguageData(data, r, lang)
}
if err := s.templatesFor(lang).ExecuteTemplate(&buf, c.tmpl, data); err != nil {
t.Fatalf("%s [%s]: render: %v", c.name, lang, err)
}
- return relativeAgeRe.ReplaceAllString(buf.String(), "# $1")
+ return normaliseI18nRender(buf.String())
+}
+
+// csrfValueRe matches a CSRF field's value. A session CSRF token is random per session, so it can
+// never be a fixture value; it is blanked on BOTH sides of every parity comparison, exactly as
+// relative ages are. What the fixture still pins is that the field is THERE and which form it is in —
+// which is the part that says whether the page posts to the household switch or to /lang.
+var csrfValueRe = regexp.MustCompile(`(name="_csrf" value=")[^"]*"`)
+
+// normaliseI18nRender removes the two things that legitimately differ between two renders of the same
+// page: a relative age off the wall clock, and a per-session CSRF token.
+func normaliseI18nRender(s string) string {
+ s = relativeAgeRe.ReplaceAllString(s, "# $1")
+ return csrfValueRe.ReplaceAllString(s, "${1}CSRF\"")
}
// i18nDirectTemplates are the templates rendered by executeTemplateLang — the pages a request with no
// household session meets. Kept beside the harness that has to branch on it; i18nDirectPages (in
// i18n_wiring_test.go) names the same set with one case and an English probe each.
+// i18nSessionTemplates are the direct-render templates whose reader is SIGNED IN. /recovery is in the
+// authenticated route table; the rest of the direct set is met with no session.
+var i18nSessionTemplates = map[string]bool{"recovery": true}
+
var i18nDirectTemplates = map[string]bool{
"login": true, "claim": true, "recovery": true,
"launcher_shared": true, "launcher_share_password": true, "catchall": true,
diff --git a/controller/internal/web/i18n_wiring_test.go b/controller/internal/web/i18n_wiring_test.go
index 9360200..182bb02 100644
--- a/controller/internal/web/i18n_wiring_test.go
+++ b/controller/internal/web/i18n_wiring_test.go
@@ -306,10 +306,19 @@ func TestI18nDirectRenderPagesFollowLanguage(t *testing.T) {
// 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.
- if err := s.executeTemplateLang(&buf, httptest.NewRequest(http.MethodGet, "/i18n-fixture", nil), p.tmpl, c.data()); err != nil {
+ //
+ // 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 := relativeAgeRe.ReplaceAllString(buf.String(), "# $1")
+ got := normaliseI18nRender(buf.String())
if lang == "hu" {
want, err := os.ReadFile(filepath.Join("testdata", "i18n_parity", p.caseName+".html"))
if err != nil {
diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go
index dc8ba6a..572eaca 100644
--- a/controller/internal/web/server.go
+++ b/controller/internal/web/server.go
@@ -915,17 +915,30 @@ func (s *Server) executeTemplateLang(w io.Writer, r *http.Request, name string,
lang := s.langFor(r)
if data != nil {
data["Lang"] = lang
- // v0.254.0: the globe on the pages a VISITOR meets. It posts to /lang, not to the household
- // switch, and carries NO CSRF field — there is no session to mint one from, and the handler
- // writes only a display cookie in the visitor's own browser (CsrfProtect carries the reasoning).
+ // v0.254.0: the globe, on a page rendered outside the dashboard chrome. WHICH FORM it posts to
+ // depends on WHO is reading, and getting that wrong makes the globe do nothing:
//
- // `back` is the path the visitor is ON, so the switch returns them to it. safeBackPath in the
- // handler is what makes that safe to take from an anonymous form.
+ // * NO SESSION — the sign-in and claim pages. The reader is a visitor: they have no setting
+ // and may not write the household's, so the globe posts to /lang and their choice lives in
+ // their own browser. No CSRF field: there is no session to mint one from.
+ // * WITH A SESSION — /recovery is in the AUTHENTICATED route table; its reader IS the
+ // household. Their globe must write their SETTING, because langFor deliberately ignores the
+ // cookie once there is a session — so an anonymous form here would set a cookie nothing
+ // reads and the button would appear to do nothing. Measured live on demo-hp before this
+ // branch existed.
+ //
+ // Only the language form's own fields are added, never the dashboard chrome R-543 keeps off
+ // these pages (TestI18nDirectRenderPagesHaveNoAdminChrome still passes: it names CSRFField,
+ // CSRFToken and the escrow banner, none of which appear here).
back := "/"
if r != nil && r.URL != nil && r.URL.Path != "" {
back = r.URL.Path
}
- addLangOptions(data, lang, langCookiePath, back, "")
+ if s.hasSession(r) {
+ addLangOptions(data, lang, "/settings/language", back, s.csrfField(r))
+ } else {
+ addLangOptions(data, lang, langCookiePath, back, "")
+ }
}
return s.templatesFor(lang).ExecuteTemplate(w, name, data)
}
diff --git a/controller/internal/web/testdata/i18n_parity/app_export.html b/controller/internal/web/testdata/i18n_parity/app_export.html
index 2418587..48e4b02 100644
--- a/controller/internal/web/testdata/i18n_parity/app_export.html
+++ b/controller/internal/web/testdata/i18n_parity/app_export.html
@@ -150,8 +150,8 @@
0.247.0
Kijelentkezés ↗
diff --git a/controller/internal/web/testdata/i18n_parity/claim_reset_code.html b/controller/internal/web/testdata/i18n_parity/claim_reset_code.html
index a4ddc78..cc336e4 100644
--- a/controller/internal/web/testdata/i18n_parity/claim_reset_code.html
+++ b/controller/internal/web/testdata/i18n_parity/claim_reset_code.html
@@ -29,7 +29,7 @@
Add meg az e-mailben kapott beállító kódot, majd válassz új jelszót.