The left rail is back to the three places you actually go — Garage, Charging, Settings — and user management moves inside Settings as an admin-only tab, next to a new Organization tab that used to be a card buried in the personal settings. Tab order is Personal settings, Users, Organization, Integrations. /admin redirects to /settings?tab=users so old links keep working, and ?tab= picks the starting tab in general. AdminUsers moves from views/ to components/ since it is a panel now, not a route, and its page header becomes a section header like its neighbours. The personal panel was split in two around the integrations markup, which left no gap between the Profile and Privacy cards; it is one block again. Creating a user gets an organization picker for superadmins, defaulting to "no organization" so an org-less account stays a deliberate choice. Admins see no picker: the server pins their members to their own org regardless, which users_test.go now covers along with both superadmin paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
195 lines
6.9 KiB
Go
195 lines
6.9 KiB
Go
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"])
|
|
}
|
|
})
|
|
}
|
|
}
|