diff --git a/API Server/internal/api/users_test.go b/API Server/internal/api/users_test.go new file mode 100644 index 0000000..d9914c2 --- /dev/null +++ b/API Server/internal/api/users_test.go @@ -0,0 +1,194 @@ +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"]) + } + }) + } +} diff --git a/Web App/README.md b/Web App/README.md index c58edb0..e8ef6b2 100644 --- a/Web App/README.md +++ b/Web App/README.md @@ -21,7 +21,7 @@ server/ Go BFF: embeds web/dist, proxies /api -> API_BASE web/ Vue 3 + Vite + Tailwind v4 source src/ main.js app bootstrap - router.js /login, / (dashboard), /charging, /cars/:id, /settings, /admin + router.js /login, / (dashboard), /charging, /cars/:id, /settings api.js the only place that calls the API Server (base URL resolution) auth.js token/profile state, isAdmin prefs.js theme/locale/date/font preferences -> @@ -29,12 +29,12 @@ web/ Vue 3 + Vite + Tailwind v4 source lib/format.js date/km formatting + next-service status badges lib/attachment.js upload / fetch / open a record's attached file style.css Tailwind v4 entry (+ dark custom-variant) - App.vue layout shell + nav (Charging + Admin links when relevant) + App.vue layout shell + nav (Garage, Charging, Settings) components/ Modal, AttachmentField, CarFormModal, ServiceFormModal, TechnicalCheckFormModal, MaintenanceFormModal, FuelFormModal, DocumentFormModal, ReminderFormModal, PartFormModal, ShareModal, - OrgManager, Logo - views/ Login, Dashboard, CarDetail, Charging, Settings, AdminUsers + OrgManager, AdminUsers, Logo + views/ Login, Dashboard, CarDetail, Charging, Settings ``` ## Requirements @@ -116,13 +116,14 @@ Config (`server/.env`, copy from `.env.example`): - **Charging** — the EV charging screen for connected Anker Solix chargers: live status and, in own/proxy control mode, start/stop and charge-limit controls driven by the API Server's OCPP Central System. -- **Settings** — split into tabs: account (name / email verification / password), - appearance (theme light/dark/system, locale, date format, currency, font size), - profile (avatar, bio), **integrations** (Toyota, Anker Solix), **organization** - (create your own — which makes you its admin — or rename/delete the one you - administer), data **export/import**, and the account-deletion state machine. -- **Admin** — `/admin` user management (list / create / role / reset password / - delete), gated by the admin role via a router guard + nav link. +- **Settings** — split into tabs: *Personal settings* — account (name / email + verification / password), appearance (theme light/dark/system, locale, date + format, currency, font size), profile (avatar, bio), data **export/import**, + and the account-deletion state machine; *Integrations* (Toyota, Anker Solix); + *Users* for admins; and *Organization* (create your own — which makes you its + admin — or rename/delete the one you administer). +- **Users** — user management (list / create / role / reset password / delete) + as the admin-only Settings tab; `/admin` redirects there for old links. - **Theming** — light/dark/system app-wide (Tailwind v4 class strategy); `prefs.js` toggles `.dark` on `` and applies the saved theme/locale/date/font. diff --git a/Web App/web/src/App.vue b/Web App/web/src/App.vue index 05df09a..631f0fc 100644 --- a/Web App/web/src/App.vue +++ b/Web App/web/src/App.vue @@ -1,7 +1,7 @@