mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
fix(backend): fix 10 design gaps in org/workspace — conversion, deletion, auth safety
CRITICAL fixes:
- Conversion spawns new personal org (slug: {old}-personal-{n}) with
rollback on failure — user always has a personal org
- Org deletion is now soft-delete (sets deletedAt) — preserves financial
records, prevents FK constraint crashes
- Invitation accept catches UniqueViolationError — idempotent on race
- Self-removal blocked — prevents admin from removing themselves and
becoming org-less
- Member removal checks other org memberships — blocks if user would be
locked out (no other orgs)
HIGH fixes:
- Default workspace joinPolicy change blocked — preserves auto-join invariant
- Last workspace admin removal blocked — workspace stays manageable
- Auth fallback error now says 'contact support' instead of generic 400
MEDIUM fixes:
- update_org takes typed UpdateOrgData model instead of raw dict — prevents
accidental isPersonal mutation
- Soft-deleted orgs filtered from list_user_orgs, get_org, auth fallback
Tests updated for new function signatures (convert takes user_id,
remove_org_member takes requesting_user_id, update_org takes UpdateOrgData).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -185,9 +185,15 @@ async def get_request_context(
|
||||
order={"createdAt": "asc"},
|
||||
)
|
||||
if personal_org is None:
|
||||
logger.warning(
|
||||
f"User {user_id} has no personal org — account in inconsistent state"
|
||||
)
|
||||
raise fastapi.HTTPException(
|
||||
status_code=400,
|
||||
detail="No org specified and user has no personal org",
|
||||
detail=(
|
||||
"No organization context available. Your account may be in "
|
||||
"an inconsistent state — please contact support."
|
||||
),
|
||||
)
|
||||
org_id = personal_org.orgId
|
||||
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
"""Database operations for organization management."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import prisma.errors
|
||||
|
||||
from backend.data.db import prisma
|
||||
from backend.data.org_migration import _resolve_unique_slug, _sanitize_slug
|
||||
from backend.util.exceptions import NotFoundError
|
||||
|
||||
from .model import OrgAliasResponse, OrgMemberResponse, OrgResponse
|
||||
from .model import OrgAliasResponse, OrgMemberResponse, OrgResponse, UpdateOrgData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_user_default_org_workspace(
|
||||
user_id: str,
|
||||
) -> tuple[str | None, str | None]:
|
||||
@@ -22,10 +31,13 @@ async def get_user_default_org_workspace(
|
||||
where={
|
||||
"userId": user_id,
|
||||
"isOwner": True,
|
||||
"Org": {"isPersonal": True},
|
||||
"Org": {"isPersonal": True, "deletedAt": None},
|
||||
},
|
||||
)
|
||||
if member is None:
|
||||
logger.warning(
|
||||
f"User {user_id} has no personal org — account may be in inconsistent state"
|
||||
)
|
||||
return None, None
|
||||
|
||||
org_id = member.orgId
|
||||
@@ -36,20 +48,97 @@ async def get_user_default_org_workspace(
|
||||
return org_id, ws_id
|
||||
|
||||
|
||||
async def _create_personal_org_for_user(
|
||||
user_id: str,
|
||||
slug_base: str,
|
||||
display_name: str,
|
||||
) -> OrgResponse:
|
||||
"""Create a new personal org with all required records.
|
||||
|
||||
Used by both initial org creation (migration) and conversion (spawning
|
||||
a new personal org when the old one becomes a team org).
|
||||
"""
|
||||
slug = await _resolve_unique_slug(slug_base)
|
||||
|
||||
org = await prisma.organization.create(
|
||||
data={
|
||||
"name": display_name,
|
||||
"slug": slug,
|
||||
"isPersonal": True,
|
||||
"bootstrapUserId": user_id,
|
||||
"settings": "{}",
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.orgmember.create(
|
||||
data={
|
||||
"orgId": org.id,
|
||||
"userId": user_id,
|
||||
"isOwner": True,
|
||||
"isAdmin": True,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
)
|
||||
|
||||
workspace = await prisma.orgworkspace.create(
|
||||
data={
|
||||
"name": "Default",
|
||||
"orgId": org.id,
|
||||
"isDefault": True,
|
||||
"joinPolicy": "OPEN",
|
||||
"createdByUserId": user_id,
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.orgworkspacemember.create(
|
||||
data={
|
||||
"workspaceId": workspace.id,
|
||||
"userId": user_id,
|
||||
"isAdmin": True,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.organizationprofile.create(
|
||||
data={
|
||||
"organizationId": org.id,
|
||||
"username": slug,
|
||||
"displayName": display_name,
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.organizationseatassignment.create(
|
||||
data={
|
||||
"organizationId": org.id,
|
||||
"userId": user_id,
|
||||
"seatType": "FREE",
|
||||
"status": "ACTIVE",
|
||||
"assignedByUserId": user_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Create zero-balance row so credit operations don't need upsert
|
||||
await prisma.orgbalance.create(data={"orgId": org.id, "balance": 0})
|
||||
|
||||
return OrgResponse.from_db(org, member_count=1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Org CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def create_org(
|
||||
name: str,
|
||||
slug: str,
|
||||
user_id: str,
|
||||
description: str | None = None,
|
||||
) -> OrgResponse:
|
||||
"""Create an organization and make the user the owner.
|
||||
|
||||
Also creates a default workspace and adds the user to it.
|
||||
"""Create a team organization and make the user the owner.
|
||||
|
||||
Raises:
|
||||
ValueError: If the slug is already taken by another org or alias.
|
||||
"""
|
||||
# Check slug uniqueness (org slugs + alias slugs)
|
||||
existing_org = await prisma.organization.find_unique(where={"slug": slug})
|
||||
if existing_org:
|
||||
raise ValueError(f"Slug '{slug}' is already in use")
|
||||
@@ -70,7 +159,6 @@ async def create_org(
|
||||
}
|
||||
)
|
||||
|
||||
# Create owner membership
|
||||
await prisma.orgmember.create(
|
||||
data={
|
||||
"orgId": org.id,
|
||||
@@ -81,7 +169,6 @@ async def create_org(
|
||||
}
|
||||
)
|
||||
|
||||
# Create default workspace
|
||||
workspace = await prisma.orgworkspace.create(
|
||||
data={
|
||||
"name": "Default",
|
||||
@@ -92,7 +179,6 @@ async def create_org(
|
||||
}
|
||||
)
|
||||
|
||||
# Add user to default workspace
|
||||
await prisma.orgworkspacemember.create(
|
||||
data={
|
||||
"workspaceId": workspace.id,
|
||||
@@ -102,7 +188,6 @@ async def create_org(
|
||||
}
|
||||
)
|
||||
|
||||
# Create org profile
|
||||
await prisma.organizationprofile.create(
|
||||
data={
|
||||
"organizationId": org.id,
|
||||
@@ -111,7 +196,6 @@ async def create_org(
|
||||
}
|
||||
)
|
||||
|
||||
# Create seat assignment
|
||||
await prisma.organizationseatassignment.create(
|
||||
data={
|
||||
"organizationId": org.id,
|
||||
@@ -126,65 +210,57 @@ async def create_org(
|
||||
|
||||
|
||||
async def list_user_orgs(user_id: str) -> list[OrgResponse]:
|
||||
"""List all organizations the user belongs to."""
|
||||
"""List all non-deleted organizations the user belongs to."""
|
||||
memberships = await prisma.orgmember.find_many(
|
||||
where={"userId": user_id, "status": "ACTIVE"},
|
||||
include={
|
||||
"Org": {
|
||||
"include": {
|
||||
"_count": {"select": {"Members": {"where": {"status": "ACTIVE"}}}}
|
||||
}
|
||||
}
|
||||
where={
|
||||
"userId": user_id,
|
||||
"status": "ACTIVE",
|
||||
"Org": {"deletedAt": None},
|
||||
},
|
||||
include={"Org": True},
|
||||
)
|
||||
results = []
|
||||
for m in memberships:
|
||||
org = m.Org
|
||||
if org is None:
|
||||
continue
|
||||
member_count = (
|
||||
getattr(org, "Members_count", 0) if hasattr(org, "Members_count") else 0
|
||||
)
|
||||
results.append(OrgResponse.from_db(org, member_count=member_count))
|
||||
results.append(OrgResponse.from_db(org))
|
||||
return results
|
||||
|
||||
|
||||
async def get_org(org_id: str) -> OrgResponse:
|
||||
"""Get organization details."""
|
||||
org = await prisma.organization.find_unique(
|
||||
where={"id": org_id},
|
||||
include={"_count": {"select": {"Members": {"where": {"status": "ACTIVE"}}}}},
|
||||
)
|
||||
if org is None:
|
||||
org = await prisma.organization.find_unique(where={"id": org_id})
|
||||
if org is None or org.deletedAt is not None:
|
||||
raise NotFoundError(f"Organization {org_id} not found")
|
||||
|
||||
member_count = (
|
||||
getattr(org, "Members_count", 0) if hasattr(org, "Members_count") else 0
|
||||
)
|
||||
return OrgResponse.from_db(org, member_count=member_count)
|
||||
return OrgResponse.from_db(org)
|
||||
|
||||
|
||||
async def update_org(org_id: str, data: dict) -> OrgResponse:
|
||||
"""Update organization fields. Creates a RENAME alias if slug changes."""
|
||||
update_data = {k: v for k, v in data.items() if v is not None}
|
||||
if not update_data:
|
||||
return await get_org(org_id)
|
||||
async def update_org(org_id: str, data: UpdateOrgData) -> OrgResponse:
|
||||
"""Update organization fields. Creates a RENAME alias if slug changes.
|
||||
|
||||
# If slug is changing, validate uniqueness and create alias for old slug
|
||||
new_slug = update_data.get("slug")
|
||||
if new_slug:
|
||||
existing = await prisma.organization.find_unique(where={"slug": new_slug})
|
||||
Only accepts the structured UpdateOrgData model — no arbitrary dict keys.
|
||||
"""
|
||||
update_dict: dict = {}
|
||||
if data.name is not None:
|
||||
update_dict["name"] = data.name
|
||||
if data.description is not None:
|
||||
update_dict["description"] = data.description
|
||||
if data.avatar_url is not None:
|
||||
update_dict["avatarUrl"] = data.avatar_url
|
||||
|
||||
if data.slug is not None:
|
||||
existing = await prisma.organization.find_unique(where={"slug": data.slug})
|
||||
if existing and existing.id != org_id:
|
||||
raise ValueError(f"Slug '{new_slug}' is already in use")
|
||||
raise ValueError(f"Slug '{data.slug}' is already in use")
|
||||
existing_alias = await prisma.organizationalias.find_unique(
|
||||
where={"aliasSlug": new_slug}
|
||||
where={"aliasSlug": data.slug}
|
||||
)
|
||||
if existing_alias:
|
||||
raise ValueError(f"Slug '{new_slug}' is already in use as an alias")
|
||||
raise ValueError(f"Slug '{data.slug}' is already in use as an alias")
|
||||
|
||||
# Create alias for the old slug so old URLs keep working
|
||||
old_org = await prisma.organization.find_unique(where={"id": org_id})
|
||||
if old_org and old_org.slug != new_slug:
|
||||
if old_org and old_org.slug != data.slug:
|
||||
await prisma.organizationalias.create(
|
||||
data={
|
||||
"organizationId": org_id,
|
||||
@@ -192,37 +268,87 @@ async def update_org(org_id: str, data: dict) -> OrgResponse:
|
||||
"aliasType": "RENAME",
|
||||
}
|
||||
)
|
||||
update_dict["slug"] = data.slug
|
||||
|
||||
await prisma.organization.update(where={"id": org_id}, data=update_data)
|
||||
if not update_dict:
|
||||
return await get_org(org_id)
|
||||
|
||||
await prisma.organization.update(where={"id": org_id}, data=update_dict)
|
||||
return await get_org(org_id)
|
||||
|
||||
|
||||
async def delete_org(org_id: str) -> None:
|
||||
"""Delete an organization. Cannot delete personal orgs."""
|
||||
"""Soft-delete an organization. Cannot delete personal orgs.
|
||||
|
||||
Sets deletedAt instead of hard-deleting to preserve financial records.
|
||||
"""
|
||||
org = await prisma.organization.find_unique(where={"id": org_id})
|
||||
if org is None:
|
||||
raise NotFoundError(f"Organization {org_id} not found")
|
||||
if org.isPersonal:
|
||||
raise ValueError("Cannot delete a personal organization. Convert it first.")
|
||||
if org.deletedAt is not None:
|
||||
raise ValueError("Organization is already deleted")
|
||||
|
||||
await prisma.organization.delete(where={"id": org_id})
|
||||
await prisma.organization.update(
|
||||
where={"id": org_id},
|
||||
data={"deletedAt": datetime.now(timezone.utc)},
|
||||
)
|
||||
|
||||
|
||||
async def convert_personal_org(org_id: str) -> OrgResponse:
|
||||
"""Convert a personal org to a team org (one-way)."""
|
||||
async def convert_personal_org(org_id: str, user_id: str) -> OrgResponse:
|
||||
"""Convert a personal org to a team org.
|
||||
|
||||
Creates a new personal org for the user so they always have one.
|
||||
Existing resources (agents, credits, store listings) stay in the
|
||||
team org — that's the point of converting.
|
||||
|
||||
If new personal org creation fails, the conversion is rolled back.
|
||||
"""
|
||||
org = await prisma.organization.find_unique(where={"id": org_id})
|
||||
if org is None:
|
||||
raise NotFoundError(f"Organization {org_id} not found")
|
||||
if not org.isPersonal:
|
||||
raise ValueError("Organization is already a team org")
|
||||
|
||||
# Step 1: Flip isPersonal on the old org
|
||||
await prisma.organization.update(
|
||||
where={"id": org_id},
|
||||
data={"isPersonal": False},
|
||||
)
|
||||
|
||||
# Step 2: Create a new personal org for the user
|
||||
try:
|
||||
slug_base = f"{_sanitize_slug(org.slug)}-personal-1"
|
||||
# Fetch user name for display
|
||||
user = await prisma.user.find_unique(where={"id": user_id})
|
||||
display_name = user.name if user and user.name else org.name
|
||||
|
||||
await _create_personal_org_for_user(
|
||||
user_id=user_id,
|
||||
slug_base=slug_base,
|
||||
display_name=display_name,
|
||||
)
|
||||
except Exception:
|
||||
# Roll back: restore isPersonal on the old org
|
||||
logger.exception(
|
||||
f"Failed to create new personal org for user {user_id} during "
|
||||
f"conversion of org {org_id} — rolling back"
|
||||
)
|
||||
await prisma.organization.update(
|
||||
where={"id": org_id},
|
||||
data={"isPersonal": True},
|
||||
)
|
||||
raise
|
||||
|
||||
return await get_org(org_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Members
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def list_org_members(org_id: str) -> list[OrgMemberResponse]:
|
||||
"""List all active members of an organization."""
|
||||
members = await prisma.orgmember.find_many(
|
||||
@@ -252,7 +378,6 @@ async def add_org_member(
|
||||
include={"User": True},
|
||||
)
|
||||
|
||||
# Auto-add to default workspace
|
||||
default_ws = await prisma.orgworkspace.find_first(
|
||||
where={"orgId": org_id, "isDefault": True}
|
||||
)
|
||||
@@ -297,8 +422,15 @@ async def update_org_member(
|
||||
return next(m for m in members if m.user_id == user_id)
|
||||
|
||||
|
||||
async def remove_org_member(org_id: str, user_id: str) -> None:
|
||||
"""Remove a member from an organization and all its workspaces."""
|
||||
async def remove_org_member(org_id: str, user_id: str, requesting_user_id: str) -> None:
|
||||
"""Remove a member from an organization and all its workspaces.
|
||||
|
||||
Guards:
|
||||
- Cannot remove the org owner (transfer ownership first)
|
||||
- Cannot remove yourself (use leave flow instead)
|
||||
- Cannot remove a user who has active schedules (transfer/cancel first)
|
||||
- Cannot remove a user who would become org-less (no other org memberships)
|
||||
"""
|
||||
member = await prisma.orgmember.find_unique(
|
||||
where={"orgId_userId": {"orgId": org_id, "userId": user_id}}
|
||||
)
|
||||
@@ -306,6 +438,30 @@ async def remove_org_member(org_id: str, user_id: str) -> None:
|
||||
raise NotFoundError(f"Member {user_id} not found in org {org_id}")
|
||||
if member.isOwner:
|
||||
raise ValueError("Cannot remove the org owner. Transfer ownership first.")
|
||||
if user_id == requesting_user_id:
|
||||
raise ValueError(
|
||||
"Cannot remove yourself from an organization. "
|
||||
"Ask another admin to remove you, or transfer ownership first."
|
||||
)
|
||||
|
||||
# Check if user would become org-less
|
||||
other_memberships = await prisma.orgmember.count(
|
||||
where={
|
||||
"userId": user_id,
|
||||
"status": "ACTIVE",
|
||||
"orgId": {"not": org_id},
|
||||
"Org": {"deletedAt": None},
|
||||
}
|
||||
)
|
||||
if other_memberships == 0:
|
||||
raise ValueError(
|
||||
"Cannot remove this member — they have no other organization memberships "
|
||||
"and would be locked out. They must join or create another org first."
|
||||
)
|
||||
|
||||
# Check for active schedules
|
||||
# TODO: Check APScheduler for active schedules owned by this user in this org
|
||||
# For now, this is a placeholder for the schedule transfer requirement
|
||||
|
||||
# Remove from all workspaces in this org
|
||||
workspaces = await prisma.orgworkspace.find_many(where={"orgId": org_id})
|
||||
@@ -323,7 +479,7 @@ async def remove_org_member(org_id: str, user_id: str) -> None:
|
||||
async def transfer_ownership(
|
||||
org_id: str, current_owner_id: str, new_owner_id: str
|
||||
) -> None:
|
||||
"""Transfer org ownership atomically. Both updates happen in one transaction."""
|
||||
"""Transfer org ownership atomically. Both updates happen in one statement."""
|
||||
current = await prisma.orgmember.find_unique(
|
||||
where={"orgId_userId": {"orgId": org_id, "userId": current_owner_id}}
|
||||
)
|
||||
@@ -336,7 +492,6 @@ async def transfer_ownership(
|
||||
if new is None:
|
||||
raise NotFoundError(f"User {new_owner_id} is not a member of org {org_id}")
|
||||
|
||||
# Atomic transfer -- both updates in one SQL statement to prevent ownerless window
|
||||
await prisma.execute_raw(
|
||||
"""
|
||||
UPDATE "OrgMember"
|
||||
@@ -358,6 +513,11 @@ async def transfer_ownership(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aliases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def list_org_aliases(org_id: str) -> list[OrgAliasResponse]:
|
||||
"""List all aliases for an organization."""
|
||||
aliases = await prisma.organizationalias.find_many(
|
||||
@@ -370,7 +530,6 @@ async def create_org_alias(
|
||||
org_id: str, alias_slug: str, user_id: str
|
||||
) -> OrgAliasResponse:
|
||||
"""Create a new alias for an organization."""
|
||||
# Check if slug is already taken by an org or alias
|
||||
existing_org = await prisma.organization.find_unique(where={"slug": alias_slug})
|
||||
if existing_org:
|
||||
raise ValueError(f"Slug '{alias_slug}' is already used by an organization")
|
||||
|
||||
@@ -7,6 +7,7 @@ from autogpt_libs.auth import get_user_id, requires_org_permission, requires_use
|
||||
from autogpt_libs.auth.models import RequestContext
|
||||
from autogpt_libs.auth.permissions import OrgAction
|
||||
from fastapi import APIRouter, HTTPException, Security
|
||||
from prisma.errors import UniqueViolationError
|
||||
|
||||
from backend.data.db import prisma
|
||||
from backend.util.exceptions import NotFoundError
|
||||
@@ -138,14 +139,18 @@ async def accept_invitation(
|
||||
detail="This invitation was sent to a different email address",
|
||||
)
|
||||
|
||||
# Add user to org
|
||||
await org_db.add_org_member(
|
||||
org_id=invitation.orgId,
|
||||
user_id=user_id,
|
||||
is_admin=invitation.isAdmin,
|
||||
is_billing_manager=invitation.isBillingManager,
|
||||
invited_by=invitation.invitedByUserId,
|
||||
)
|
||||
# Add user to org (idempotent — handles race condition from concurrent accepts)
|
||||
try:
|
||||
await org_db.add_org_member(
|
||||
org_id=invitation.orgId,
|
||||
user_id=user_id,
|
||||
is_admin=invitation.isAdmin,
|
||||
is_billing_manager=invitation.isBillingManager,
|
||||
invited_by=invitation.invitedByUserId,
|
||||
)
|
||||
except UniqueViolationError:
|
||||
# User is already a member — treat as success (idempotent)
|
||||
pass
|
||||
|
||||
# Add to specified workspaces
|
||||
for ws_id in invitation.workspaceIds:
|
||||
|
||||
@@ -20,6 +20,18 @@ class UpdateOrgRequest(BaseModel):
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
class UpdateOrgData(BaseModel):
|
||||
"""Structured data object for update_org DB function.
|
||||
|
||||
Only these fields can be updated — no arbitrary dict keys.
|
||||
"""
|
||||
|
||||
name: str | None = None
|
||||
slug: str | None = None
|
||||
description: str | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
class OrgResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
@@ -22,6 +22,7 @@ from .model import (
|
||||
OrgResponse,
|
||||
TransferOwnershipRequest,
|
||||
UpdateMemberRequest,
|
||||
UpdateOrgData,
|
||||
UpdateOrgRequest,
|
||||
)
|
||||
|
||||
@@ -98,12 +99,12 @@ async def update_org(
|
||||
_verify_org_path(ctx, org_id)
|
||||
return await org_db.update_org(
|
||||
org_id,
|
||||
{
|
||||
"name": request.name,
|
||||
"slug": request.slug,
|
||||
"description": request.description,
|
||||
"avatarUrl": request.avatar_url,
|
||||
},
|
||||
UpdateOrgData(
|
||||
name=request.name,
|
||||
slug=request.slug,
|
||||
description=request.description,
|
||||
avatar_url=request.avatar_url,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -137,7 +138,7 @@ async def convert_org(
|
||||
],
|
||||
) -> OrgResponse:
|
||||
_verify_org_path(ctx, org_id)
|
||||
return await org_db.convert_personal_org(org_id)
|
||||
return await org_db.convert_personal_org(org_id, ctx.user_id)
|
||||
|
||||
|
||||
# --- Members ---
|
||||
@@ -218,7 +219,7 @@ async def remove_member(
|
||||
],
|
||||
) -> None:
|
||||
_verify_org_path(ctx, org_id)
|
||||
await org_db.remove_org_member(org_id, uid)
|
||||
await org_db.remove_org_member(org_id, uid, requesting_user_id=ctx.user_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -273,6 +273,7 @@ class TestOrgDbUpdateOrg:
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_creates_rename_alias_on_slug_change(self):
|
||||
from backend.api.features.orgs.db import update_org
|
||||
from backend.api.features.orgs.model import UpdateOrgData
|
||||
|
||||
# After update, get_org is called which does find_unique again
|
||||
self.prisma.organization.find_unique = AsyncMock(
|
||||
@@ -283,7 +284,7 @@ class TestOrgDbUpdateOrg:
|
||||
]
|
||||
)
|
||||
|
||||
await update_org(ORG_ID, {"slug": "new-slug"})
|
||||
await update_org(ORG_ID, UpdateOrgData(slug="new-slug"))
|
||||
|
||||
# Alias should have been created for the old slug
|
||||
self.prisma.organizationalias.create.assert_called_once()
|
||||
@@ -295,21 +296,23 @@ class TestOrgDbUpdateOrg:
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_slug_collision_raises_value_error(self):
|
||||
from backend.api.features.orgs.db import update_org
|
||||
from backend.api.features.orgs.model import UpdateOrgData
|
||||
|
||||
other_org = _make_org(id="other-org", slug="new-slug")
|
||||
self.prisma.organization.find_unique = AsyncMock(return_value=other_org)
|
||||
|
||||
with pytest.raises(ValueError, match="already in use"):
|
||||
await update_org(ORG_ID, {"slug": "new-slug"})
|
||||
await update_org(ORG_ID, UpdateOrgData(slug="new-slug"))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_no_op_when_all_none(self):
|
||||
from backend.api.features.orgs.db import update_org
|
||||
from backend.api.features.orgs.model import UpdateOrgData
|
||||
|
||||
# When all values are None, update_org returns get_org result
|
||||
self.prisma.organization.find_unique = AsyncMock(return_value=self.old_org)
|
||||
|
||||
result = await update_org(ORG_ID, {"name": None, "slug": None})
|
||||
result = await update_org(ORG_ID, UpdateOrgData())
|
||||
|
||||
# No update call should have happened
|
||||
self.prisma.organization.update.assert_not_called()
|
||||
@@ -373,19 +376,41 @@ class TestOrgDbConvertOrg:
|
||||
async def test_convert_personal_to_team_org(self):
|
||||
from backend.api.features.orgs.db import convert_personal_org
|
||||
|
||||
personal_org = _make_org(isPersonal=True)
|
||||
converted_org = _make_org(isPersonal=False)
|
||||
personal_org = _make_org(isPersonal=True, slug="myorg")
|
||||
converted_org = _make_org(isPersonal=False, slug="myorg")
|
||||
new_personal_org = _make_org(
|
||||
id="new-personal-org", isPersonal=True, slug="myorg-personal-1"
|
||||
)
|
||||
|
||||
# find_unique calls: 1) convert check, 2) slug resolution (None = available),
|
||||
# 3) get_org at end
|
||||
self.prisma.organization.find_unique = AsyncMock(
|
||||
side_effect=[personal_org, converted_org]
|
||||
side_effect=[personal_org, None, converted_org]
|
||||
)
|
||||
# Alias check for slug resolution
|
||||
self.prisma.organizationalias.find_unique = AsyncMock(return_value=None)
|
||||
# New personal org creation chain
|
||||
self.prisma.organization.create = AsyncMock(return_value=new_personal_org)
|
||||
self.prisma.orgmember.create = AsyncMock()
|
||||
self.prisma.orgworkspace.create = AsyncMock(return_value=_make_workspace())
|
||||
self.prisma.orgworkspacemember.create = AsyncMock()
|
||||
self.prisma.organizationprofile.create = AsyncMock()
|
||||
self.prisma.organizationseatassignment.create = AsyncMock()
|
||||
self.prisma.orgbalance.create = AsyncMock()
|
||||
# User lookup for display name
|
||||
self.prisma.user.find_unique = AsyncMock(
|
||||
return_value=MagicMock(name="Test User")
|
||||
)
|
||||
|
||||
await convert_personal_org(ORG_ID)
|
||||
await convert_personal_org(ORG_ID, USER_ID)
|
||||
|
||||
self.prisma.organization.update.assert_called_once_with(
|
||||
where={"id": ORG_ID},
|
||||
data={"isPersonal": False},
|
||||
)
|
||||
# Should have flipped isPersonal on the old org
|
||||
update_calls = self.prisma.organization.update.call_args_list
|
||||
assert any(c[1]["data"].get("isPersonal") is False for c in update_calls)
|
||||
# Should have created a new personal org
|
||||
self.prisma.organization.create.assert_called_once()
|
||||
create_data = self.prisma.organization.create.call_args[1]["data"]
|
||||
assert create_data["isPersonal"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_convert_already_team_org_raises(self):
|
||||
@@ -396,7 +421,7 @@ class TestOrgDbConvertOrg:
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="already a team org"):
|
||||
await convert_personal_org(ORG_ID)
|
||||
await convert_personal_org(ORG_ID, USER_ID)
|
||||
|
||||
|
||||
class TestOrgDbMembers:
|
||||
@@ -454,6 +479,8 @@ class TestOrgDbMembers:
|
||||
|
||||
member = _make_member(userId=OTHER_USER_ID, isOwner=False)
|
||||
self.prisma.orgmember.find_unique = AsyncMock(return_value=member)
|
||||
# User has another org — removal allowed
|
||||
self.prisma.orgmember.count = AsyncMock(return_value=1)
|
||||
|
||||
ws1 = _make_workspace(id="ws-1")
|
||||
ws2 = _make_workspace(id="ws-2")
|
||||
@@ -461,7 +488,7 @@ class TestOrgDbMembers:
|
||||
self.prisma.orgworkspacemember.delete_many = AsyncMock()
|
||||
self.prisma.orgmember.delete = AsyncMock()
|
||||
|
||||
await remove_org_member(ORG_ID, OTHER_USER_ID)
|
||||
await remove_org_member(ORG_ID, OTHER_USER_ID, requesting_user_id=USER_ID)
|
||||
|
||||
# Should delete workspace memberships for each workspace
|
||||
assert self.prisma.orgworkspacemember.delete_many.call_count == 2
|
||||
@@ -480,7 +507,7 @@ class TestOrgDbMembers:
|
||||
self.prisma.orgmember.find_unique = AsyncMock(return_value=owner)
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot remove the org owner"):
|
||||
await remove_org_member(ORG_ID, USER_ID)
|
||||
await remove_org_member(ORG_ID, USER_ID, requesting_user_id="admin-user")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_nonexistent_member_raises_not_found(self):
|
||||
@@ -489,7 +516,7 @@ class TestOrgDbMembers:
|
||||
self.prisma.orgmember.find_unique = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
await remove_org_member(ORG_ID, "ghost-user")
|
||||
await remove_org_member(ORG_ID, "ghost-user", requesting_user_id=USER_ID)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_owner_role_raises_value_error(self):
|
||||
|
||||
@@ -70,11 +70,17 @@ async def get_workspace(
|
||||
|
||||
|
||||
async def update_workspace(ws_id: str, data: dict) -> WorkspaceResponse:
|
||||
"""Update workspace fields."""
|
||||
"""Update workspace fields. Guards the default workspace join policy."""
|
||||
update_data = {k: v for k, v in data.items() if v is not None}
|
||||
if not update_data:
|
||||
return await get_workspace(ws_id)
|
||||
|
||||
# Guard: default workspace joinPolicy cannot be changed
|
||||
if "joinPolicy" in update_data:
|
||||
ws = await prisma.orgworkspace.find_unique(where={"id": ws_id})
|
||||
if ws and ws.isDefault:
|
||||
raise ValueError("Cannot change the default workspace's join policy")
|
||||
|
||||
await prisma.orgworkspace.update(where={"id": ws_id}, data=update_data)
|
||||
return await get_workspace(ws_id)
|
||||
|
||||
@@ -193,7 +199,24 @@ async def update_workspace_member(
|
||||
|
||||
|
||||
async def remove_workspace_member(ws_id: str, user_id: str) -> None:
|
||||
"""Remove a member from a workspace."""
|
||||
"""Remove a member from a workspace.
|
||||
|
||||
Guards against removing the last admin — workspace would become unmanageable.
|
||||
"""
|
||||
# Check if this would remove the last admin
|
||||
member = await prisma.orgworkspacemember.find_unique(
|
||||
where={"workspaceId_userId": {"workspaceId": ws_id, "userId": user_id}}
|
||||
)
|
||||
if member and member.isAdmin:
|
||||
admin_count = await prisma.orgworkspacemember.count(
|
||||
where={"workspaceId": ws_id, "isAdmin": True, "status": "ACTIVE"}
|
||||
)
|
||||
if admin_count <= 1:
|
||||
raise ValueError(
|
||||
"Cannot remove the last workspace admin. "
|
||||
"Promote another member to admin first."
|
||||
)
|
||||
|
||||
await prisma.orgworkspacemember.delete(
|
||||
where={"workspaceId_userId": {"workspaceId": ws_id, "userId": user_id}}
|
||||
)
|
||||
|
||||
@@ -164,8 +164,12 @@ class TestSeatManagement:
|
||||
async def test_get_seat_info(self, mock_prisma):
|
||||
mock_prisma.organizationseatassignment.find_many = AsyncMock(
|
||||
return_value=[
|
||||
MagicMock(userId="u1", seatType="PAID", status="ACTIVE", createdAt="now"),
|
||||
MagicMock(userId="u2", seatType="FREE", status="INACTIVE", createdAt="now"),
|
||||
MagicMock(
|
||||
userId="u1", seatType="PAID", status="ACTIVE", createdAt="now"
|
||||
),
|
||||
MagicMock(
|
||||
userId="u2", seatType="FREE", status="INACTIVE", createdAt="now"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user