NextMyDay/internal/core/users/users_test.go
Claus Lohmar d11e14295b feat(core): add the store, identity plumbing and the door (SPEC-003/004)
- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded
  migration runner; each module's migrations are name-prefixed (D-012)
- audit: the append-only audit log; the actor threads via context (R-1)
- users: the user_id/GUID/email anchors (D-013)
- config: the YAML settings file (D-035) + validation
- httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security
  headers · redaction
- middleware: correlation id · audit · rate limiting
- mail: the invite/recovery mailer seam
- server: the core DOOR — the resolution switch, the dispatch registry
  (RegisterModule), the role gates, /setup · enroll/login · recovery flows,
  the reserved `static` workflow, and the loopback-only bootstrap (D-025)
2026-10-14 10:53:51 +00:00

405 lines
14 KiB
Go

package users_test
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// testCtx is the request context every service call needs under ruling R-1.
var testCtx = context.Background()
func newUsers(t *testing.T) (*users.Store, *store.DB) {
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)
}
return users.New(db.DB, audit.New(db.DB)), db
}
// bootstrap creates the top admin (user 1) and returns its id.
func bootstrap(t *testing.T, s *users.Store) int64 {
t.Helper()
id, err := s.Create(testCtx, 0, "admin@example.com", "Admin", users.RoleAdmin)
if err != nil {
t.Fatalf("bootstrap Create: %v", err)
}
if id != 1 {
t.Fatalf("bootstrap created user %d, want 1 (D-013)", id)
}
return id
}
// TestCanApproveChainRule pins ruling R-7: recovery authority is the CHAIN —
// the approver must be an ancestor of the requester (their manager upward) or
// the admin, and never the requester. A role merely ABOVE the requester is not
// enough.
func TestCanApproveChainRule(t *testing.T) {
admin := users.User{ID: 1, Role: users.RoleAdmin}
director := users.User{ID: 2, Role: users.RoleDirector}
manager := users.User{ID: 3, Role: users.RoleManager}
team := users.User{ID: 4, Role: users.RoleTeam}
otherDirector := users.User{ID: 5, Role: users.RoleDirector} // a different subtree
cases := []struct {
name string
approver users.User
requester users.User
isAncestor bool
want bool
}{
{"self (team)", team, team, false, false},
{"self (admin)", admin, admin, false, false},
{"manager is an ancestor", manager, team, true, true},
{"director is an ancestor", director, team, true, true},
{"admin is the root (walked)", admin, team, true, true},
{"admin with no walked chain still approves", admin, team, false, true},
{"peer (same role, not ancestor)", team, users.User{ID: 9, Role: users.RoleTeam}, false, false},
{"role above but NOT an ancestor", otherDirector, team, false, false},
{"ancestor flag cannot override self", manager, manager, true, false},
}
for _, c := range cases {
if got := users.CanApprove(c.approver, c.requester, c.isAncestor); got != c.want {
t.Errorf("%s: CanApprove = %v, want %v", c.name, got, c.want)
}
}
}
func TestRoleLadder(t *testing.T) {
cases := []struct {
higher, lower users.Role
}{
{users.RoleAdmin, users.RoleDirector},
{users.RoleAdmin, users.RoleManager},
{users.RoleAdmin, users.RoleTeam},
{users.RoleDirector, users.RoleManager},
{users.RoleDirector, users.RoleTeam},
{users.RoleManager, users.RoleTeam},
}
for _, c := range cases {
if !c.higher.Above(c.lower) {
t.Errorf("%s should be above %s", c.higher, c.lower)
}
}
for _, r := range []users.Role{users.RoleAdmin, users.RoleDirector, users.RoleManager, users.RoleTeam} {
if !r.Valid() {
t.Errorf("%s should be valid", r)
}
}
if users.Role("boss").Valid() {
t.Error("unknown role reported valid")
}
if _, err := users.ParseRole("nope"); !errors.Is(err, users.ErrInvalidRole) {
t.Errorf("ParseRole(nope) = %v, want ErrInvalidRole", err)
}
}
func TestBootstrapRules(t *testing.T) {
s, _ := newUsers(t)
// A bootstrap must be the admin.
if _, err := s.Create(testCtx, 0, "x@example.com", "X", users.RoleTeam); !errors.Is(err, users.ErrBootstrapAdmin) {
t.Errorf("bootstrap non-admin = %v, want ErrBootstrapAdmin", err)
}
id := bootstrap(t, s)
u, err := s.Get(testCtx, id)
if err != nil {
t.Fatalf("Get(1): %v", err)
}
if u.Role != users.RoleAdmin {
t.Errorf("user 1 role = %s, want admin", u.Role)
}
if u.Manager.Set {
t.Errorf("admin manager = %v, want none (root of the chain)", u.Manager)
}
// A second bootstrap is refused once users exist.
if _, err := s.Create(testCtx, 0, "y@example.com", "Y", users.RoleAdmin); !errors.Is(err, users.ErrBootstrapClosed) {
t.Errorf("second bootstrap = %v, want ErrBootstrapClosed", err)
}
}
// AC-6: creating an admin once user 1 exists is rejected.
func TestAC6_SecondAdminRejected(t *testing.T) {
s, _ := newUsers(t)
admin := bootstrap(t, s)
if _, err := s.Create(testCtx, admin, "admin2@example.com", "Admin2", users.RoleAdmin); !errors.Is(err, users.ErrOneAdmin) {
t.Fatalf("Create(role=admin) = %v, want ErrOneAdmin (AC-6)", err)
}
// A second director is fine — only admin is unique.
director, err := s.Create(testCtx, admin, "director@example.com", "Director", users.RoleDirector)
if err != nil {
t.Fatalf("admin creates director: %v", err)
}
// Promoting that director to admin is also rejected (one admin).
if err := s.SetRole(testCtx, director, users.RoleAdmin); !errors.Is(err, users.ErrOneAdmin) {
t.Errorf("SetRole(admin) = %v, want ErrOneAdmin", err)
}
// Demoting the top admin is refused (A14).
if err := s.SetRole(testCtx, admin, users.RoleDirector); !errors.Is(err, users.ErrTopAdmin) {
t.Errorf("demote top admin = %v, want ErrTopAdmin", err)
}
}
func TestCreationAuthorityAndChain(t *testing.T) {
s, _ := newUsers(t)
admin := bootstrap(t, s)
director, err := s.Create(testCtx, admin, "d@example.com", "D", users.RoleDirector)
if err != nil {
t.Fatalf("admin -> director: %v", err)
}
// The new user's manager defaults to the creator.
mgr, err := s.Manager(testCtx, director)
if err != nil {
t.Fatalf("Manager(director): %v", err)
}
if !mgr.Set || mgr.Value != admin {
t.Errorf("director manager = %+v, want admin %d", mgr, admin)
}
// A peer cannot be created: a director may not create a director.
if _, err := s.Create(testCtx, director, "d2@example.com", "D2", users.RoleDirector); !errors.Is(err, users.ErrRoleNotAllowed) {
t.Errorf("director -> director = %v, want ErrRoleNotAllowed", err)
}
// Nor may a director create an admin.
if _, err := s.Create(testCtx, director, "a2@example.com", "A2", users.RoleAdmin); !errors.Is(err, users.ErrOneAdmin) {
t.Errorf("director -> admin = %v, want ErrOneAdmin", err)
}
// A manager below the director is fine.
manager, err := s.Create(testCtx, director, "m@example.com", "M", users.RoleManager)
if err != nil {
t.Fatalf("director -> manager: %v", err)
}
// A team member below the manager is fine.
if _, err := s.Create(testCtx, manager, "t@example.com", "T", users.RoleTeam); err != nil {
t.Fatalf("manager -> team: %v", err)
}
// A manager may not create a manager.
if _, err := s.Create(testCtx, manager, "m2@example.com", "M2", users.RoleManager); !errors.Is(err, users.ErrRoleNotAllowed) {
t.Errorf("manager -> manager = %v, want ErrRoleNotAllowed", err)
}
}
// TestSpec007AC1_CreationAuthorityAndDefaultManager restates D-019 as the
// SPEC-007 acceptance criterion: creation requires a role strictly ABOVE the
// new user's, and the creator is the new user's default manager.
func TestSpec007AC1_CreationAuthorityAndDefaultManager(t *testing.T) {
s, _ := newUsers(t)
admin := bootstrap(t, s)
// Each level creates the next; the creator becomes the default manager.
director, err := s.Create(testCtx, admin, "ac1-director@example.com", "D", users.RoleDirector)
if err != nil {
t.Fatalf("admin -> director: %v", err)
}
manager, err := s.Create(testCtx, director, "ac1-manager@example.com", "M", users.RoleManager)
if err != nil {
t.Fatalf("director -> manager: %v", err)
}
team, err := s.Create(testCtx, manager, "ac1-team@example.com", "T", users.RoleTeam)
if err != nil {
t.Fatalf("manager -> team: %v", err)
}
for want, id := range map[int64]int64{director: admin, manager: director, team: manager} {
mgr, err := s.Manager(testCtx, want)
if err != nil {
t.Fatalf("Manager(%d): %v", want, err)
}
if !mgr.Set || mgr.Value != id {
t.Errorf("user %d manager = %+v, want the creator %d (AC-1)", want, mgr, id)
}
}
// A role may NOT create its own tier (a peer) — only strictly below.
if _, err := s.Create(testCtx, director, "ac1-peer@example.com", "P", users.RoleDirector); !errors.Is(err, users.ErrRoleNotAllowed) {
t.Errorf("director -> director = %v, want ErrRoleNotAllowed (AC-1)", err)
}
// Nor a role ABOVE (a second admin).
if _, err := s.Create(testCtx, director, "ac1-admin@example.com", "A", users.RoleAdmin); !errors.Is(err, users.ErrOneAdmin) {
t.Errorf("director -> admin = %v, want ErrOneAdmin (AC-1)", err)
}
}
func TestSetRoleValid(t *testing.T) {
s, _ := newUsers(t)
admin := bootstrap(t, s)
team, err := s.Create(testCtx, admin, "t@example.com", "T", users.RoleTeam)
if err != nil {
t.Fatalf("create team: %v", err)
}
if err := s.SetRole(testCtx, team, users.RoleManager); err != nil {
t.Fatalf("SetRole(manager): %v", err)
}
u, err := s.Get(testCtx, team)
if err != nil {
t.Fatalf("Get: %v", err)
}
if u.Role != users.RoleManager {
t.Errorf("role = %s, want manager", u.Role)
}
if err := s.SetRole(testCtx, team, users.Role("boss")); !errors.Is(err, users.ErrInvalidRole) {
t.Errorf("SetRole(invalid) = %v, want ErrInvalidRole", err)
}
if err := s.SetRole(testCtx, 999, users.RoleTeam); !errors.Is(err, users.ErrNotFound) {
t.Errorf("SetRole(unknown) = %v, want ErrNotFound", err)
}
}
func TestCreateValidation(t *testing.T) {
s, _ := newUsers(t)
admin := bootstrap(t, s)
if _, err := s.Create(testCtx, admin, " ", "X", users.RoleTeam); !errors.Is(err, users.ErrEmailRequired) {
t.Errorf("empty email = %v, want ErrEmailRequired", err)
}
if _, err := s.Create(testCtx, admin, "x@example.com", " ", users.RoleTeam); !errors.Is(err, users.ErrNameRequired) {
t.Errorf("empty name = %v, want ErrNameRequired", err)
}
if _, err := s.Create(testCtx, admin, "x@example.com", "X", users.Role("boss")); !errors.Is(err, users.ErrInvalidRole) {
t.Errorf("invalid role = %v, want ErrInvalidRole", err)
}
if _, err := s.Create(testCtx, 999, "x@example.com", "X", users.RoleTeam); !errors.Is(err, users.ErrNotFound) {
t.Errorf("unknown creator = %v, want ErrNotFound", err)
}
// Email is unique (and normalised to lower case).
if _, err := s.Create(testCtx, admin, "Team@Example.com", "T", users.RoleTeam); err != nil {
t.Fatalf("create team: %v", err)
}
if _, err := s.Create(testCtx, admin, "team@example.com", "T2", users.RoleTeam); !errors.Is(err, users.ErrDuplicateEmail) {
t.Errorf("duplicate email = %v, want ErrDuplicateEmail", err)
}
}
// TestAuditCarriesContextMeta proves ruling R-1 end to end: the door attaches
// audit.Meta to the context, and a service mutation WITHOUT an explicit actor
// records the context's actor · client IP · correlation in `audit_log`.
func TestAuditCarriesContextMeta(t *testing.T) {
s, db := newUsers(t)
admin := bootstrap(t, s)
ctx := audit.WithMeta(context.Background(), audit.Meta{
ActorUserID: admin,
ClientIP: "203.0.113.9",
CorrelationID: "corr-42",
})
team, err := s.Create(ctx, admin, "t@example.com", "T", users.RoleTeam)
if err != nil {
t.Fatalf("Create: %v", err)
}
// SetRole carries no explicit actor: it must come from the context.
if err := s.SetRole(ctx, team, users.RoleManager); err != nil {
t.Fatalf("SetRole: %v", err)
}
var (
actor sql.NullInt64
ip sql.NullString
corr sql.NullString
)
err = db.QueryRow(`
SELECT actor_user_id, client_ip, correlation_id
FROM audit_log WHERE action = 'user.set_role' ORDER BY id DESC LIMIT 1`,
).Scan(&actor, &ip, &corr)
if err != nil {
t.Fatalf("read audit row: %v", err)
}
if !actor.Valid || actor.Int64 != admin {
t.Errorf("actor_user_id = %+v, want %d (from context meta, R-1)", actor, admin)
}
if ip.String != "203.0.113.9" {
t.Errorf("client_ip = %q, want 203.0.113.9", ip.String)
}
if corr.String != "corr-42" {
t.Errorf("correlation_id = %q, want corr-42", corr.String)
}
}
// TestPasskeyOptOut (D-041): the marker is a USER preference — default false,
// settable, readable through Get, and audited.
func TestPasskeyOptOut(t *testing.T) {
s, db := newUsers(t)
admin := bootstrap(t, s)
// Default: not opted out, and reflected on the user record.
opt, err := s.PasskeyOptOut(testCtx, admin)
if err != nil {
t.Fatalf("PasskeyOptOut: %v", err)
}
if opt {
t.Error("passkey_opt_out defaults to true, want false (D-041)")
}
if err := s.SetPasskeyOptOut(testCtx, admin, true); err != nil {
t.Fatalf("SetPasskeyOptOut(true): %v", err)
}
if opt, err = s.PasskeyOptOut(testCtx, admin); err != nil || !opt {
t.Errorf("after SetPasskeyOptOut(true) opt-out = %v (err %v), want true", opt, err)
}
u, err := s.Get(testCtx, admin)
if err != nil {
t.Fatalf("Get: %v", err)
}
if !u.PasskeyOptOut {
t.Error("Get did not carry passkey_opt_out (D-041)")
}
// The write is audited (A11).
var n int
if err := db.QueryRow(
`SELECT count(*) FROM audit_log WHERE action = 'user.passkey_opt_out'`).Scan(&n); err != nil {
t.Fatalf("count audit: %v", err)
}
if n != 1 {
t.Errorf("opt-out audit rows = %d, want 1 (A11)", n)
}
// Unknown user -> ErrNotFound.
if _, err := s.PasskeyOptOut(testCtx, 999); !errors.Is(err, users.ErrNotFound) {
t.Errorf("PasskeyOptOut(unknown) = %v, want ErrNotFound", err)
}
if err := s.SetPasskeyOptOut(testCtx, 999, true); !errors.Is(err, users.ErrNotFound) {
t.Errorf("SetPasskeyOptOut(unknown) = %v, want ErrNotFound", err)
}
}
func TestByEmailAndCount(t *testing.T) {
s, _ := newUsers(t)
admin := bootstrap(t, s)
if _, err := s.Create(testCtx, admin, "team@example.com", "T", users.RoleTeam); err != nil {
t.Fatalf("create: %v", err)
}
u, err := s.ByEmail(testCtx, "TEAM@example.com")
if err != nil {
t.Fatalf("ByEmail: %v", err)
}
if u.Role != users.RoleTeam {
t.Errorf("ByEmail role = %s, want team", u.Role)
}
if _, err := s.ByEmail(testCtx, "nobody@example.com"); !errors.Is(err, users.ErrNotFound) {
t.Errorf("ByEmail(unknown) = %v, want ErrNotFound", err)
}
n, err := s.Count(testCtx)
if err != nil {
t.Fatalf("Count: %v", err)
}
if n != 2 {
t.Errorf("Count = %d, want 2", n)
}
}