NextMyDay/internal/core/recovery/recovery_test.go
Claus Lohmar 4c839ff30d feat(recovery): add the admin recovery flow (SPEC-007)
Config-flag → email user 1 an enrolment link (D-033), with the approve/deny
actions and dual control.
2026-10-14 10:53:53 +00:00

112 lines
3.5 KiB
Go

package recovery_test
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/recovery"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
func newStore(t *testing.T, opts ...recovery.Option) (*recovery.Store, *store.DB, int64) {
t.Helper()
ctx := context.Background()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
aud := audit.New(db.DB)
us := users.New(db.DB, aud)
admin, err := us.Create(ctx, 0, "admin@example.com", "A", users.RoleAdmin)
if err != nil {
t.Fatalf("create admin: %v", err)
}
return recovery.NewStore(db.DB, aud, opts...), db, admin
}
// TestCreateIsIdempotent: a second create reuses the live pending request and
// reports created=false (so the chain is not notified twice).
func TestCreateIsIdempotent(t *testing.T) {
ctx := context.Background()
s, _, uid := newStore(t)
first, created, err := s.Create(ctx, uid, "127.0.0.1")
if err != nil || !created {
t.Fatalf("first Create = (%+v, %v, %v), want created", first, created, err)
}
again, created, err := s.Create(ctx, uid, "127.0.0.1")
if err != nil {
t.Fatalf("second Create: %v", err)
}
if created {
t.Error("a second Create reported created=true, want a reuse")
}
if again.ID != first.ID {
t.Errorf("reuse returned request %d, want %d", again.ID, first.ID)
}
}
// TestCapIsEnforced: once a user is at the cap, Create refuses with
// ErrTooManyPending (SPEC-007 §4: "capped number per user").
func TestCapIsEnforced(t *testing.T) {
ctx := context.Background()
s, _, uid := newStore(t, recovery.WithCap(1))
if _, created, err := s.Create(ctx, uid, "127.0.0.1"); err != nil || !created {
t.Fatalf("first Create = (created=%v, %v), want a new request", created, err)
}
if _, _, err := s.Create(ctx, uid, "127.0.0.1"); !errors.Is(err, recovery.ErrTooManyPending) {
t.Errorf("Create at the cap = %v, want ErrTooManyPending", err)
}
}
// TestExpireTombstonesStaleAndFreesTheUser: a request past the TTL is expired
// and the user may open a fresh one.
func TestExpireTombstonesStaleAndFreesTheUser(t *testing.T) {
ctx := context.Background()
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
clock := now
s, db, uid := newStore(t, recovery.WithTTL(72*time.Hour), recovery.WithClock(func() time.Time { return clock }))
first, _, err := s.Create(ctx, uid, "127.0.0.1")
if err != nil {
t.Fatalf("Create: %v", err)
}
// Advance well past the TTL and open another: the stale row is expired and
// a NEW request is created.
clock = now.Add(100 * time.Hour)
second, created, err := s.Create(ctx, uid, "127.0.0.1")
if err != nil || !created {
t.Fatalf("Create after expiry = (%+v, %v, %v), want a new request", second, created, err)
}
if second.ID == first.ID {
t.Error("the stale request was reused instead of expired")
}
stale, err := s.Get(ctx, first.ID)
if err != nil {
t.Fatalf("Get(first): %v", err)
}
if stale.Status != recovery.StatusExpired {
t.Errorf("stale request status = %s, want expired", stale.Status)
}
// The expiry is audited (A11).
var n int
if err := db.QueryRow(`SELECT count(*) FROM audit_log WHERE action = 'recovery.expire'`).Scan(&n); err != nil {
t.Fatalf("read audit_log: %v", err)
}
if n == 0 {
t.Error("the expiry was not audited")
}
}