NextMyDay/internal/core/recovery/admin_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

168 lines
5.2 KiB
Go

package recovery_test
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/mail"
"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"
)
type adminFixture struct {
t *testing.T
ctx context.Context
db *store.DB
users *users.Store
ids *auth.IdentitiesService
sessions *auth.SessionsService
mailer *mail.Fake
cfg *config.Config
cfgPath string
guid string
sessionID string
}
func newAdminFixture(t *testing.T) *adminFixture {
t.Helper()
ctx := context.Background()
dir := t.TempDir()
db, err := store.Open(filepath.Join(dir, "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)
core := auth.New(db.DB, aud)
adminID, err := us.Create(ctx, 0, "admin@example.com", "Claus", users.RoleAdmin)
if err != nil {
t.Fatalf("bootstrap admin: %v", err)
}
id, guid, err := core.Identities().Mint(ctx, adminID)
if err != nil {
t.Fatalf("Mint: %v", err)
}
if err := core.Identities().Activate(ctx, id); err != nil {
t.Fatalf("Activate: %v", err)
}
sess, err := core.Sessions().Mint(ctx, id, "127.0.0.1", "test")
if err != nil {
t.Fatalf("session Mint: %v", err)
}
return &adminFixture{
t: t, ctx: ctx, db: db, users: us,
ids: core.Identities(), sessions: core.Sessions(), mailer: mail.NewFake(),
cfg: &config.Config{
Server: config.Server{Listen: "127.0.0.1:8080", BaseURL: "https://myday.example.com"},
Database: config.Database{Path: db.Path()},
},
cfgPath: filepath.Join(dir, config.FileName),
guid: guid,
sessionID: sess.ID,
}
}
func (f *adminFixture) deps(flag bool) recovery.AdminDeps {
f.cfg.AdminRecovery = flag
return recovery.AdminDeps{
Config: f.cfg,
ConfigPath: f.cfgPath,
Users: f.users,
Identities: f.ids,
Mailer: f.mailer,
Audit: audit.New(f.db.DB),
BaseURL: "https://myday.example.com",
WriteFile: func(p string, b []byte) error { return os.WriteFile(p, b, 0o600) },
}
}
// AC-5: `admin_recovery: false` ⇒ nothing happens. With the flag set the
// identity is rotated, the enrolment link is emailed to user 1's address on
// record, the rotation is audited, and the flag is cleared (D-033).
func TestSpec007AC5_AdminRecoveryIsConfigGated(t *testing.T) {
f := newAdminFixture(t)
// ── flag clear: nothing happens ─────────────────────────────────────────
acted, err := recovery.RunAdminRecovery(f.ctx, f.deps(false))
if err != nil {
t.Fatalf("RunAdminRecovery(false): %v", err)
}
if acted {
t.Error("admin recovery acted without the config flag (AC-5)")
}
if idn, err := f.ids.ByGUID(f.ctx, f.guid); err != nil || idn.Status != auth.StatusActive {
t.Errorf("identity changed without the flag: %+v (AC-5)", idn)
}
if len(f.mailer.Sent()) != 0 {
t.Errorf("mail was sent without the flag: %v (AC-5)", f.mailer.Sent())
}
if _, err := os.Stat(f.cfgPath); !os.IsNotExist(err) {
t.Error("the config was rewritten without the flag (AC-5)")
}
// ── flag set: rotate + email + audit + clear ────────────────────────────
acted, err = recovery.RunAdminRecovery(f.ctx, f.deps(true))
if err != nil {
t.Fatalf("RunAdminRecovery(true): %v", err)
}
if !acted {
t.Fatal("admin recovery did not act with the flag set")
}
old, err := f.ids.ByGUID(f.ctx, f.guid)
if err != nil || old.Status != auth.StatusRevoked {
t.Errorf("old identity = %+v, want revoked", old)
}
fresh, err := f.ids.ByUserID(f.ctx, 1)
if err != nil {
t.Fatalf("ByUserID(1): %v", err)
}
if fresh.Status != auth.StatusPendingEnrollment || fresh.GUID == f.guid {
t.Errorf("new identity = %+v, want a fresh pending GUID", fresh)
}
if _, err := f.sessions.Validate(f.ctx, f.sessionID); err == nil {
t.Error("the admin's old session survived the rotation")
}
// Email to the address on record with the enrolment link.
msgs := f.mailer.To("admin@example.com")
if len(msgs) == 0 {
t.Fatal("no enrolment email was sent to user 1's address on record")
}
if !strings.Contains(msgs[0].Body, fresh.GUID) {
t.Errorf("the enrolment email does not carry the new GUID: %q", msgs[0].Body)
}
// Audited (AC-6).
var n int
if err := f.db.QueryRow(`SELECT count(*) FROM audit_log WHERE action = 'admin.recovery'`).Scan(&n); err != nil {
t.Fatalf("read audit_log: %v", err)
}
if n == 0 {
t.Error("the admin recovery was not audited (A11/AC-6)")
}
// The flag is cleared in the persisted config.
data, err := os.ReadFile(f.cfgPath)
if err != nil {
t.Fatalf("config was not rewritten: %v", err)
}
if !strings.Contains(string(data), "admin_recovery: false") {
t.Errorf("the flag was not cleared in the config file:\n%s", data)
}
}