9167cf53af
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
133 lines
6.4 KiB
Go
133 lines
6.4 KiB
Go
package web
|
|
|
|
// R-323 — THE THIRD NAME. One secret, one name, and none of them a near-homograph of another.
|
|
//
|
|
// Three secrets a customer can hold, and they must be tellable apart by a hurried reader:
|
|
//
|
|
// three words takes control of the dashboard „Beállító kód"
|
|
// ten words opens the sealed off-site backups „Helyreállítási kód"
|
|
// five words proves the account owns the box being bound „Tulajdonosi jelmondat" ← this one
|
|
//
|
|
// The five-word phrase used to be „Visszaállító jelszó" — one word away from „Visszaállító kód",
|
|
// which R-295 had just retired for colliding with „Helyreállítási kód". Found while shipping that
|
|
// rename and deliberately NOT swept in with it; ruled on separately by the operator 2026-08-13.
|
|
//
|
|
// WHY NOT THE OBVIOUS CANDIDATES, since a test is where the reasoning survives:
|
|
//
|
|
// „Összekötési jelszó" — the OTHER factor on this very page is the „Párosító kód". Naming this one
|
|
// after the same act would leave the two factors a customer types in one sitting separated
|
|
// only by kód-versus-jelszó, which is structurally the „Visszaállító kód"/„Visszaállító
|
|
// jelszó" trap being removed.
|
|
// „Fiókjelszó" — there IS an account password (the dashboard login). This would collide
|
|
// with a DIFFERENT real secret, i.e. trade one homograph for a worse one.
|
|
//
|
|
// „Tulajdonosi jelmondat" is distinct on BOTH axes — stem (Tulajdonosi vs Beállító / Helyreállítási
|
|
// / Párosító) and noun (jelmondat vs kód / jelszó) — and it says what the phrase actually does.
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
)
|
|
|
|
// retiredThirdName is the name this change removes. It is a SUBSTRING check on the stem so that a
|
|
// possessive or accusative form („visszaállító jelszavadat") cannot slip past a check written
|
|
// against the nominative — the R-299 lesson, where a guard matched one inflection of a Hungarian
|
|
// verb and the plural walked straight by.
|
|
const retiredThirdName = "isszaállító jelsz"
|
|
|
|
// The binding page names the secret „Tulajdonosi jelmondat" and nowhere carries the retired name.
|
|
// Driven through the real handler, so a template that never renders is visible here.
|
|
func TestSelfBind_ThirdSecretNaming(t *testing.T) {
|
|
s, st := newTestServer(t)
|
|
selfBindSetup(t, st, "acme", testCode)
|
|
token := mintLink(t, st, "acme", selfBindTTL)
|
|
|
|
body := bindGET(t, s, token).Body.String()
|
|
|
|
if !strings.Contains(body, "Tulajdonosi jelmondat") {
|
|
t.Errorf("the field label must name the secret „Tulajdonosi jelmondat”:\n%s", body)
|
|
}
|
|
if strings.Contains(body, retiredThirdName) {
|
|
t.Error("the retired name „Visszaállító jelszó” is back on the binding page")
|
|
}
|
|
// The two factors on this page must not be near-homographs of each other. The pairing code keeps
|
|
// its name; what matters is that the SECOND factor no longer differs from it by one noun.
|
|
if !strings.Contains(body, "Párosító kód") {
|
|
t.Error("the pairing code lost its name — the two factors must both be named")
|
|
}
|
|
// Neither of the OTHER two secrets may be named on this page: a customer who reads „kód" here
|
|
// twice is the failure this whole arc exists to prevent.
|
|
for _, other := range []string{"Beállító kód", "Helyreállítási kód"} {
|
|
if strings.Contains(body, other) {
|
|
t.Errorf("the binding page names a different secret (%q) — that is the collision", other)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The failure banner must name the secret the same way the label does. A page that asks for a
|
|
// „Tulajdonosi jelmondat" and then complains about „a jelszó" has two names for one secret again,
|
|
// which is the defect in miniature.
|
|
func TestSelfBind_FailureBannerUsesTheSameName(t *testing.T) {
|
|
s, st := newTestServer(t)
|
|
selfBindSetup(t, st, "acme", testCode)
|
|
token := mintLink(t, st, "acme", selfBindTTL)
|
|
|
|
rr := bindPOST(t, s, token, testCodeFmt, "wrong wrong wrong wrong wrong")
|
|
body := rr.Body.String()
|
|
|
|
if !strings.Contains(body, "tulajdonosi jelmondat") {
|
|
t.Errorf("the failure banner does not name the secret the way the label does:\n%s", body)
|
|
}
|
|
if strings.Contains(body, retiredThirdName) {
|
|
t.Error("the retired name is on the failure banner")
|
|
}
|
|
}
|
|
|
|
// 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("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)
|
|
}
|
|
if strings.Contains(subject+body, retiredThirdName) {
|
|
t.Error("the retired name is still in the self-bind mail")
|
|
}
|
|
// It must still say what the thing IS — a rename that leaves the customer unable to recognise
|
|
// what they are holding has fixed a collision and broken the delivery.
|
|
if !strings.Contains(body, "5 szóból álló kifejezést") {
|
|
t.Error("the mail no longer says the phrase is five words")
|
|
}
|
|
}
|
|
|
|
// THE ACCEPTANCE PIN — the whole point of calling this naming rather than function.
|
|
//
|
|
// The SAME phrase, typed the same messy way a human types it (odd spacing, mixed case, mixed
|
|
// separators), still binds the appliance after the rename. If this ever goes red, the rename stopped
|
|
// being a rename.
|
|
func TestSelfBindPassphrase_StillAcceptedAfterTheRename(t *testing.T) {
|
|
s, st := newTestServer(t)
|
|
id := selfBindSetup(t, st, "acme", testCode)
|
|
token := mintLink(t, st, "acme", selfBindTTL)
|
|
|
|
rr := bindPOST(t, s, token, testCodeFmt, " Alpha Beta gamma-delta epsilon ")
|
|
|
|
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "egy percen belül") {
|
|
t.Fatalf("the same passphrase stopped being accepted: code=%d body=%q", rr.Code, rr.Body.String())
|
|
}
|
|
if a, _ := st.GetAppliance(id); a == nil || a.Status != store.ApplianceBound {
|
|
t.Fatalf("the appliance did not bind: %+v", a)
|
|
}
|
|
// The form field NAME is deliberately still `passphrase`: renaming customer copy must not touch
|
|
// the wire. A changed field name would be an acceptance change wearing a rename's clothes.
|
|
body := bindGET(t, s, mintLink(t, st, "acme", selfBindTTL)).Body.String()
|
|
if !strings.Contains(body, `name="passphrase"`) {
|
|
t.Error("the form field name changed — that is not a rename, that is a wire change")
|
|
}
|
|
}
|