Files
DriverVault/API Server/internal/api/users_test.go
T
tajniak81andClaude Opus 5 e373497958 Settings: fold Users and Organization into the Settings tabs
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>
2026-08-17 17:38:54 +02:00

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