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(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(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(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(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("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) } }