Files
felhom.eu/hub/internal/claim/engine_test.go
T
admin 4d6ec7c7bb
gates / gates (push) Successful in 14s
hub v0.104.0: the guest network gets a reader (R-319), and the hub half of the naming (R-295)
Four paper debts and one fact given a reader. Hub-only — nothing to bake.

A4 — the entry about "the tester's machine" named a risk correctly and labelled it
in a way that invited deleting it. Established from the hub's own store: `peti-felhom`
is a REAL machine (482 reports, 2026-02-27 → 2026-07-15, a named person's own box) and
the 3.6 GB with no key and no backup is real. `david` → `tester-1` is a DIFFERENT record
with no host, no escrow and no report, ever — deleted 07:55:49 and re-created 07:56:47
this morning. The prompt's premise conflated the two; the register now says which is which.

A1 — R-312/R-313/R-303 recorded as DECIDED with their re-open triggers, and moved out
of STATUS's "Waiting on you", which is now empty.

A3 — day0-install §C.1 said pushing the installer publishes it. It has not since
R-110. Corrected, with the two manifest pins named and an outside-verification command;
the one copy that repeated it (a dated audit, true when written) carries a superseded note.

A5 — standing rule 5: evidence comes off the machine at the end of the phase that
produced it, before any revert. Earned twice in three days on the same box at the same
point (R-320). Four homes, plus what to do when it is already gone.

R-295 hub half — „Beállító kód" everywhere; „Visszaállító kód" retired. New `reenroll`
mail kind so the mail names the page a REBUILT box actually shows („A szerver
beállítása"), not the „Elfelejtett jelszó" page it has no login screen to reach.
Naming only; the acceptance pin proves the secret is untouched.

R-319 — the hub models `guest_net` after 23 days of receiving and discarding it. The
signal is `heals_last_hour`, not `state`: a guest the watchdog keeps repairing reads
healthy between repairs. `heal_succeeded` decoded too (R-260's lesson). Unknown is never
drawn as healthy — three absences, three sentences. No alarm, deliberately.
Three red-proofs, mutations asserted applied. Wire-gate checked tags 182 → 190.

B1 — the operator's 2026-08-12 dispositions were NOT in the register; they are now.
Third allowlist kind for the five ruled "no reader wanted"; `reporting_disabled`
reclassified redundant. 8 read · 5 deliberately unread · 1 redundant · 6 still owed.

Also filed: R-321 (a deliberately-silent box still alarms stale/down — the checker is
age-only, and decoding the flag would not have fixed it), R-322 (the claim guard has
never scanned the hub; a hand scan returns zero, so it is a scope gap, not a defect).
2026-08-13 10:50:12 +02:00

288 lines
9.7 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)")
}
// R-295 hub half (2026-08-13): this used to assert "reset:". The KIND was split — same
// secret, same name („Beállító kód"), different SENTENCE — because a rebuilt box has no
// password, so it shows „A szerver beállítása" and serves no login page, and the reset mail
// sent the customer to an „Elfelejtett jelszó" page that is not on their screen. The
// assertion is deliberately kept STRICT rather than loosened to "either kind": routing a
// re-enrolment back down the reset copy is exactly the regression worth failing on.
if len(m.sends) != sendsBefore+1 || !strings.HasPrefix(m.sends[len(m.sends)-1], "reenroll:") {
t.Fatalf("claimed re-enroll must send exactly one REENROLL 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")
}
}