hub v0.118.0: the household's e-mails follow the household's language (R-558 Part A)
gates / gates (push) Successful in 23s

The hub has written every customer e-mail in Hungarian whatever the box was set
to. The box has published its language since controller v0.247.0; nothing read
it. Now it does.

Nothing an operator reads changes. The Hungarian mails are byte-identical, and
that is a diff rather than a reading: 56 goldens per language captured from
v0.117.0 BEFORE any string moved, and all 56 Hungarian ones pass unchanged after
every sentence was routed through the new bundle.

- internal/i18n: flat bundle, 79 keys, hu authoritative + hu fallback, ceiling 0.
- customerMessages/severityLabels are DERIVED from the bundle, so a sentence is
  written in one place and all 40+ tests that read those maps still work.
- Language order: last reported -> created-with -> hu. reports.language defaults
  to EMPTY, never hu: "never told us" is not "chose Hungarian".
- message_customer on POST /api/v1/event, additive and optional forever, for the
  sentences the box composes and the hub cannot translate.
- The bind page is per-language, and its `expired` state stays Hungarian: it is
  the state an unknown token lands in, so rendering a real English customer's
  token in English would make the LANGUAGE answer what the TEXT refuses to.

Two defects found inside the release:
- R-581: the newest report was picked by received_at, which has SECOND
  granularity, so same-second reports tied and the winner was arbitrary. Ordered
  by the autoincrement id now. GetCustomers() still has the shape - row open.
- R-582: the English copy-guard stems, ported word for word from Hungarian,
  convicted 141 honest sentences. The English claim is a phrase with a modal.

R-555 closed: the language allowlist entry is out of wire_contract_gate.py.
hub_copy_gate.py follows the sentences into the bundle - without that it would
have scanned four files that no longer hold any customer text and reported
success. Three new decoys incl. an innocent control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-18 16:20:11 +02:00
parent 20aafc3dec
commit 9167cf53af
150 changed files with 4063 additions and 410 deletions
+117
View File
@@ -0,0 +1,117 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/i18n"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// The public bind page in the household's language (R-558), and the property that constrains it.
func bindFixture(t *testing.T, lang string) (*Server, string) {
t.Helper()
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "acme", CustomerName: "Acme", Domain: "acme.example",
RetrievalPassword: "p", APIKey: "k", ConfigJSON: "{}", Language: lang,
}); err != nil {
t.Fatal(err)
}
token := "0123456789abcdef0123456789abcdef"
if err := st.MintSelfBindToken("acme", selfBindHash(token), 7*24*time.Hour); err != nil {
t.Fatal(err)
}
return s, token
}
func getBind(t *testing.T, s *Server, token string) string {
t.Helper()
rec := httptest.NewRecorder()
s.handleBind(rec, httptest.NewRequest(http.MethodGet, "/bind/"+token, nil))
return rec.Body.String()
}
// The page follows the CUSTOMER'S CREATION-TIME language, because no box has reported at bind time.
func TestBindPageFollowsTheCreationLanguage(t *testing.T) {
b := i18n.Shared()
sHU, tokHU := bindFixture(t, "hu")
huPage := getBind(t, sHU, tokHU)
if !strings.Contains(huPage, b.Msg("hu", "bind.label.pairing")) {
t.Errorf("the Hungarian bind page does not carry the Hungarian pairing label")
}
if !strings.Contains(huPage, `<html lang="hu">`) {
t.Error("the Hungarian page does not declare lang=hu")
}
sEN, tokEN := bindFixture(t, "en")
enPage := getBind(t, sEN, tokEN)
if !strings.Contains(enPage, b.Msg("en", "bind.label.pairing")) {
t.Errorf("the English bind page does not carry the English pairing label:\n%s", enPage)
}
if !strings.Contains(enPage, `<html lang="en">`) {
t.Error("the English page does not declare lang=en — a screen reader would read English aloud in Hungarian")
}
// NEGATIVE CONTROL: the English page must not still carry the Hungarian sentence.
if strings.Contains(enPage, b.Msg("hu", "bind.lead")) {
t.Error("the English page still carries the Hungarian lead")
}
if huPage == enPage {
t.Fatal("both languages rendered the same page — the control proves nothing")
}
}
// THE NO-ORACLE PROPERTY. This page folds an unknown token into "expired" so a stranger cannot learn
// whether a link was ever real. The LANGUAGE must not answer what the TEXT refuses to: if a real
// English customer's expired token rendered in English while an unknown token rendered in Hungarian,
// the page would confirm the token for every non-Hungarian customer.
func TestBindExpiredIsAlwaysDefaultLanguage(t *testing.T) {
s, _ := bindFixture(t, "en")
// A real token belonging to an ENGLISH customer, expired.
expiredTok := "ffffffffffffffffffffffffffffffff"
if err := mintExpired(t, s, "acme", expiredTok); err != nil {
t.Fatal(err)
}
realExpired := getBind(t, s, expiredTok)
// A token that was never real.
unknown := getBind(t, s, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
if realExpired != unknown {
t.Errorf("an expired REAL token and an unknown token render differently — the page is an "+
"oracle for whether a link existed.\n--- real ---\n%s\n--- unknown ---\n%s", realExpired, unknown)
}
b := i18n.Shared()
if !strings.Contains(realExpired, b.Msg(i18n.Default, "bind.invalid.lead")) {
t.Errorf("the expired page is not in the default language:\n%s", realExpired)
}
if strings.Contains(realExpired, b.Msg("en", "bind.invalid.lead")) {
t.Error("the expired page rendered in the customer's language — it leaks that the token is real")
}
}
func mintExpired(t *testing.T, s *Server, customerID, token string) error {
t.Helper()
// A negative TTL mints a token that is already past its expiry.
return s.store.MintSelfBindToken(customerID, selfBindHash(token), -time.Hour)
}
// Every state of the page renders in both languages without a marker or a blank escaping into it.
func TestBindPageHasNoUnsubstitutedMarkers(t *testing.T) {
for _, lang := range i18n.Supported {
s, tok := bindFixture(t, lang)
page := getBind(t, s, tok)
if strings.Contains(page, "{{T ") || strings.Contains(page, "!bind.") {
t.Errorf("%s page carries an unsubstituted marker or a missing key:\n%s", lang, page)
}
if strings.Contains(page, "<title></title>") {
t.Errorf("%s page has an empty title", lang)
}
}
}
+13
View File
@@ -17,6 +17,8 @@ import (
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"gitea.dooplex.hu/admin/felhom-hub/internal/i18n"
)
var validCustomerID = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`)
@@ -732,6 +734,9 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
// v0.51.0: the DR-tier flag (the form checkbox defaults ON for new customers). Set
// BEFORE applyOffsite — offsite provisioning is refused without the DR tier (F-6).
DRTier: formBool(r, "dr_tier"),
// v0.118.0 (R-558): the customer-mail language. An absent or unknown value is Hungarian
// (createdLanguage), so an operator page that never posts the field keeps today's behaviour.
Language: strings.TrimSpace(r.FormValue("language")),
}
// Offsite provisioning (fail-closed): a provisioning error must NOT save a half-enabled config.
@@ -795,6 +800,10 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust
// v0.51.0: the DR-tier flag — set BEFORE applyOffsite (offsite requires the tier) and
// applyPBSDR (which converges the descriptor toward it).
cfg.DRTier = formBool(r, "dr_tier")
// v0.118.0 (R-558): the customer-mail language default. Editing it changes the mails sent
// before a box reports (claim, bind); it does NOT overrule a household that has chosen on
// their own dashboard — CustomerLanguage prefers the reported one.
cfg.Language = strings.TrimSpace(r.FormValue("language"))
// Server-side twin of the form's required attributes (v0.48.0 — B3). The error re-render is
// the STANDALONE page and carries the SUBMITTED overrides, so nothing the operator typed is
@@ -1446,6 +1455,10 @@ func (s *Server) handleCreateConfigFromReport(w http.ResponseWriter, r *http.Req
RetrievalPassword: retrievalPassword,
APIKey: apiKey,
ConfigJSON: "{}",
// No operator is present on this path (a config invented from a box's own report), so the
// language is the default. Stated rather than left blank: createdLanguage would supply the
// same value, and a reader should not have to know that to see what a box gets here.
Language: i18n.Default,
}
if err := s.store.SaveCustomerConfig(cfg); err != nil {
+2 -2
View File
@@ -40,7 +40,7 @@ func TestCustomerPage_CreatedFlashTellsTheOperatorToHandOverThePhrase(t *testing
}
func TestSelfBindEmail_SaysWhoHandedOverThePhrase(t *testing.T) {
_, body := notify.FormatSelfBindEmail("acme", "https://hub.example/bind/tok")
_, body := notify.FormatSelfBindEmail("hu", "acme", "https://hub.example/bind/tok")
if strings.Contains(body, "beállításkor kaptál") {
t.Error("the mail still says the customer received the phrase „a beállításkor” — nothing delivers it then")
@@ -59,7 +59,7 @@ func TestSelfBindEmail_SaysWhoHandedOverThePhrase(t *testing.T) {
// the link text or a helper.
func TestSelfBindEmail_NeverCarriesThePhrase(t *testing.T) {
const phrase = "alma korte szilva barack meggy"
_, body := notify.FormatSelfBindEmail("acme", "https://hub.example/bind/tok")
_, body := notify.FormatSelfBindEmail("hu", "acme", "https://hub.example/bind/tok")
for _, w := range strings.Fields(phrase) {
if strings.Contains(body, w) {
t.Errorf("the self-bind mail contains a passphrase word %q", w)
+91 -34
View File
@@ -4,12 +4,15 @@ import (
"crypto/subtle"
"html/template"
"net/http"
"regexp"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"gitea.dooplex.hu/admin/felhom-hub/internal/i18n"
)
// selfbind.go — the CUSTOMER side of self-bind (v0.66.0, R-27 slice 1): the PUBLIC /bind/<token> page.
@@ -97,14 +100,50 @@ type bindPageData struct {
State string // "form" | "success" | "expired" | "consumed" | "locked"
Token string // echoed into the form action (the capability itself; already in the URL)
Failed bool // generic factor-check failure (form state only)
// Lang is the language to render in. It is the CUSTOMER'S CREATION-TIME language and nothing
// else: no box has reported yet at bind time, and this page deliberately offers no switch —
// the person opening it is a stranger holding a capability URL, exactly like the guest share
// pages, and a language control here would be a setting a stranger could touch.
Lang string
}
var bindTemplate = template.Must(template.New("bind").Parse(bindPageHTML))
// bindTemplates holds ONE PARSED TEMPLATE PER LANGUAGE, with the bundle's text substituted into the
// markup BEFORE html/template parses it.
//
// This is the controller's design (10-localisation.md rule 3) and it is chosen for the same reason:
// the Hungarian set is parsed from exactly the bytes the page carried before it was converted, in
// the same escaping contexts, so the Hungarian page is byte-identical by construction rather than by
// inspection. A runtime T function would route every string through the contextual escaper and move
// bytes (`—`, quotes) in ways nobody would notice until a customer saw them.
var bindTemplates = buildBindTemplates()
func buildBindTemplates() map[string]*template.Template {
out := make(map[string]*template.Template, len(i18n.Supported))
b := i18n.Shared()
for _, lang := range i18n.Supported {
html := i18nMarkerRe.ReplaceAllStringFunc(bindPageHTML, func(m string) string {
return b.Msg(lang, i18nMarkerRe.FindStringSubmatch(m)[1])
})
// Must: a template that fails to parse is a broken build, not a broken request. It fails
// at startup and in every test, rather than serving a stranger a blank page.
out[lang] = template.Must(template.New("bind-" + lang).Parse(html))
}
return out
}
// i18nMarkerRe matches a {{T "key"}} marker. Strict on purpose: a malformed marker is NOT
// substituted, so it reaches html/template as a call to an undefined function and the build fails
// loudly — never a marker shown to a customer.
var i18nMarkerRe = regexp.MustCompile(`\{\{\s*T\s+"([A-Za-z0-9_.\-]+)"\s*\}\}`)
func (s *Server) renderBind(w http.ResponseWriter, status int, data bindPageData) {
tmpl, ok := bindTemplates[i18n.Normalize(data.Lang)]
if !ok {
tmpl = bindTemplates[i18n.Default]
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := bindTemplate.Execute(w, data); err != nil {
if err := tmpl.Execute(w, data); err != nil {
s.logger.Printf("[ERROR] rendering /bind page: %v", err)
}
}
@@ -114,14 +153,14 @@ func (s *Server) renderBind(w http.ResponseWriter, status int, data bindPageData
// customer self-bind). Reached only via isPublicBindPath — auth + CSRF exempt at the gate sites.
func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) {
if s.bindLimiter != nil && !s.bindLimiter.allow(bindClientIP(r)) {
s.renderBind(w, http.StatusTooManyRequests, bindPageData{State: "expired"})
s.renderBind(w, http.StatusTooManyRequests, bindPageData{State: "expired", Lang: i18n.Default})
return
}
token := strings.TrimPrefix(r.URL.Path, "/bind/")
// A trailing segment only — reject anything with further path structure (defence in depth atop
// the ServeMux path-clean; the token is a flat hex string).
if token == "" || strings.Contains(token, "/") {
s.renderBind(w, http.StatusNotFound, bindPageData{State: "expired"})
s.renderBind(w, http.StatusNotFound, bindPageData{State: "expired", Lang: i18n.Default})
return
}
hash := selfBindHash(token)
@@ -133,22 +172,40 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) {
}
now := time.Now()
// THE LANGUAGE IS ITSELF AN ORACLE, so it is resolved with the same care as the text (R-558).
//
// This page folds an UNKNOWN token into "expired" precisely so a stranger cannot learn whether a
// link was ever real. If the page then rendered a real customer's token in English and an unknown
// one in Hungarian, the LANGUAGE would answer the question the TEXT refuses to — for every
// customer who is not Hungarian. So:
//
// - the "expired" state ALWAYS renders in the default language, because that is the state an
// unknown token lands in and the two must be indistinguishable;
// - every other state already discloses that the token is real (its text says so), so those
// may follow the customer.
//
// Pinned by TestBindExpiredIsAlwaysDefaultLanguage.
lang := i18n.Default
if tok != nil {
lang = s.store.CustomerLanguage(tok.CustomerID)
}
// Terminal link states — identical for GET and POST, no factor check attempted. An unknown token
// (nil) is folded into "expired": no oracle for "was this link ever real".
switch {
case tok == nil || tok.Expired(now):
s.renderBind(w, http.StatusOK, bindPageData{State: "expired"})
s.renderBind(w, http.StatusOK, bindPageData{State: "expired", Lang: i18n.Default})
return
case tok.Consumed():
s.renderBind(w, http.StatusOK, bindPageData{State: "consumed"})
s.renderBind(w, http.StatusOK, bindPageData{State: "consumed", Lang: lang})
return
case tok.Locked:
s.renderBind(w, http.StatusOK, bindPageData{State: "locked"})
s.renderBind(w, http.StatusOK, bindPageData{State: "locked", Lang: lang})
return
}
if r.Method != http.MethodPost {
s.renderBind(w, http.StatusOK, bindPageData{State: "form", Token: token})
s.renderBind(w, http.StatusOK, bindPageData{State: "form", Token: token, Lang: lang})
return
}
@@ -181,10 +238,10 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) {
// COUNTS only — never which factor failed, never the secrets, never the raw token.
s.logger.Printf("[WARN] self-bind attempt %d/%d failed for token %s… (customer %s)", attempts, store.SelfBindMaxAttempts, hash[:8], tok.CustomerID)
if locked {
s.renderBind(w, http.StatusOK, bindPageData{State: "locked"})
s.renderBind(w, http.StatusOK, bindPageData{State: "locked", Lang: lang})
return
}
s.renderBind(w, http.StatusOK, bindPageData{State: "form", Token: token, Failed: true})
s.renderBind(w, http.StatusOK, bindPageData{State: "form", Token: token, Failed: true, Lang: lang})
return
}
@@ -197,14 +254,14 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) {
}
if !consumed {
// Lost the race (a concurrent request consumed it) — it is already being bound.
s.renderBind(w, http.StatusOK, bindPageData{State: "consumed"})
s.renderBind(w, http.StatusOK, bindPageData{State: "consumed", Lang: lang})
return
}
if err := s.store.BindAppliance(appliance.ID, tok.CustomerID, "appliance", ""); err != nil {
// Rare: the appliance became unbindable (operator discarded it) between lookup and bind. The
// token is spent; surface a neutral generic failure rather than an appliance-state oracle.
s.logger.Printf("[WARN] self-bind: BindAppliance %d → %s failed after factor match: %v", appliance.ID, tok.CustomerID, err)
s.renderBind(w, http.StatusOK, bindPageData{State: "form", Failed: true})
s.renderBind(w, http.StatusOK, bindPageData{State: "form", Failed: true, Lang: lang})
return
}
if _, err := s.store.SaveEvent(tok.CustomerID, "appliance_bound", "info",
@@ -212,7 +269,7 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[WARN] self-bind: save event for %s: %v", tok.CustomerID, err)
}
s.logger.Printf("[INFO] self-bind SUCCESS: appliance %d bound to customer %s by customer self-service (token %s…)", appliance.ID, tok.CustomerID, hash[:8])
s.renderBind(w, http.StatusOK, bindPageData{State: "success"})
s.renderBind(w, http.StatusOK, bindPageData{State: "success", Lang: lang})
}
// bindPageHTML is the self-contained public page. It CANNOT link /style.css (that route is
@@ -220,12 +277,12 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) {
// surface, 2px radius, hairline rules, exception color for the failure banner. Hungarian, adult tone,
// no emoji. It renders NO appliance data in any state.
const bindPageHTML = `<!DOCTYPE html>
<html lang="hu">
<html lang="{{T "bind.htmllang"}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Felhom — Doboz összekötése</title>
<title>{{T "bind.title"}}</title>
<style>
:root { --navy:#0A2540; --ink:#0A2540; --muted:#5b6b7d; --line:#d8e0e8; --brand:#0083D8; --exc:#c0392b; --bg:#f4f7fa; }
* { box-sizing: border-box; }
@@ -250,32 +307,32 @@ const bindPageHTML = `<!DOCTYPE html>
<body>
<div class="wrap">
<div class="card">
<h1>Felhom <span>doboz</span> összekötése</h1>
<h1>{{T "bind.heading"}}</h1>
{{if eq .State "form"}}
<p class="lead">Kösd össze a most telepített Felhom dobozodat a fiókoddal. Add meg a doboz képernyőjén látható párosító kódot és a tulajdonosi jelmondatodat.</p>
{{if .Failed}}<div class="banner">A megadott adatok nem megfelelőek. Ellenőrizd a párosító kódot és a tulajdonosi jelmondatot, majd próbáld újra.</div>{{end}}
<p class="lead">{{T "bind.lead"}}</p>
{{if .Failed}}<div class="banner">{{T "bind.failed"}}</div>{{end}}
<form method="POST" action="/bind/{{.Token}}">
<label for="pairing_code">Párosító kód</label>
<input class="code" type="text" id="pairing_code" name="pairing_code" autocomplete="off" autocapitalize="characters" spellcheck="false" required autofocus placeholder="ABC-234">
<p class="hint">A doboz monitorán jelenik meg, a telepítés után.</p>
<label for="passphrase">Tulajdonosi jelmondat</label>
<input type="text" id="passphrase" name="passphrase" autocomplete="off" spellcheck="false" required placeholder="öt szó, kötőjellel vagy szóközzel">
<p class="hint">Az öt szóból álló kifejezés, amelyet a beállításkor kaptál. Ez igazolja, hogy a fiók a tiéd.</p>
<button type="submit">Összekötés</button>
<label for="pairing_code">{{T "bind.label.pairing"}}</label>
<input class="code" type="text" id="pairing_code" name="pairing_code" autocomplete="off" autocapitalize="characters" spellcheck="false" required autofocus placeholder="{{T "bind.placeholder.pairing"}}">
<p class="hint">{{T "bind.hint.pairing"}}</p>
<label for="passphrase">{{T "bind.label.passphrase"}}</label>
<input type="text" id="passphrase" name="passphrase" autocomplete="off" spellcheck="false" required placeholder="{{T "bind.placeholder.passphrase"}}">
<p class="hint">{{T "bind.hint.passphrase"}}</p>
<button type="submit">{{T "bind.submit"}}</button>
</form>
<p class="note">Biztonsági okból 5 sikertelen próbálkozás után a hivatkozás zárolódik. Ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal.</p>
<p class="note">{{T "bind.note"}}</p>
{{else if eq .State "success"}}
<p class="lead ok">Sikeres összekötés.</p>
<p>A doboz kb. egy percen belül folytatja a telepítést. Ezt az oldalt bezárhatod — a beállítás a háttérben befejeződik, és a vezérlőpultod hamarosan elérhető lesz.</p>
<p class="lead ok">{{T "bind.success.lead"}}</p>
<p>{{T "bind.success.body"}}</p>
{{else if eq .State "consumed"}}
<p class="lead">Ez a hivatkozás már fel lett használva.</p>
<p>A doboz összekötése megtörtént. Ha úgy gondolod, hogy ez tévedés, vedd fel a kapcsolatot az ügyfélszolgálattal.</p>
<p class="lead">{{T "bind.consumed.lead"}}</p>
<p>{{T "bind.consumed.body"}}</p>
{{else if eq .State "locked"}}
<p class="lead">Ez a hivatkozás zárolva van.</p>
<p>Túl sok sikertelen próbálkozás történt. Biztonsági okból a hivatkozás zárolódott — kérjük, vedd fel a kapcsolatot az ügyfélszolgálattal a doboz összekötéséhez.</p>
<p class="lead">{{T "bind.locked.lead"}}</p>
<p>{{T "bind.locked.body"}}</p>
{{else}}
<p class="lead">Ez a hivatkozás érvénytelen vagy lejárt.</p>
<p>A hivatkozás 7 napig érvényes. Ha lejárt, kérj újat az ügyfélszolgálattól, vagy az összekötést az üzemeltető is elvégezheti.</p>
<p class="lead">{{T "bind.invalid.lead"}}</p>
<p>{{T "bind.invalid.body"}}</p>
{{end}}
</div>
<p class="foot">Felhom.eu</p>
+1 -1
View File
@@ -90,7 +90,7 @@ func TestSelfBind_FailureBannerUsesTheSameName(t *testing.T) {
// The e-mail that carries the link names it identically. The mail is read BEFORE the page, so a
// mismatch here is the customer's first impression of two different secrets.
func TestSelfBindEmail_UsesTheSameName(t *testing.T) {
subject, body := notify.FormatSelfBindEmail("acme", "https://hub.example/bind/tok")
subject, body := notify.FormatSelfBindEmail("hu", "acme", "https://hub.example/bind/tok")
if !strings.Contains(body, "tulajdonosi jelmondatodat") {
t.Errorf("the self-bind mail does not name the secret „Tulajdonosi jelmondat”:\n%s", body)
+4 -4
View File
@@ -21,8 +21,8 @@ import (
const (
testPass = "alpha beta gamma delta epsilon" // the customer retrieval passphrase (5 words)
testCode = "ABC234" // an appliance console pairing code (raw stored form)
testCodeFmt = "abc-234" // as a human might type it (lowercased, separated)
testCode = "ABC234" // an appliance console pairing code (raw stored form)
testCodeFmt = "abc-234" // as a human might type it (lowercased, separated)
)
// selfBindSetup seeds a customer (with passphrase + email) and one registered appliance carrying the
@@ -123,8 +123,8 @@ func TestSelfBind_B_NoOracle(t *testing.T) {
selfBindSetup(t, st, "acme", testCode)
selfBindSetup(t, st, "acme2", "XYZ789")
t1 := mintLink(t, st, "acme", selfBindTTL) // wrong-code attempt (right passphrase)
t2 := mintLink(t, st, "acme2", selfBindTTL) // wrong-passphrase attempt (right code)
t1 := mintLink(t, st, "acme", selfBindTTL) // wrong-code attempt (right passphrase)
t2 := mintLink(t, st, "acme2", selfBindTTL) // wrong-passphrase attempt (right code)
wrongCode := strings.ReplaceAll(bindPOST(t, s, t1, "ZZZ999", testPass).Body.String(), t1, "TOKEN")
wrongPass := strings.ReplaceAll(bindPOST(t, s, t2, "xyz-789", "wrong words here now").Body.String(), t2, "TOKEN")
@@ -34,6 +34,16 @@
value="{{.Config.Email}}"
placeholder="e.g. kovacs@example.com">
</div>
<div class="form-group">
<label for="language">Customer e-mail language</label>
<select id="language" name="language">
<option value="hu"{{if ne .Config.Language "en"}} selected{{end}}>Magyar</option>
<option value="en"{{if eq .Config.Language "en"}} selected{{end}}>English</option>
</select>
<small>A DEFAULT, not a setting. It is the language of the claim e-mail and
the bind page, and the language the box starts in. The moment the household
picks one on their own dashboard, theirs wins.</small>
</div>
</div>
</div>