- webauthn: the passkey ceremony service (a fake in tests) - totp: the TOTP authenticator; the secret is AEAD-sealed at rest (A9) - sealer: the AEAD TOTP sealer - identities/sessions/credentials: the GUID state machine - flow: the enroll/login screens + the passkey nudge (pre-session double-submit CSRF — A15), rendered inside the shell
578 lines
18 KiB
Go
578 lines
18 KiB
Go
package auth_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/routing"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
|
|
)
|
|
|
|
// The Identities service satisfies the pure model's injected Resolver
|
|
// (SPEC-002 §3.1) — a compile-time proof.
|
|
var _ routing.Resolver = (*auth.IdentitiesService)(nil)
|
|
|
|
// fakeClock drives session/identity expiry deterministically.
|
|
type fakeClock struct{ now time.Time }
|
|
|
|
func (c *fakeClock) Now() time.Time { return c.now }
|
|
func (c *fakeClock) Advance(d time.Duration) { c.now = c.now.Add(d) }
|
|
|
|
type fixture struct {
|
|
t *testing.T
|
|
ctx context.Context
|
|
db *store.DB
|
|
users *users.Store
|
|
ids *auth.IdentitiesService
|
|
sessions *auth.SessionsService
|
|
clock *fakeClock
|
|
}
|
|
|
|
func newFixture(t *testing.T, opts ...auth.Option) *fixture {
|
|
t.Helper()
|
|
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)
|
|
}
|
|
|
|
clk := &fakeClock{now: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)}
|
|
allOpts := append([]auth.Option{auth.WithClock(clk.Now)}, opts...)
|
|
aud := audit.New(db.DB)
|
|
core := auth.New(db.DB, aud, allOpts...)
|
|
|
|
return &fixture{
|
|
t: t,
|
|
ctx: context.Background(),
|
|
db: db,
|
|
users: users.New(db.DB, aud),
|
|
ids: core.Identities(),
|
|
sessions: core.Sessions(),
|
|
clock: clk,
|
|
}
|
|
}
|
|
|
|
func (f *fixture) admin() int64 {
|
|
f.t.Helper()
|
|
id, err := f.users.Create(f.ctx, 0, "admin@example.com", "Admin", users.RoleAdmin)
|
|
mustNoErr(f.t, err)
|
|
return id
|
|
}
|
|
|
|
func (f *fixture) activeIdentity(userID int64) (int64, string) {
|
|
f.t.Helper()
|
|
id, guid, err := f.ids.Mint(f.ctx, userID)
|
|
mustNoErr(f.t, err)
|
|
mustNoErr(f.t, f.ids.Activate(f.ctx, id))
|
|
return id, guid
|
|
}
|
|
|
|
func (f *fixture) auditCount() int {
|
|
f.t.Helper()
|
|
var n int
|
|
if err := f.db.QueryRow(`SELECT count(*) FROM audit_log`).Scan(&n); err != nil {
|
|
f.t.Fatalf("count audit_log: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// expectAudit asserts fn produced at least one audit row (AC-7).
|
|
func (f *fixture) expectAudit(name string, fn func()) {
|
|
f.t.Helper()
|
|
before := f.auditCount()
|
|
fn()
|
|
if after := f.auditCount(); after <= before {
|
|
f.t.Errorf("AC-7: %s wrote no audit row (before=%d after=%d)", name, before, after)
|
|
}
|
|
}
|
|
|
|
func mustNoErr(t *testing.T, err error) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// AC-1: two active identities for one user is impossible — service AND index.
|
|
func TestAC1_OneActiveIdentityPerUser(t *testing.T) {
|
|
f := newFixture(t)
|
|
admin := f.admin()
|
|
director, err := f.users.Create(f.ctx, admin, "d@example.com", "D", users.RoleDirector)
|
|
mustNoErr(t, err)
|
|
|
|
f.activeIdentity(director)
|
|
|
|
// Service check: Mint refuses while a live identity exists.
|
|
if _, _, err := f.ids.Mint(f.ctx, director); !errors.Is(err, auth.ErrIdentityExists) {
|
|
t.Errorf("Mint with a live identity = %v, want ErrIdentityExists", err)
|
|
}
|
|
|
|
now := store.FormatTime(f.clock.Now())
|
|
|
|
// Index check: a hand-inserted second ACTIVE row is rejected.
|
|
if _, err := f.db.Exec(
|
|
`INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, 'manual-active', 'active', ?)`,
|
|
director, now,
|
|
); err == nil {
|
|
t.Error("a second active identity was inserted, want a UNIQUE violation (AC-1 index)")
|
|
}
|
|
|
|
// Index check via the service: a hand-inserted pending row cannot be
|
|
// activated into a second active identity.
|
|
res, err := f.db.Exec(
|
|
`INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, 'manual-pending', 'pending_enrollment', ?)`,
|
|
director, now,
|
|
)
|
|
mustNoErr(t, err)
|
|
pendingID, err := res.LastInsertId()
|
|
mustNoErr(t, err)
|
|
if err := f.ids.Activate(f.ctx, pendingID); err == nil {
|
|
t.Error("Activate produced a second active identity, want an error (AC-1)")
|
|
}
|
|
}
|
|
|
|
// AC-2: guid is unique and a revoked GUID is never reissued (tombstoned).
|
|
func TestAC2_GUIDUniqueAndTombstoned(t *testing.T) {
|
|
f := newFixture(t)
|
|
admin := f.admin()
|
|
id, guid1 := f.activeIdentity(admin)
|
|
|
|
if guid1 == "" || routing.IsReservedWorkflow(guid1) {
|
|
t.Fatalf("minted guid %q is empty or reserved", guid1)
|
|
}
|
|
|
|
mustNoErr(t, f.ids.Revoke(f.ctx, id))
|
|
guid2, err := f.ids.Rotate(f.ctx, admin)
|
|
mustNoErr(t, err)
|
|
if guid2 == guid1 {
|
|
t.Error("Rotate reissued the tombstoned GUID (AC-2)")
|
|
}
|
|
|
|
// The old GUID can never be re-inserted.
|
|
if _, err := f.db.Exec(
|
|
`INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, ?, 'pending_enrollment', ?)`,
|
|
admin, guid1, store.FormatTime(f.clock.Now()),
|
|
); err == nil {
|
|
t.Error("a tombstoned GUID was reused, want a UNIQUE violation (AC-2)")
|
|
}
|
|
|
|
// And it resolves as revoked/tombstoned, not as a live identity.
|
|
old, err := f.ids.ByGUID(f.ctx, guid1)
|
|
mustNoErr(t, err)
|
|
if old.Status != auth.StatusRevoked {
|
|
t.Errorf("old identity status = %s, want revoked", old.Status)
|
|
}
|
|
if found, enrolled := f.ids.GUID(guid1); found || enrolled {
|
|
t.Errorf("tombstoned GUID resolved found=%v enrolled=%v, want false/false", found, enrolled)
|
|
}
|
|
}
|
|
|
|
// AC-3: an empty session id is rejected (A2).
|
|
func TestAC3_EmptySessionRejected(t *testing.T) {
|
|
f := newFixture(t)
|
|
if _, err := f.sessions.Validate(f.ctx, ""); !errors.Is(err, auth.ErrEmptySessionID) {
|
|
t.Errorf("Validate(\"\") = %v, want ErrEmptySessionID (AC-3, A2)", err)
|
|
}
|
|
if err := f.sessions.Revoke(f.ctx, ""); !errors.Is(err, auth.ErrEmptySessionID) {
|
|
t.Errorf("Revoke(\"\") = %v, want ErrEmptySessionID", err)
|
|
}
|
|
}
|
|
|
|
// AC-4: expired/revoked sessions error; RevokeAll kills every session.
|
|
func TestAC4_ExpiredRevokedAndRevokeAll(t *testing.T) {
|
|
f := newFixture(t, auth.WithSessionTTL(time.Hour))
|
|
admin := f.admin()
|
|
id, _ := f.activeIdentity(admin)
|
|
|
|
s1, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test")
|
|
mustNoErr(t, err)
|
|
s2, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test")
|
|
mustNoErr(t, err)
|
|
|
|
if _, err := f.sessions.Validate(f.ctx, s1.ID); err != nil {
|
|
t.Fatalf("a fresh session failed to validate: %v", err)
|
|
}
|
|
|
|
// Revoked -> error.
|
|
mustNoErr(t, f.sessions.Revoke(f.ctx, s1.ID))
|
|
if _, err := f.sessions.Validate(f.ctx, s1.ID); !errors.Is(err, auth.ErrSessionRevoked) {
|
|
t.Errorf("Validate(revoked) = %v, want ErrSessionRevoked", err)
|
|
}
|
|
|
|
// Expired -> error.
|
|
f.clock.Advance(2 * time.Hour)
|
|
if _, err := f.sessions.Validate(f.ctx, s2.ID); !errors.Is(err, auth.ErrSessionExpired) {
|
|
t.Errorf("Validate(expired) = %v, want ErrSessionExpired", err)
|
|
}
|
|
|
|
// RevokeAll kills every live session of the identity.
|
|
a, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test")
|
|
mustNoErr(t, err)
|
|
b, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test")
|
|
mustNoErr(t, err)
|
|
mustNoErr(t, f.sessions.RevokeAll(f.ctx, id))
|
|
for _, sid := range []string{a.ID, b.ID} {
|
|
if _, err := f.sessions.Validate(f.ctx, sid); !errors.Is(err, auth.ErrSessionRevoked) {
|
|
t.Errorf("Validate(%s) after RevokeAll = %v, want ErrSessionRevoked", sid, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// AC-5: Rotate revokes the old identity AND its sessions, and mints a new GUID.
|
|
func TestAC5_RotateRevokesOldAndMintsNew(t *testing.T) {
|
|
f := newFixture(t)
|
|
admin := f.admin()
|
|
id, guid1 := f.activeIdentity(admin)
|
|
session, err := f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua")
|
|
mustNoErr(t, err)
|
|
|
|
guid2, err := f.ids.Rotate(f.ctx, admin)
|
|
mustNoErr(t, err)
|
|
if guid2 == "" || guid2 == guid1 {
|
|
t.Fatalf("Rotate guid = %q, want a fresh non-empty GUID", guid2)
|
|
}
|
|
|
|
old, err := f.ids.ByGUID(f.ctx, guid1)
|
|
mustNoErr(t, err)
|
|
if old.Status != auth.StatusRevoked || old.RevokedAt == nil {
|
|
t.Errorf("old identity = %+v, want revoked with a timestamp", old)
|
|
}
|
|
|
|
// Every session of the old identity is dead.
|
|
if _, err := f.sessions.Validate(f.ctx, session.ID); !errors.Is(err, auth.ErrSessionRevoked) {
|
|
t.Errorf("old session after Rotate = %v, want ErrSessionRevoked", err)
|
|
}
|
|
|
|
// The new identity is pending enrollment under the same user.
|
|
fresh, err := f.ids.ByGUID(f.ctx, guid2)
|
|
mustNoErr(t, err)
|
|
if fresh.Status != auth.StatusPendingEnrollment {
|
|
t.Errorf("new identity status = %s, want pending_enrollment", fresh.Status)
|
|
}
|
|
if fresh.UserID != admin {
|
|
t.Errorf("new identity user = %d, want %d (employment survives rotation)", fresh.UserID, admin)
|
|
}
|
|
}
|
|
|
|
// AC-7: every service mutation writes an audit_log row (A11).
|
|
func TestAC7_EveryMutationAudited(t *testing.T) {
|
|
f := newFixture(t)
|
|
admin := f.admin()
|
|
|
|
var director int64
|
|
f.expectAudit("users.Create", func() {
|
|
var err error
|
|
director, err = f.users.Create(f.ctx, admin, "d@example.com", "D", users.RoleDirector)
|
|
mustNoErr(t, err)
|
|
})
|
|
f.expectAudit("users.SetRole", func() {
|
|
mustNoErr(t, f.users.SetRole(f.ctx, director, users.RoleManager))
|
|
})
|
|
|
|
var id int64
|
|
f.expectAudit("identities.Mint", func() {
|
|
var err error
|
|
id, _, err = f.ids.Mint(f.ctx, director)
|
|
mustNoErr(t, err)
|
|
})
|
|
f.expectAudit("identities.Activate", func() {
|
|
mustNoErr(t, f.ids.Activate(f.ctx, id))
|
|
})
|
|
|
|
var sess auth.Session
|
|
f.expectAudit("sessions.Mint", func() {
|
|
var err error
|
|
sess, err = f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua")
|
|
mustNoErr(t, err)
|
|
})
|
|
f.expectAudit("sessions.Rotate", func() {
|
|
_, err := f.sessions.Rotate(f.ctx, sess.ID)
|
|
mustNoErr(t, err)
|
|
})
|
|
|
|
sess2, err := f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua")
|
|
mustNoErr(t, err)
|
|
f.expectAudit("sessions.Revoke", func() {
|
|
mustNoErr(t, f.sessions.Revoke(f.ctx, sess2.ID))
|
|
})
|
|
|
|
_, err = f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua")
|
|
mustNoErr(t, err)
|
|
f.expectAudit("sessions.RevokeAll", func() {
|
|
mustNoErr(t, f.sessions.RevokeAll(f.ctx, id))
|
|
})
|
|
|
|
f.expectAudit("identities.Revoke", func() {
|
|
mustNoErr(t, f.ids.Revoke(f.ctx, id))
|
|
})
|
|
f.expectAudit("identities.Rotate", func() {
|
|
_, err := f.ids.Rotate(f.ctx, director)
|
|
mustNoErr(t, err)
|
|
})
|
|
}
|
|
|
|
// AC-8: the TOTP secret is encrypted at rest and never stored or logged in
|
|
// plaintext (A9).
|
|
func TestAC8_TOTPSecretEncryptedAndNeverPlaintext(t *testing.T) {
|
|
var logs bytes.Buffer
|
|
old := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
|
|
t.Cleanup(func() { slog.SetDefault(old) })
|
|
|
|
sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x7f}, auth.KeySize))
|
|
mustNoErr(t, err)
|
|
|
|
f := newFixture(t, auth.WithSealer(sealer))
|
|
admin := f.admin()
|
|
id, _ := f.activeIdentity(admin)
|
|
|
|
secret := []byte("JBSWY3DPEHPK3PXP") // the shape of a base32 TOTP secret
|
|
f.expectAudit("totp.PutTOTPSecret", func() {
|
|
mustNoErr(t, f.ids.PutTOTPSecret(f.ctx, id, secret))
|
|
})
|
|
|
|
// 1) What is stored is ciphertext, not the secret.
|
|
var raw []byte
|
|
if err := f.db.QueryRow(`SELECT secret_encrypted FROM totp_secrets WHERE identity_id = ?`, id).Scan(&raw); err != nil {
|
|
t.Fatalf("read secret_encrypted: %v", err)
|
|
}
|
|
if bytes.Equal(raw, secret) || bytes.Contains(raw, secret) {
|
|
t.Error("AC-8: secret_encrypted holds the plaintext secret (A9)")
|
|
}
|
|
|
|
// 2) It still decrypts back to the secret.
|
|
got, err := f.ids.TOTPSecret(f.ctx, id)
|
|
mustNoErr(t, err)
|
|
if !bytes.Equal(got, secret) {
|
|
t.Errorf("TOTPSecret = %q, want %q", got, secret)
|
|
}
|
|
|
|
// 3) No audit row carries the secret.
|
|
rows, err := f.db.Query(
|
|
`SELECT COALESCE(action,''), COALESCE(subject,''), COALESCE(detail,''), COALESCE(correlation_id,'') FROM audit_log`,
|
|
)
|
|
mustNoErr(t, err)
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var a, s, d, c string
|
|
mustNoErr(t, rows.Scan(&a, &s, &d, &c))
|
|
if strings.Contains(a+s+d+c, string(secret)) {
|
|
t.Error("AC-8: an audit row contains the TOTP secret (A9)")
|
|
}
|
|
}
|
|
mustNoErr(t, rows.Err())
|
|
|
|
// 4) Nothing reached the log.
|
|
if strings.Contains(logs.String(), string(secret)) {
|
|
t.Error("AC-8: the TOTP secret reached the log (A9)")
|
|
}
|
|
|
|
// Verification is audited too and never stores plaintext.
|
|
f.expectAudit("totp.MarkTOTPVerified", func() {
|
|
mustNoErr(t, f.ids.MarkTOTPVerified(f.ctx, id))
|
|
})
|
|
}
|
|
|
|
func TestTOTPWithoutSealerFailsClosed(t *testing.T) {
|
|
f := newFixture(t) // no sealer
|
|
admin := f.admin()
|
|
id, _ := f.activeIdentity(admin)
|
|
if err := f.ids.PutTOTPSecret(f.ctx, id, []byte("secret")); !errors.Is(err, auth.ErrNoSealer) {
|
|
t.Errorf("PutTOTPSecret without a sealer = %v, want ErrNoSealer (fail closed)", err)
|
|
}
|
|
}
|
|
|
|
// The Identities service satisfies routing.Resolver end to end.
|
|
func TestResolverContract(t *testing.T) {
|
|
f := newFixture(t)
|
|
admin := f.admin()
|
|
|
|
if found, enrolled := f.ids.GUID("definitely-unknown"); found || enrolled {
|
|
t.Error("unknown GUID reported found")
|
|
}
|
|
if found, _ := f.ids.GUID(""); found {
|
|
t.Error("empty GUID reported found")
|
|
}
|
|
|
|
id, guid, err := f.ids.Mint(f.ctx, admin) // pending
|
|
mustNoErr(t, err)
|
|
if found, enrolled := f.ids.GUID(guid); !found || enrolled {
|
|
t.Errorf("pending GUID: found=%v enrolled=%v, want true/false", found, enrolled)
|
|
}
|
|
|
|
mustNoErr(t, f.ids.Activate(f.ctx, id))
|
|
if found, enrolled := f.ids.GUID(guid); !found || !enrolled {
|
|
t.Errorf("active GUID: found=%v enrolled=%v, want true/true", found, enrolled)
|
|
}
|
|
}
|
|
|
|
func TestIdentityLifecycleErrors(t *testing.T) {
|
|
f := newFixture(t)
|
|
admin := f.admin()
|
|
|
|
if _, _, err := f.ids.Mint(f.ctx, 999); !errors.Is(err, auth.ErrUnknownUser) {
|
|
t.Errorf("Mint(unknown user) = %v, want ErrUnknownUser", err)
|
|
}
|
|
if _, err := f.ids.Rotate(f.ctx, 999); !errors.Is(err, auth.ErrUnknownUser) {
|
|
t.Errorf("Rotate(unknown user) = %v, want ErrUnknownUser", err)
|
|
}
|
|
|
|
id, _, err := f.ids.Mint(f.ctx, admin)
|
|
mustNoErr(t, err)
|
|
mustNoErr(t, f.ids.Activate(f.ctx, id))
|
|
if err := f.ids.Activate(f.ctx, id); !errors.Is(err, auth.ErrIdentityNotPending) {
|
|
t.Errorf("Activate twice = %v, want ErrIdentityNotPending", err)
|
|
}
|
|
|
|
// A live identity blocks a second Mint.
|
|
if _, _, err := f.ids.Mint(f.ctx, admin); !errors.Is(err, auth.ErrIdentityExists) {
|
|
t.Errorf("second Mint = %v, want ErrIdentityExists", err)
|
|
}
|
|
if _, err := f.sessions.Mint(f.ctx, id, "ip", "ua"); err != nil {
|
|
t.Fatalf("Mint session for active identity: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSessionMintRequiresActiveIdentity(t *testing.T) {
|
|
f := newFixture(t)
|
|
admin := f.admin()
|
|
pending, _, err := f.ids.Mint(f.ctx, admin) // still pending
|
|
mustNoErr(t, err)
|
|
if _, err := f.sessions.Mint(f.ctx, pending, "ip", "ua"); !errors.Is(err, auth.ErrIdentityNotActive) {
|
|
t.Errorf("Mint for pending identity = %v, want ErrIdentityNotActive", err)
|
|
}
|
|
if _, err := f.sessions.Mint(f.ctx, 999, "ip", "ua"); !errors.Is(err, auth.ErrIdentityNotFound) {
|
|
t.Errorf("Mint for unknown identity = %v, want ErrIdentityNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestSessionGCKeepsValidSessions(t *testing.T) {
|
|
f := newFixture(t, auth.WithSessionTTL(time.Hour))
|
|
admin := f.admin()
|
|
id, _ := f.activeIdentity(admin)
|
|
|
|
stale1, err := f.sessions.Mint(f.ctx, id, "ip", "ua")
|
|
mustNoErr(t, err)
|
|
stale2, err := f.sessions.Mint(f.ctx, id, "ip", "ua")
|
|
mustNoErr(t, err)
|
|
|
|
f.clock.Advance(2 * time.Hour) // both stale sessions are now expired
|
|
fresh, err := f.sessions.Mint(f.ctx, id, "ip", "ua")
|
|
mustNoErr(t, err)
|
|
|
|
n, err := f.sessions.GC(f.ctx, f.clock.Now())
|
|
mustNoErr(t, err)
|
|
if n != 2 {
|
|
t.Errorf("GC removed %d rows, want 2", n)
|
|
}
|
|
|
|
// The valid session survives — GC never takes the last valid session.
|
|
if _, err := f.sessions.Validate(f.ctx, fresh.ID); err != nil {
|
|
t.Errorf("GC removed a valid session: %v", err)
|
|
}
|
|
for _, sid := range []string{stale1.ID, stale2.ID} {
|
|
if _, err := f.sessions.Validate(f.ctx, sid); !errors.Is(err, auth.ErrSessionNotFound) {
|
|
t.Errorf("stale session %s = %v, want ErrSessionNotFound after GC", sid, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEnrollmentWindow(t *testing.T) {
|
|
f := newFixture(t, auth.WithEnrollWindow(72*time.Hour))
|
|
admin := f.admin()
|
|
id, _, err := f.ids.Mint(f.ctx, admin) // pending
|
|
mustNoErr(t, err)
|
|
|
|
ok, err := f.ids.WithinEnrollmentWindow(f.ctx, id)
|
|
mustNoErr(t, err)
|
|
if !ok {
|
|
t.Error("a fresh pending identity is outside its enrollment window")
|
|
}
|
|
|
|
f.clock.Advance(73 * time.Hour)
|
|
if ok, err = f.ids.WithinEnrollmentWindow(f.ctx, id); err != nil {
|
|
t.Fatalf("WithinEnrollmentWindow: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("an identity past its window is still enrollable (A1)")
|
|
}
|
|
}
|
|
|
|
// R-4: the identity services take the ACTOR from the audit.Meta on the context
|
|
// (creator/approver) and record the AFFECTED user as the subject — never a
|
|
// hard-coded owner.
|
|
func TestR4_IdentityAuditActorFromContext(t *testing.T) {
|
|
f := newFixture(t)
|
|
admin := f.admin()
|
|
director, err := f.users.Create(f.ctx, admin, "d@example.com", "D", users.RoleDirector)
|
|
mustNoErr(t, err)
|
|
|
|
// The creator acts with an explicit actor on the context (the door would
|
|
// attach it); the affected user is the subject.
|
|
ctx := audit.WithMeta(f.ctx, audit.Meta{ActorUserID: admin, ClientIP: "203.0.113.9", CorrelationID: "corr-r4"})
|
|
|
|
id, guid, err := f.ids.Mint(ctx, director)
|
|
mustNoErr(t, err)
|
|
mustNoErr(t, f.ids.Activate(ctx, id))
|
|
mustNoErr(t, f.ids.Revoke(ctx, id))
|
|
|
|
if _, err := f.ids.Rotate(ctx, director); err != nil {
|
|
t.Fatalf("Rotate: %v", err)
|
|
}
|
|
_ = guid
|
|
|
|
rows, err := f.db.Query(`SELECT action, actor_user_id, subject, correlation_id FROM audit_log
|
|
WHERE action IN ('identity.mint','identity.activate','identity.revoke','identity.rotate')
|
|
ORDER BY id`)
|
|
if err != nil {
|
|
t.Fatalf("query audit_log: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
want := map[string]bool{
|
|
"identity.mint": true,
|
|
"identity.activate": true,
|
|
"identity.revoke": true,
|
|
"identity.rotate": true,
|
|
}
|
|
seen := 0
|
|
for rows.Next() {
|
|
var action, subject string
|
|
var actor sql.NullInt64
|
|
var corr sql.NullString
|
|
if err := rows.Scan(&action, &actor, &subject, &corr); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
seen++
|
|
if !actor.Valid || actor.Int64 != admin {
|
|
t.Errorf("%s actor = %+v, want the context actor %d (R-4)", action, actor, admin)
|
|
}
|
|
if subject != fmt.Sprintf("user:%d", director) {
|
|
t.Errorf("%s subject = %q, want the affected user (R-4)", action, subject)
|
|
}
|
|
if corr.String != "corr-r4" {
|
|
t.Errorf("%s correlation = %q, want corr-r4", action, corr.String)
|
|
}
|
|
delete(want, action)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("rows: %v", err)
|
|
}
|
|
if seen != 4 || len(want) != 0 {
|
|
t.Errorf("audited %d identity events, missing %v (R-4)", seen, want)
|
|
}
|
|
}
|