Files
felhom.eu/hub/internal/claim/naming_test.go
T
admin 9167cf53af
gates / gates (push) Successful in 23s
hub v0.118.0: the household's e-mails follow the household's language (R-558 Part A)
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
2026-09-18 16:20:11 +02:00

168 lines
8.0 KiB
Go

package claim
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
"golang.org/x/crypto/bcrypt"
)
// ── R-295, HUB HALF — ONE NAME PER SECRET, AND THE MAIL NAMES THE PAGE THE MACHINE SHOWS ────────
//
// Two different secrets were both called „Visszaállító kód": the THREE-word code that gives a person
// control of the dashboard, and the TEN-word code that opens the sealed off-site backups. They are
// near-homographs of each other and of „Helyreállítási kód", and the collision cost a real code. The
// box side shipped on 2026-08-10; the hub side was dropped twice and was the last place the retired
// name survived — in the mails, which is the one surface a customer reads BEFORE they see any screen.
//
// The ruling these tests pin:
// - the three-word dashboard code is „Beállító kód" everywhere;
// - the ten-word escrow code is „Helyreállítási kód";
// - „Visszaállító kód" is RETIRED;
// - one secret in two situations keeps its NAME and changes its SENTENCE;
// - the mail names the page the machine is ACTUALLY SHOWING.
//
// THIS IS NAMING, NOT FUNCTION — TestReenrollSplit_ChangesTheMailNotTheSecret is the pin that says
// so. On the box side the companion pin is the controller's
// TestResetCode_StillAcceptedOnTheSetupPage (claim_code_naming_test.go).
const retiredName = "Visszaállító kód"
// The three code-bearing mails all name the secret „Beállító kód", and none of them carries the
// retired name. The escrow code's name must not appear in a dashboard-code mail either: naming both
// secrets in one message is how a customer comes to believe they are the same thing.
func TestFormatClaimEmail_OneNamePerSecret(t *testing.T) {
for _, kind := range []EmailKind{EmailClaim, EmailReset, EmailReenroll} {
subject, body := notify.FormatClaimEmail("hu", string(kind), "c1", "example.hu", "alma-korte-szilva")
whole := subject + "\n" + body
if !strings.Contains(body, "Beállító kód: alma-korte-szilva") {
t.Errorf("%s: the secret must be labelled „Beállító kód”; body was:\n%s", kind, body)
}
if strings.Contains(whole, retiredName) {
t.Errorf("%s: the retired name „%s” is back (subject or body)", kind, retiredName)
}
if strings.Contains(whole, "Helyreállítási kód") {
t.Errorf("%s: names the ESCROW code in a dashboard-code mail — that is the collision", kind)
}
}
}
// The two situations that deliver the SAME secret must not name the SAME page, because the machine
// does not show the same page. A box that still has a password shows a login screen carrying an
// „Elfelejtett jelszó" link; a REBUILT box has no password, renders „A szerver beállítása" and serves
// no login page at all (controller web/claim.go:279 — `reset := s.authEnabled()`).
//
// Sending a re-enrolled customer to „Elfelejtett jelszó" names a route that is not on their screen.
// That was the live defect, and this is the test that would have caught it.
func TestFormatClaimEmail_NamesThePageTheMachineShows(t *testing.T) {
_, resetBody := notify.FormatClaimEmail("hu", string(EmailReset), "c1", "example.hu", "a-b-c")
if !strings.Contains(resetBody, `"Elfelejtett jelszó"`) {
t.Errorf("the forgot-password mail should name the page that IS on that customer's screen:\n%s", resetBody)
}
_, reBody := notify.FormatClaimEmail("hu", string(EmailReenroll), "c1", "example.hu", "a-b-c")
if strings.Contains(reBody, "Elfelejtett jelszó") {
t.Errorf("a REBUILT box serves no login page, so it has no „Elfelejtett jelszó” link:\n%s", reBody)
}
if !strings.Contains(reBody, `"A szerver beállítása"`) {
t.Errorf("the re-enrol mail must name the page a rebuilt box actually shows:\n%s", reBody)
}
}
// The re-enrol mail must promise nothing about the customer's apps or backups. A clean-slate
// reinstall is exactly the situation in which such a reassurance could be false, and this project has
// spent four register rows (R-294, R-299, R-302, R-311) removing promises it could not see were still
// true. Guarding the CLAIM rather than one phrasing of it: any sentence that says the backups are
// unaffected would have to say so with one of these stems.
func TestFormatClaimEmail_ReenrollPromisesNothingAboutTheData(t *testing.T) {
_, body := notify.FormatClaimEmail("hu", string(EmailReenroll), "c1", "example.hu", "a-b-c")
for _, claimWord := range []string{"mentés", "biztonsági", "alkalmazás", "adataid", "visszaállíthat", "visszaszerezhet"} {
if strings.Contains(body, claimWord) {
t.Errorf("the re-enrol mail must not talk about data or backups (found %q):\n%s", claimWord, body)
}
}
}
// An unrecognised kind falls through to the first-setup mail. That default is SAFE (a rebuilt box
// really is in a setup state) and it is deliberately asserted, because it is the failure mode of
// adding a kind on the engine side and forgetting the template: nothing panics, nothing errors, and
// the wrong-but-harmless mail goes out. Pinning it means a future reader knows it was chosen.
func TestFormatClaimEmail_UnknownKindFallsBackToSetup(t *testing.T) {
subject, body := notify.FormatClaimEmail("hu", "no-such-kind", "c1", "example.hu", "a-b-c")
if !strings.Contains(subject, "Elindult a Felhom szervered") {
t.Errorf("unknown kind should fall back to the first-setup mail, got subject %q", subject)
}
if strings.Contains(subject+body, retiredName) {
t.Errorf("the fallback carries the retired name")
}
}
// THE ACCEPTANCE PIN. Splitting the mail KIND must not touch the secret: ReissueForReenroll still
// rotates the generation, still stores a bcrypt hash that verifies the emailed code, and still stores
// no plaintext — byte-for-byte the same custody as the reset path it was split out of. A rename that
// quietly broke acceptance would be far worse than the collision it fixes.
func TestReenrollSplit_ChangesTheMailNotTheSecret(t *testing.T) {
e, st, m := newTestEngine(t)
cc := cust()
if _, err := e.EnsureIssued(cc); err != nil {
t.Fatalf("EnsureIssued: %v", err)
}
if err := e.MarkClaimed(cc); err != nil {
t.Fatalf("MarkClaimed: %v", err)
}
before, err := st.GetClaim(cc.CustomerID)
if err != nil {
t.Fatalf("GetClaim: %v", err)
}
gen, reissued, err := e.ReissueForReenroll(cc)
if err != nil || !reissued {
t.Fatalf("ReissueForReenroll: gen=%d reissued=%v err=%v", gen, reissued, err)
}
// It is the re-enrol mail, not the reset mail — the whole point of the split.
last := m.sends[len(m.sends)-1]
if !strings.HasPrefix(last, string(EmailReenroll)+":") {
t.Errorf("a re-enrolled box must get the reenroll mail, got %q", last)
}
// …and the secret behaves exactly as before: generation advanced, the stored hash verifies the
// newly emailed code, the previous code no longer verifies, and no plaintext is persisted.
after, err := st.GetClaim(cc.CustomerID)
if err != nil {
t.Fatalf("GetClaim after: %v", err)
}
if after.Generation != before.Generation+1 {
t.Errorf("generation %d → %d, want +1", before.Generation, after.Generation)
}
if bcrypt.CompareHashAndPassword([]byte(after.CodeHash), []byte(m.lastCode)) != nil {
t.Error("the stored hash does not verify the emailed code — acceptance moved")
}
if strings.Contains(after.CodeHash, m.lastCode) {
t.Error("the stored hash contains the plaintext code")
}
}
// RequestReset — a customer who still has a password — must keep sending the RESET mail. The split
// must not have swept the genuine forgot-password path along with it: that customer IS looking at a
// login page, and „Elfelejtett jelszó" is the right thing to name for them.
func TestRequestReset_StillSendsTheResetMail(t *testing.T) {
e, _, m := newTestEngine(t)
cc := cust()
if _, err := e.EnsureIssued(cc); err != nil {
t.Fatalf("EnsureIssued: %v", err)
}
if err := e.MarkClaimed(cc); err != nil {
t.Fatalf("MarkClaimed: %v", err)
}
if err := e.RequestReset(cc); err != nil {
t.Fatalf("RequestReset: %v", err)
}
last := m.sends[len(m.sends)-1]
if !strings.HasPrefix(last, string(EmailReset)+":") {
t.Errorf("a forgot-password request must still send the reset mail, got %q", last)
}
}