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'
224 lines
6.8 KiB
Go
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")
|
|
}
|
|
}
|