Files
felhom.eu/hub/internal/claim/engine_test.go
T
admin 7747a16ff1 feat(hub): v0.57.0 reinstall-of-existing-customer arc — claim/offsite/escrow continuity
F2 claim re-issue on clean-slate re-enroll (ReissueForReenroll, host-enroll mint path,
single-bump, reset code; hub never stores the password so fork B). F3 offsite re-issue on
re-enroll (ReissueOffsiteForCustomer, same machinery as the manual button). 2.3 escrow honesty
(red-proofed): re-issuing offsite marks the escrow stale (MarkEscrowStale), withholds the
mismatched restic hash from auto-confirm, DR checklist shows stale not done. Events:
claim_reissued_reenroll / offsite_reissued / escrow_stale.

Controller + scripts unchanged (source contradicted both premises): the controller reads escrow
prereqs live from the agent; the installer can't know the descriptor-provisioned storage id. F4
root fix is agent-side -> ROADMAP R-22; demo unblocked live (Part 0 ACL grant). VALIDATION doc
F2 erratum + F3/F4 dispositions. Green gate + Scenario-C red-proof pass.
2026-07-16 18:00:13 +02:00

282 lines
9.2 KiB
Go

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