package api import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "sync" "testing" "drivervault/apiserver/internal/config" "drivervault/apiserver/internal/pb" ) // These tests cover the self-service organization flows through the real // Handler + middleware chain: an org-less user creating an org and being // promoted to its admin (including the rollback when that promotion fails), and // an admin renaming or deleting only their own organization. A stand-in // PocketBase (orgFakePB) serves exactly the endpoints those paths hit. const ( orgTestUserID = "u1" orgTestBearer = "user-bearer-token" orgTestNewID = "neworg1" ) // orgFakePB answers the identity, organization and user-record calls the org // handlers make, and records the writes so tests can assert on them. type orgFakePB struct { mu sync.Mutex // Identity returned by auth-refresh. callerRole string callerOrg string // Behaviour switches. userPatchStatus int // status for a caller promotion/demotion (200 when 0) memberCount int // totalItems for the blocking-member query // Recorded effects. createdOrgs []string orgPatches []map[string]any orgDeletes []string userPatches []map[string]any } func (f *orgFakePB) handler(t *testing.T) http.Handler { t.Helper() 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": orgTestUserID, "email": "owner@test.local", "name": "Owner", "role": f.callerRole, "organization": f.callerOrg, }}) }) // Organization create / rename / delete. mux.HandleFunc("POST /api/collections/organizations/records", func(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) f.mu.Lock() f.createdOrgs = append(f.createdOrgs, orgTestNewID) f.mu.Unlock() writeJSON(w, 200, map[string]any{"id": orgTestNewID, "name": body["name"], "created": "2026-08-17 10:00:00Z"}) }) mux.HandleFunc("PATCH /api/collections/organizations/records/{id}", func(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) f.mu.Lock() f.orgPatches = append(f.orgPatches, body) f.mu.Unlock() writeJSON(w, 200, map[string]any{"id": r.PathValue("id"), "name": body["name"]}) }) mux.HandleFunc("DELETE /api/collections/organizations/records/{id}", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() f.orgDeletes = append(f.orgDeletes, r.PathValue("id")) f.mu.Unlock() writeJSON(w, 204, nil) }) // Blocking-member count for a delete. mux.HandleFunc("GET /api/collections/users/records", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() writeJSON(w, 200, map[string]any{"totalItems": f.memberCount, "items": []any{}}) }) // Caller promotion / demotion. mux.HandleFunc("PATCH /api/collections/users/records/{id}", func(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) f.mu.Lock() f.userPatches = append(f.userPatches, body) status := f.userPatchStatus f.mu.Unlock() if status != 0 && status != 200 { writeJSON(w, status, map[string]any{"message": "cannot update user"}) return } writeJSON(w, 200, map[string]any{"id": r.PathValue("id")}) }) return mux } // newOrgTestServer wires the fake PocketBase to a real Handler and returns the // app's base URL. func newOrgTestServer(t *testing.T, f *orgFakePB) string { t.Helper() pbSrv := httptest.NewServer(f.handler(t)) 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 } // orgReq issues an authenticated request and returns the status and decoded body. func orgReq(t *testing.T, base, method, path string, body any) (int, map[string]any) { t.Helper() var buf bytes.Buffer if body != nil { if err := json.NewEncoder(&buf).Encode(body); err != nil { t.Fatal(err) } } req, err := http.NewRequest(method, base+path, &buf) if err != nil { t.Fatal(err) } req.Header.Set("Authorization", "Bearer "+orgTestBearer) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() out := map[string]any{} _ = json.NewDecoder(resp.Body).Decode(&out) return resp.StatusCode, out } func TestCreateOrgPromotesCreatorToAdmin(t *testing.T) { f := &orgFakePB{callerRole: roleUser, callerOrg: ""} base := newOrgTestServer(t, f) status, body := orgReq(t, base, http.MethodPost, "/api/orgs", map[string]any{"name": "Acme Inc."}) if status != http.StatusCreated { t.Fatalf("status = %d, want 201 (body %v)", status, body) } org, _ := body["organization"].(map[string]any) if org["id"] != orgTestNewID || org["name"] != "Acme Inc." { t.Fatalf("organization = %v, want id %q name %q", org, orgTestNewID, "Acme Inc.") } if len(f.userPatches) != 1 { t.Fatalf("user patches = %d, want 1 (%v)", len(f.userPatches), f.userPatches) } patch := f.userPatches[0] if patch["organization"] != orgTestNewID { t.Errorf("patched organization = %v, want %q", patch["organization"], orgTestNewID) } if patch["role"] != roleAdmin { t.Errorf("patched role = %v, want %q", patch["role"], roleAdmin) } if len(f.orgDeletes) != 0 { t.Errorf("org was rolled back unexpectedly: %v", f.orgDeletes) } } func TestCreateOrgRejectedWhenCallerAlreadyHasOne(t *testing.T) { f := &orgFakePB{callerRole: roleUser, callerOrg: "existing1"} base := newOrgTestServer(t, f) status, body := orgReq(t, base, http.MethodPost, "/api/orgs", map[string]any{"name": "Second Org"}) if status != http.StatusForbidden { t.Fatalf("status = %d, want 403 (body %v)", status, body) } if len(f.createdOrgs) != 0 { t.Errorf("created orgs = %v, want none", f.createdOrgs) } if len(f.userPatches) != 0 { t.Errorf("user patches = %v, want none", f.userPatches) } } // A superadmin manages every tenant centrally, so creating one must not move // them into it or change their role. func TestCreateOrgLeavesSuperadminUnattached(t *testing.T) { f := &orgFakePB{callerRole: roleSuperadmin, callerOrg: ""} base := newOrgTestServer(t, f) status, body := orgReq(t, base, http.MethodPost, "/api/orgs", map[string]any{"name": "Tenant A"}) if status != http.StatusCreated { t.Fatalf("status = %d, want 201 (body %v)", status, body) } if len(f.userPatches) != 0 { t.Errorf("user patches = %v, want none for a superadmin", f.userPatches) } } // If the creator cannot be promoted, the new org must not survive — otherwise it // is stranded with nobody able to administer it. func TestCreateOrgRollsBackWhenPromotionFails(t *testing.T) { f := &orgFakePB{callerRole: roleUser, callerOrg: "", userPatchStatus: http.StatusBadRequest} base := newOrgTestServer(t, f) status, _ := orgReq(t, base, http.MethodPost, "/api/orgs", map[string]any{"name": "Doomed"}) if status == http.StatusCreated { t.Fatalf("status = %d, want a failure", status) } if len(f.orgDeletes) != 1 || f.orgDeletes[0] != orgTestNewID { t.Errorf("org deletes = %v, want rollback of %q", f.orgDeletes, orgTestNewID) } } func TestCreateOrgRejectsBadName(t *testing.T) { f := &orgFakePB{callerRole: roleUser, callerOrg: ""} base := newOrgTestServer(t, f) status, _ := orgReq(t, base, http.MethodPost, "/api/orgs", map[string]any{"name": " "}) if status != http.StatusBadRequest { t.Fatalf("status = %d, want 400", status) } if len(f.createdOrgs) != 0 { t.Errorf("created orgs = %v, want none", f.createdOrgs) } } func TestUpdateOrgAdminScopedToOwnOrg(t *testing.T) { f := &orgFakePB{callerRole: roleAdmin, callerOrg: "o1"} base := newOrgTestServer(t, f) // Someone else's organization is off limits. status, _ := orgReq(t, base, http.MethodPatch, "/api/orgs/o2", map[string]any{"name": "Hijack"}) if status != http.StatusForbidden { t.Fatalf("foreign org status = %d, want 403", status) } if len(f.orgPatches) != 0 { t.Fatalf("org patches = %v, want none", f.orgPatches) } // Their own is fine. status, body := orgReq(t, base, http.MethodPatch, "/api/orgs/o1", map[string]any{"name": "Renamed"}) if status != http.StatusOK { t.Fatalf("own org status = %d, want 200 (body %v)", status, body) } if len(f.orgPatches) != 1 || f.orgPatches[0]["name"] != "Renamed" { t.Errorf("org patches = %v, want one rename to %q", f.orgPatches, "Renamed") } } // An admin deleting their own org is detached and demoted first, so the org is // empty by the time it is removed. func TestDeleteOrgAdminSoleMemberIsDetachedAndDemoted(t *testing.T) { f := &orgFakePB{callerRole: roleAdmin, callerOrg: "o1", memberCount: 0} base := newOrgTestServer(t, f) status, body := orgReq(t, base, http.MethodDelete, "/api/orgs/o1", nil) if status != http.StatusOK { t.Fatalf("status = %d, want 200 (body %v)", status, body) } if len(f.userPatches) != 1 { t.Fatalf("user patches = %d, want 1 (%v)", len(f.userPatches), f.userPatches) } patch := f.userPatches[0] if patch["organization"] != "" { t.Errorf("patched organization = %v, want cleared", patch["organization"]) } if patch["role"] != roleUser { t.Errorf("patched role = %v, want %q", patch["role"], roleUser) } if len(f.orgDeletes) != 1 || f.orgDeletes[0] != "o1" { t.Errorf("org deletes = %v, want [o1]", f.orgDeletes) } } func TestDeleteOrgBlockedByOtherMembers(t *testing.T) { f := &orgFakePB{callerRole: roleAdmin, callerOrg: "o1", memberCount: 2} base := newOrgTestServer(t, f) status, _ := orgReq(t, base, http.MethodDelete, "/api/orgs/o1", nil) if status != http.StatusConflict { t.Fatalf("status = %d, want 409", status) } if len(f.orgDeletes) != 0 { t.Errorf("org deletes = %v, want none", f.orgDeletes) } if len(f.userPatches) != 0 { t.Errorf("user patches = %v, want none — the admin must stay put", f.userPatches) } } func TestDeleteOrgAdminCannotDeleteForeignOrg(t *testing.T) { f := &orgFakePB{callerRole: roleAdmin, callerOrg: "o1"} base := newOrgTestServer(t, f) status, _ := orgReq(t, base, http.MethodDelete, "/api/orgs/o2", nil) if status != http.StatusForbidden { t.Fatalf("status = %d, want 403", status) } if len(f.orgDeletes) != 0 { t.Errorf("org deletes = %v, want none", f.orgDeletes) } } // A plain user is not a manager, so rename/delete stay closed to them even for // the org they belong to. func TestPlainUserCannotManageOrgs(t *testing.T) { f := &orgFakePB{callerRole: roleUser, callerOrg: "o1"} base := newOrgTestServer(t, f) if status, _ := orgReq(t, base, http.MethodPatch, "/api/orgs/o1", map[string]any{"name": "Nope"}); status != http.StatusForbidden { t.Errorf("rename status = %d, want 403", status) } if status, _ := orgReq(t, base, http.MethodDelete, "/api/orgs/o1", nil); status != http.StatusForbidden { t.Errorf("delete status = %d, want 403", status) } }