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