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