Config-flag → email user 1 an enrolment link (D-033), with the approve/deny actions and dual control.
112 lines
3.5 KiB
Go
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")
|
|
}
|
|
}
|