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