- 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)
405 lines
14 KiB
Go
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)
|
|
}
|
|
}
|