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
168 lines
8.0 KiB
Go
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)
|
|
}
|
|
}
|