package claim import ( "io" "log" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "golang.org/x/crypto/bcrypt" ) // fakeMailer records sends and captures the LAST code (test-only — the engine itself never // retains one). failNext makes the next send fail. type fakeMailer struct { sends []string // "kind:customerID:email" lastCode string failNext bool } func (f *fakeMailer) SendClaimEmail(kind, customerID, email, domain, code string) error { if f.failNext { f.failNext = false return errSend } f.sends = append(f.sends, kind+":"+customerID+":"+email) f.lastCode = code return nil } var errSend = &sendErr{} type sendErr struct{} func (*sendErr) Error() string { return "send failed" } func newTestEngine(t *testing.T) (*Engine, *store.Store, *fakeMailer) { t.Helper() st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0)) if err != nil { t.Fatalf("store.New: %v", err) } t.Cleanup(func() { st.Close() }) m := &fakeMailer{} return &Engine{Store: st, Mailer: m, Logger: log.New(io.Discard, "", 0)}, st, m } func cust() *store.CustomerConfig { return &store.CustomerConfig{CustomerID: "c1", Email: "owner@example.hu", Domain: "example.hu"} } // EnsureIssued creates the row + emails ONCE; repeated calls neither rotate nor re-send. func TestEnsureIssued_IdempotentSingleEmail(t *testing.T) { e, st, m := newTestEngine(t) cs, err := e.EnsureIssued(cust()) if err != nil { t.Fatalf("EnsureIssued: %v", err) } if cs == nil || cs.Generation != 1 || cs.CodeHash == "" { t.Fatalf("first issue: got %+v", cs) } if len(m.sends) != 1 || !strings.HasPrefix(m.sends[0], "claim:c1:") { t.Fatalf("expected exactly one claim email, got %v", m.sends) } // The stored hash must verify the emailed code and must NOT contain it (bcrypt-only custody). if bcrypt.CompareHashAndPassword([]byte(cs.CodeHash), []byte(m.lastCode)) != nil { t.Fatal("stored hash does not verify the emailed code") } if strings.Contains(cs.CodeHash, m.lastCode) { t.Fatal("plaintext code leaked into the stored hash") } cs2, err := e.EnsureIssued(cust()) if err != nil { t.Fatalf("EnsureIssued (second): %v", err) } if cs2.Generation != 1 || cs2.CodeHash != cs.CodeHash { t.Fatalf("second EnsureIssued must not rotate: gen %d hash-changed=%v", cs2.Generation, cs2.CodeHash != cs.CodeHash) } if len(m.sends) != 1 { t.Fatalf("second EnsureIssued must not re-send: %v", m.sends) } // §10 bcrypt-only storage: the DB row itself never contains the plaintext. row, err := st.GetClaim("c1") if err != nil || row == nil { t.Fatalf("GetClaim: %v %v", row, err) } if strings.Contains(row.CodeHash, m.lastCode) || row.CodeHash == m.lastCode { t.Fatal("DB row contains the plaintext code") } } // Resend rotates: generation bumps, the OLD code no longer verifies against the stored hash // (single active code). Red-proof partner: drop the generation bump in RotateClaimCode → fails. func TestResend_RotatesAndInvalidatesOldCode(t *testing.T) { e, st, m := newTestEngine(t) if _, err := e.EnsureIssued(cust()); err != nil { t.Fatalf("EnsureIssued: %v", err) } oldCode := m.lastCode if err := e.Resend(cust()); err != nil { t.Fatalf("Resend: %v", err) } cs, _ := st.GetClaim("c1") if cs.Generation != 2 { t.Fatalf("generation after resend = %d, want 2", cs.Generation) } if bcrypt.CompareHashAndPassword([]byte(cs.CodeHash), []byte(oldCode)) == nil { t.Fatal("OLD code still verifies after resend — single-active-code broken") } if bcrypt.CompareHashAndPassword([]byte(cs.CodeHash), []byte(m.lastCode)) != nil { t.Fatal("new code does not verify after resend") } if len(m.sends) != 2 || !strings.HasPrefix(m.sends[1], "claim:") { t.Fatalf("unclaimed resend should use the claim template: %v", m.sends) } } // A claimed customer's resend uses the RESET template and never clears claimed_at. func TestResend_ClaimedGetsResetTemplateAndStaysClaimed(t *testing.T) { e, st, m := newTestEngine(t) if _, err := e.EnsureIssued(cust()); err != nil { t.Fatalf("EnsureIssued: %v", err) } if err := e.MarkClaimed(cust()); err != nil { t.Fatalf("MarkClaimed: %v", err) } if err := e.Resend(cust()); err != nil { t.Fatalf("Resend: %v", err) } cs, _ := st.GetClaim("c1") if !cs.Claimed() { t.Fatal("rotation cleared claimed_at — resets must never un-claim") } last := m.sends[len(m.sends)-1] if !strings.HasPrefix(last, "reset:") { t.Fatalf("claimed resend should use the reset template, got %s", last) } } // v0.57.0 (F2) — ReissueForReenroll rotates + emails a RESET code for a CLAIMED customer whose box // was clean-slate reinstalled (fresh box has no password), and is a NO-OP for an unclaimed customer // (the first-provision path, where EnsureIssued owns the first code — re-enrolling must not rotate). func TestReissueForReenroll(t *testing.T) { t.Run("claimed rotates and sends the reset template", func(t *testing.T) { e, st, m := newTestEngine(t) if _, err := e.EnsureIssued(cust()); err != nil { t.Fatalf("EnsureIssued: %v", err) } if err := e.MarkClaimed(cust()); err != nil { t.Fatalf("MarkClaimed: %v", err) } sendsBefore := len(m.sends) gen, reissued, err := e.ReissueForReenroll(cust()) if err != nil { t.Fatalf("ReissueForReenroll: %v", err) } if !reissued { t.Fatal("a CLAIMED customer must re-issue a code on box re-enrollment") } cs, _ := st.GetClaim("c1") if gen < 2 || cs.Generation != gen { t.Fatalf("re-enroll must bump the generation once: gen=%d stored=%d", gen, cs.Generation) } if !cs.Claimed() { t.Fatal("re-issue must NEVER un-claim (reset rides rotation)") } if len(m.sends) != sendsBefore+1 || !strings.HasPrefix(m.sends[len(m.sends)-1], "reset:") { t.Fatalf("claimed re-enroll must send exactly one RESET email, got %v", m.sends) } }) t.Run("unclaimed is a no-op (first-provision path)", func(t *testing.T) { e, _, m := newTestEngine(t) if _, err := e.EnsureIssued(cust()); err != nil { // issued but NOT claimed t.Fatalf("EnsureIssued: %v", err) } sendsBefore := len(m.sends) _, reissued, err := e.ReissueForReenroll(cust()) if err != nil { t.Fatalf("ReissueForReenroll: %v", err) } if reissued { t.Fatal("an UNCLAIMED customer must NOT re-issue on re-enroll (first provision owns the code)") } if len(m.sends) != sendsBefore { t.Fatalf("no email may be sent on an unclaimed re-enroll, got %v", m.sends) } }) } // RequestReset caps at 3/day per customer, hub-side. func TestRequestReset_DailyCap(t *testing.T) { e, _, m := newTestEngine(t) if _, err := e.EnsureIssued(cust()); err != nil { t.Fatalf("EnsureIssued: %v", err) } for i := 0; i < 3; i++ { if err := e.RequestReset(cust()); err != nil { t.Fatalf("RequestReset %d: %v", i+1, err) } } sendsBefore := len(m.sends) if err := e.RequestReset(cust()); err == nil { t.Fatal("4th reset request of the day should be refused") } if len(m.sends) != sendsBefore { t.Fatal("refused reset must not send an email") } } // MarkClaimed transitions once: confirmation email exactly once, idempotent afterwards. func TestMarkClaimed_TransitionOnce(t *testing.T) { e, st, m := newTestEngine(t) if _, err := e.EnsureIssued(cust()); err != nil { t.Fatalf("EnsureIssued: %v", err) } if err := e.MarkClaimed(cust()); err != nil { t.Fatalf("MarkClaimed: %v", err) } if err := e.MarkClaimed(cust()); err != nil { t.Fatalf("MarkClaimed (repeat): %v", err) } confirms := 0 for _, s := range m.sends { if strings.HasPrefix(s, "claimed:") { confirms++ } } if confirms != 1 { t.Fatalf("claimed-confirmation emails = %d, want exactly 1", confirms) } cs, _ := st.GetClaim("c1") if !cs.Claimed() { t.Fatal("not claimed after MarkClaimed") } } // An email send failure keeps the rotated hash (gate stays armed) and surfaces the error. func TestIssue_EmailFailureKeepsGateArmed(t *testing.T) { e, st, m := newTestEngine(t) m.failNext = true cs, err := e.EnsureIssued(cust()) if err == nil { t.Fatal("EnsureIssued should surface the send failure") } if cs == nil || cs.CodeHash == "" { t.Fatal("hash must be stored (gate armed) even when the email failed") } row, _ := st.GetClaim("c1") if row == nil || row.EmailedAt != nil { t.Fatalf("emailed_at must stay unset on failure: %+v", row) } } // Reset non-DoS (Scenario C WRONG case): repeated reset REQUESTS never touch the box's password // or claimed state — the engine only rotates the CODE. The controller's password keeps working // throughout (the box side owns the password; the hub only mails codes). Red-proof partner: make // RequestReset also clear claimed_at → this fails. func TestRequestReset_NeverAltersClaimedState(t *testing.T) { e, st, _ := newTestEngine(t) if _, err := e.EnsureIssued(cust()); err != nil { t.Fatalf("EnsureIssued: %v", err) } if err := e.MarkClaimed(cust()); err != nil { t.Fatalf("MarkClaimed: %v", err) } claimedBefore, _ := st.GetClaim("c1") for i := 0; i < 3; i++ { if err := e.RequestReset(cust()); err != nil { t.Fatalf("RequestReset %d: %v", i, err) } } claimedAfter, _ := st.GetClaim("c1") if !claimedAfter.Claimed() { t.Fatal("reset requests un-claimed the box — must never happen") } if !claimedAfter.ClaimedAt.Equal(*claimedBefore.ClaimedAt) { t.Fatal("reset requests moved claimed_at") } }