Files
paliad/internal/services/user_service_test.go
m b34500ad31 feat(t-paliad-051): split paliad.users.role into job_title + global_role
Conflation: paliad.users.role was simultaneously job title (display only)
and global permission ('role=admin' checks across Go/SQL/JS). m wanted
to set his real job title ('Counsel Knowledge Lawyer') without losing
admin access — the t-paliad-050 admin-team UI even rejected role='admin'
on edit, so any UI-driven update silently demoted m.

Per m's three-axis principle ("firm roles are not project roles are not
tool roles"), this lands TWO orthogonal columns:

* paliad.users.job_title — free text, NULL allowed, display only.
  NEVER gates anything in code or SQL.
* paliad.users.global_role — CHECK ('standard'|'global_admin'),
  default 'standard'. The only thing that gates ops.

Migration 023:
* Drops NOT NULL + 'associate' default off the legacy role column
* Promotes role='admin' rows to global_role='global_admin'; clears
  their role text; sets m's job_title='Counsel Knowledge Lawyer'
* Renames role -> job_title with CHECK (job_title IS NULL OR <> '')
* Replaces can_see_project body with global_role='global_admin'
* CASCADE-rebuilds every RLS policy under canonical English names —
  with the historic u.role IN ('partner','admin') gates simplified
  to u.global_role='global_admin' only (job_title NEVER gates)

Code surface:
* internal/models/models.go: User.Role -> User.JobTitle (*string) +
  User.GlobalRole (string)
* internal/services/user_service.go: bootstrap (first row promoted to
  global_admin via pg_advisory_xact_lock(7346298141), unchanged constant);
  UpdateProfile drops role, accepts job_title only; AdminUpdateUser adds
  global_role with last-admin demotion guard (ErrLastGlobalAdmin);
  IsAdmin reads global_role
* Other services (dashboard/agenda/appointment/project/deadline/
  department/party/note/checklist_instance): pass user.GlobalRole into
  visibility predicates; partner-or-admin gates simplified to
  global_admin only
* Handlers: drop now-impossible ErrAdminBootstrapOnly cases;
  admin_users handles ErrLastGlobalAdmin -> 409
* department_service: SQL u.role -> u.job_title, DepartmentMember.Role
  -> JobTitle (*string)

Frontend:
* /api/me + Me interfaces ship {job_title, global_role}
* Onboarding form: 'Berufsbezeichnung / Job title' (job_title)
* Settings + admin-team forms: same renames + i18n updates
* Admin-team: new 'Berechtigung / Permission' column with
  'Standard'|'Global Admin' badge + dropdown editor; last-admin
  demotion guard at the UI layer
* Sidebar admin-section reveal: me.global_role==='global_admin'
* deadlines/deadlines-detail/projects-detail/notes: partner-as-permission
  gates dropped, only global_admin grants those operations

Tests:
* user_service_test: bootstrap promotes first user to global_admin,
  subsequent default to standard; AdminUpdateUser refuses to demote
  the last global_admin; IsAdmin reads global_role

Migration applied to ydb 2026-04-27. Live state verified:
* m: job_title='Counsel Knowledge Lawyer', global_role='global_admin'
* tester: job_title=NULL, global_role='global_admin'
* 29 stub colleagues: job_title='associate', global_role='standard'
2026-04-27 14:59:03 +02:00

224 lines
6.8 KiB
Go

package services
import (
"context"
"errors"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/patholo/internal/db"
)
// user_service_test covers the onboarding Create path end-to-end against a
// real Postgres. Mirrors the akte_service_test setup pattern; skips when
// TEST_DATABASE_URL is unset.
func setupUserTest(t *testing.T) (*UserService, *sqlx.DB, func()) {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
users := NewUserService(pool)
return users, pool, func() { pool.Close() }
}
func seedAuthUser(t *testing.T, pool *sqlx.DB, id uuid.UUID, email string) {
t.Helper()
if _, err := pool.ExecContext(context.Background(),
`INSERT INTO auth.users (id, email) VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email`, id, email); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
}
func cleanupUsers(t *testing.T, pool *sqlx.DB, ids ...uuid.UUID) {
t.Helper()
ctx := context.Background()
for _, id := range ids {
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, id)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, id)
}
}
func TestUserService_Create_Valid(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
// Ensure the table is empty so the bootstrap path is deterministic. The
// first inserter becomes global_admin; this test just exercises the rest
// of the Create shape and isn't asserting on global_role.
pool.ExecContext(context.Background(), `DELETE FROM paliad.users`)
id := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000001")
seedAuthUser(t, pool, id, "first@hlc.com")
defer cleanupUsers(t, pool, id)
dezernat := " Team Müller "
u, err := users.Create(context.Background(), id, "first@hlc.com", CreateUserInput{
DisplayName: " First User ",
Office: "munich",
JobTitle: "Trainee",
Dezernat: &dezernat,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if u == nil || u.ID != id {
t.Fatalf("unexpected user: %+v", u)
}
if u.DisplayName != "First User" {
t.Errorf("display_name not trimmed: %q", u.DisplayName)
}
if u.Office != "munich" || u.JobTitle == nil || *u.JobTitle != "Trainee" || u.Email != "first@hlc.com" {
t.Errorf("field mismatch: %+v", u)
}
if u.Dezernat == nil || *u.Dezernat != "Team Müller" {
t.Errorf("dezernat not trimmed/persisted: %+v", u.Dezernat)
}
}
func TestUserService_Create_InvalidInput(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
id := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000002")
seedAuthUser(t, pool, id, "x@hlc.com")
defer cleanupUsers(t, pool, id)
cases := []struct {
name string
input CreateUserInput
}{
{"missing display_name", CreateUserInput{Office: "munich", JobTitle: "associate"}},
{"invalid office", CreateUserInput{DisplayName: "X", Office: "tokyo", JobTitle: "associate"}},
{"missing job_title", CreateUserInput{DisplayName: "X", Office: "munich", JobTitle: " "}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := users.Create(context.Background(), id, "x@hlc.com", c.input); err == nil {
t.Fatalf("expected error")
}
})
}
}
func TestUserService_Create_DuplicateReturns409(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
id := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000003")
seedAuthUser(t, pool, id, "dup@hlc.com")
defer cleanupUsers(t, pool, id)
ctx := context.Background()
in := CreateUserInput{DisplayName: "Dup", Office: "munich", JobTitle: "associate"}
if _, err := users.Create(ctx, id, "dup@hlc.com", in); err != nil {
t.Fatalf("first create: %v", err)
}
_, err := users.Create(ctx, id, "dup@hlc.com", in)
if !errors.Is(err, ErrUserAlreadyOnboarded) {
t.Fatalf("expected ErrUserAlreadyOnboarded, got %v", err)
}
}
// TestUserService_Create_BootstrapPromotesFirstUser asserts the bootstrap
// rule: when paliad.users is empty, the first inserter is silently promoted
// to global_admin. Subsequent inserters default to standard.
func TestUserService_Create_BootstrapPromotesFirstUser(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
ctx := context.Background()
pool.ExecContext(ctx, `DELETE FROM paliad.users`)
first := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000011")
second := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000012")
seedAuthUser(t, pool, first, "first@hlc.com")
seedAuthUser(t, pool, second, "second@hlc.com")
defer cleanupUsers(t, pool, first, second)
u1, err := users.Create(ctx, first, "first@hlc.com", CreateUserInput{
DisplayName: "First", Office: "munich", JobTitle: "Partner",
})
if err != nil {
t.Fatalf("bootstrap user: %v", err)
}
if u1.GlobalRole != "global_admin" {
t.Fatalf("first user should be global_admin, got %q", u1.GlobalRole)
}
u2, err := users.Create(ctx, second, "second@hlc.com", CreateUserInput{
DisplayName: "Second", Office: "munich", JobTitle: "Associate",
})
if err != nil {
t.Fatalf("second user: %v", err)
}
if u2.GlobalRole != "standard" {
t.Fatalf("second user should be standard, got %q", u2.GlobalRole)
}
}
// TestUserService_AdminUpdateUser_LastGlobalAdmin asserts the demotion guard.
func TestUserService_AdminUpdateUser_LastGlobalAdmin(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
ctx := context.Background()
pool.ExecContext(ctx, `DELETE FROM paliad.users`)
first := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000021")
seedAuthUser(t, pool, first, "lonely@hlc.com")
defer cleanupUsers(t, pool, first)
if _, err := users.Create(ctx, first, "lonely@hlc.com", CreateUserInput{
DisplayName: "Lonely", Office: "munich", JobTitle: "Counsel",
}); err != nil {
t.Fatalf("bootstrap: %v", err)
}
standard := "standard"
_, err := users.AdminUpdateUser(ctx, first, AdminUpdateInput{GlobalRole: &standard})
if !errors.Is(err, ErrLastGlobalAdmin) {
t.Fatalf("expected ErrLastGlobalAdmin, got %v", err)
}
}
// TestUserService_IsAdmin asserts IsAdmin reads the global_role column.
func TestUserService_IsAdmin(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
ctx := context.Background()
pool.ExecContext(ctx, `DELETE FROM paliad.users`)
id := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000031")
seedAuthUser(t, pool, id, "first@hlc.com")
defer cleanupUsers(t, pool, id)
if _, err := users.Create(ctx, id, "first@hlc.com", CreateUserInput{
DisplayName: "First", Office: "munich", JobTitle: "Counsel",
}); err != nil {
t.Fatalf("create: %v", err)
}
got, err := users.IsAdmin(ctx, id)
if err != nil {
t.Fatalf("IsAdmin: %v", err)
}
if !got {
t.Fatalf("bootstrap user should pass IsAdmin")
}
}