package api import ( "encoding/json" "net/http" "net/http/httptest" "sync" "testing" "drivervault/apiserver/internal/config" "drivervault/apiserver/internal/pb" ) // These tests cover who a newly created account belongs to, through the real // Handler + middleware chain: an admin's members are pinned to the admin's own // organization no matter what the client sends, while a superadmin picks the // organization freely — including omitting it to create an org-less account. // A stand-in PocketBase (userFakePB) serves the calls those paths hit. const ( userTestCallerID = "u1" userTestBearer = "user-bearer-token" userTestNewID = "newuser1" userTestOrgA = "orgA" // the caller's own organization userTestOrgB = "orgB" // some other organization ) // userFakePB answers the identity and user-create calls, recording the create // payload so tests can assert on the organization that was actually written. type userFakePB struct { mu sync.Mutex // Identity returned by auth-refresh. callerRole string callerOrg string // Recorded effects. createdUsers []map[string]any } func (f *userFakePB) handler() http.Handler { mux := http.NewServeMux() // Service-account auth (pb.Client.Authenticate). mux.HandleFunc("POST /api/collections/_superusers/auth-with-password", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"token": "svc-token"}) }) // Identify the bearer (withAuth → identify → AuthRefresh). mux.HandleFunc("POST /api/collections/users/auth-refresh", func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") == "" { writeJSON(w, 401, map[string]any{}) return } f.mu.Lock() defer f.mu.Unlock() writeJSON(w, 200, map[string]any{"record": map[string]any{ "id": userTestCallerID, "email": "boss@test.local", "name": "Boss", "role": f.callerRole, "organization": f.callerOrg, }}) }) // User create. mux.HandleFunc("POST /api/collections/users/records", func(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) f.mu.Lock() f.createdUsers = append(f.createdUsers, body) f.mu.Unlock() org, _ := body["organization"].(string) writeJSON(w, 200, map[string]any{ "id": userTestNewID, "email": body["email"], "name": body["name"], "role": body["role"], "verified": true, "organization": org, "created": "2026-08-17 10:00:00Z", }) }) // Organization name lookup for the created record. mux.HandleFunc("GET /api/collections/organizations/records/{id}", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"id": r.PathValue("id"), "name": "Org " + r.PathValue("id")}) }) return mux } // newUserTestServer wires the fake PocketBase to a real Handler and returns the // app's base URL. func newUserTestServer(t *testing.T, f *userFakePB) string { t.Helper() pbSrv := httptest.NewServer(f.handler()) t.Cleanup(pbSrv.Close) s := New(config.Config{UsersCollection: "users"}, pb.New(pbSrv.URL, "admin@test.local", "pw")) appSrv := httptest.NewServer(s.Handler()) t.Cleanup(appSrv.Close) return appSrv.URL } // An admin's new members join the admin's own organization, even when the // request asks for a different one. func TestCreateUserAdminForcesOwnOrganization(t *testing.T) { f := &userFakePB{callerRole: roleAdmin, callerOrg: userTestOrgA} base := newUserTestServer(t, f) status, body := orgReq(t, base, http.MethodPost, "/api/users", map[string]any{ "email": "member@test.local", "password": "hunter2hunter2", "name": "Member", "role": "user", "organization": userTestOrgB, }) if status != http.StatusCreated { t.Fatalf("status = %d, want 201 (body %v)", status, body) } if len(f.createdUsers) != 1 { t.Fatalf("created users = %d, want 1 (%v)", len(f.createdUsers), f.createdUsers) } if got := f.createdUsers[0]["organization"]; got != userTestOrgA { t.Errorf("created organization = %v, want the admin's own org %q", got, userTestOrgA) } } // An admin with no organization has nowhere to put a member, so the create is // refused rather than producing a stray org-less account. func TestCreateUserAdminWithoutOrganizationRejected(t *testing.T) { f := &userFakePB{callerRole: roleAdmin, callerOrg: ""} base := newUserTestServer(t, f) status, body := orgReq(t, base, http.MethodPost, "/api/users", map[string]any{ "email": "member@test.local", "password": "hunter2hunter2", }) if status != http.StatusForbidden { t.Fatalf("status = %d, want 403 (body %v)", status, body) } if len(f.createdUsers) != 0 { t.Errorf("created users = %v, want none", f.createdUsers) } } // A superadmin picks the organization, and it is passed through untouched. func TestCreateUserSuperadminHonoursChosenOrganization(t *testing.T) { f := &userFakePB{callerRole: roleSuperadmin, callerOrg: ""} base := newUserTestServer(t, f) status, body := orgReq(t, base, http.MethodPost, "/api/users", map[string]any{ "email": "member@test.local", "password": "hunter2hunter2", "name": "Member", "role": "admin", "organization": userTestOrgB, }) if status != http.StatusCreated { t.Fatalf("status = %d, want 201 (body %v)", status, body) } if len(f.createdUsers) != 1 { t.Fatalf("created users = %d, want 1 (%v)", len(f.createdUsers), f.createdUsers) } if got := f.createdUsers[0]["organization"]; got != userTestOrgB { t.Errorf("created organization = %v, want %q", got, userTestOrgB) } user, _ := body["user"].(map[string]any) if user["organization"] != userTestOrgB { t.Errorf("returned organization = %v, want %q", user["organization"], userTestOrgB) } if user["organizationName"] != "Org "+userTestOrgB { t.Errorf("returned organizationName = %v, want the resolved name", user["organizationName"]) } } // Leaving the picker empty is a deliberate choice: the account is created with // no organization at all, rather than the field being rejected or defaulted. func TestCreateUserSuperadminCanOmitOrganization(t *testing.T) { for _, tc := range []struct { name string body map[string]any }{ {"field absent", map[string]any{"email": "solo@test.local", "password": "hunter2hunter2"}}, {"field empty", map[string]any{"email": "solo@test.local", "password": "hunter2hunter2", "organization": ""}}, } { t.Run(tc.name, func(t *testing.T) { f := &userFakePB{callerRole: roleSuperadmin, callerOrg: ""} base := newUserTestServer(t, f) status, body := orgReq(t, base, http.MethodPost, "/api/users", tc.body) if status != http.StatusCreated { t.Fatalf("status = %d, want 201 (body %v)", status, body) } if len(f.createdUsers) != 1 { t.Fatalf("created users = %d, want 1 (%v)", len(f.createdUsers), f.createdUsers) } if got, ok := f.createdUsers[0]["organization"]; ok { t.Errorf("created organization = %v, want the field to be omitted entirely", got) } user, _ := body["user"].(map[string]any) if user["organization"] != "" { t.Errorf("returned organization = %v, want empty", user["organization"]) } }) } }