Files
tajniak81andClaude Opus 5 cd16d4383f Orgs: let any user create an organization and become its admin
Organization writes were superadmin-only, so standing up a tenant needed
an out-of-band superadmin. Creating one is now self-service, and an admin
manages the org they belong to.

- POST /api/orgs is open to any authenticated user. A creator who isn't a
  superadmin must have no organization yet (a single-valued membership
  relation means a second one would abandon the first), and is promoted to
  the new org's admin and first member in the same request. If that
  promotion fails the org is rolled back, so it is never left stranded
  with nobody able to administer it. Superadmins still create tenants
  without joining them.
- PATCH/DELETE are manager-gated and scope an admin to their own org. An
  admin deletes theirs only as its sole member: they are detached and
  demoted to a plain user before the record goes, so the org is empty when
  it is removed. Other members still block deletion with a 409.
- /api/me now carries organization + organizationName, which the clients
  need to tell "no org yet" from "org you administer".

The panel, Web App (new OrgManager.vue in Settings) and Phone App (new
_OrganizationSection) all mirror the server's gates rather than
re-deciding them. The Phone App cached its role at login and gates the
Users tab on it, so AuthService.adoptRole refreshes that from the profile
instead of making a freshly promoted admin sign in again.

Covered by orgs_test.go, which drives the real handler + middleware chain
against a stand-in PocketBase: promotion, the already-a-member refusal,
superadmin staying unattached, the rollback, own-org scoping, the
detach-and-demote, and the blocking-member 409.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:54:02 +02:00

334 lines
11 KiB
Go

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