From f9eaae589e91befd7d0dab47a2232e16cdd381fd Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:37:57 -0700 Subject: [PATCH] fix(scim): retire directory resources with local deletions --- .../scim/agent_provisioning.py | 45 ++---- .../management_endpoints/scim/scim_v2.py | 134 ++++++++++-------- .../scim/test_agent_provisioning.py | 113 ++++++++++++--- .../scim/test_scim_key_deactivation.py | 1 + .../scim/test_scim_v2_endpoints.py | 29 ++-- 5 files changed, 201 insertions(+), 121 deletions(-) diff --git a/litellm/proxy/management_endpoints/scim/agent_provisioning.py b/litellm/proxy/management_endpoints/scim/agent_provisioning.py index 95ab3c11bd1..2aa62623a3c 100644 --- a/litellm/proxy/management_endpoints/scim/agent_provisioning.py +++ b/litellm/proxy/management_endpoints/scim/agent_provisioning.py @@ -1,13 +1,13 @@ import re from collections import deque -from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping +from collections.abc import AsyncGenerator, Mapping from contextlib import asynccontextmanager from dataclasses import dataclass from datetime import timedelta -from functools import reduce, wraps +from functools import reduce from itertools import chain from types import MappingProxyType -from typing import TYPE_CHECKING, Concatenate, Final, Literal, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Final, Literal from uuid import UUID, uuid4 from fastapi import HTTPException @@ -214,23 +214,6 @@ def group_members_after_patch(group: SCIMGroup, patch: SCIMPatchOp) -> SCIMGroup return reduce(_patch_group_operation, patch.Operations, group) -Parameters = ParamSpec("Parameters") -Result = TypeVar("Result") - - -def serialized_source( - operation: Callable[Concatenate["AgentProvisioningService", Parameters], Awaitable[Result]], -) -> Callable[Concatenate["AgentProvisioningService", Parameters], Awaitable[Result]]: - @wraps(operation) - async def execute( - service: "AgentProvisioningService", *args: Parameters.args, **kwargs: Parameters.kwargs - ) -> Result: - async with service.source_transaction(): - return await operation(service, *args, **kwargs) - - return execute - - def _identity_patch_children(value: object) -> tuple[object, ...] | Literal[True]: if isinstance(value, dict): fields: Final = TypeAdapter(dict[str, object]).validate_python(value) @@ -500,27 +483,24 @@ class AgentProvisioningService: await SourceHumanProvisioner.finish_update(result) return result.document - @serialized_source async def delete(self, kind: Literal["Users", "Groups"], resource_id: str) -> None: from litellm.proxy.management_endpoints.scim import scim_v2 where: Final[LiteLLM_SCIMResourceWhereUniqueInput] = {"id": resource_id} - async with self.client.tx() as tx: + admin_group: Final = await scim_v2.provisioning_group_admin_role() if kind == "Groups" else None + async with self.source_transaction() as tx: row: Final = await tx.litellm_scimresource.find_unique(where=where) if row is None or row.source_id != self.source.source_id or row.kind != kind: raise HTTPException(404, "SCIM resource not found in this provisioning source") if row.deleted: return - if row.local_id is not None: - try: - if kind == "Groups": - await scim_v2.delete_group(group_id=row.local_id) - elif user_document(row).agent_user is None: - await scim_v2.delete_user(user_id=row.local_id) - except HTTPException as exc: - if exc.status_code != 404: - raise - async with self.client.tx() as tx: + user_deletion: Final = ( + await scim_v2.write_scim_user_deletion(tx, self.client, row.local_id) + if row.local_id is not None and kind == "Users" and user_document(row).agent_user is None + else None + ) + if row.local_id is not None and kind == "Groups": + await scim_v2.write_scim_group_deletion(tx, row.local_id, admin_group) if kind == "Users": memberships: Final[LiteLLM_SCIMResourceWhereInput] = { "source_id": self.source.source_id, @@ -532,6 +512,7 @@ class AgentProvisioningService: await remove_group_member(tx, group, row.id) retired: Final[LiteLLM_SCIMResourceUpdateInput] = {"active": False, "deleted": True, "member_ids": []} await tx.litellm_scimresource.update(where=where, data=retired) + await scim_v2.finish_scim_user_deletion(user_deletion) async def create_group(self, group: SCIMGroup) -> SCIMGroup: from litellm.proxy.management_endpoints.scim import scim_v2 diff --git a/litellm/proxy/management_endpoints/scim/scim_v2.py b/litellm/proxy/management_endpoints/scim/scim_v2.py index e914e0c8d79..7503d954517 100644 --- a/litellm/proxy/management_endpoints/scim/scim_v2.py +++ b/litellm/proxy/management_endpoints/scim/scim_v2.py @@ -1082,7 +1082,7 @@ async def write_user_keys_blocked( return tuple(key.token for key in affected_keys) -async def _delete_rows_referencing_user(prisma_client: PrismaClient, *, user_id: str) -> None: +async def _delete_rows_referencing_user(prisma_client: "PrismaClient | _GroupWriteDatabase", *, user_id: str) -> None: """Drop rows whose foreign keys reference ``LiteLLM_UserTable.user_id``. Required before deleting the user row itself, otherwise Postgres rejects @@ -1887,6 +1887,74 @@ async def update_user( raise handle_exception_on_proxy(e) +@dataclass(frozen=True, slots=True) +class SCIMUserDeletion: + user_id: str + tokens: tuple[str, ...] + removals: tuple[TeamMemberRemoval, ...] + + +async def write_scim_user_deletion(tx: "Prisma", client: PrismaClient, user_id: str) -> SCIMUserDeletion | None: + database: Final = _GroupWriteDatabase(tx) + user: Final = await UserRepository(database).find_by_id(user_id, id_field="user_id") + if user is None: + return None + teams: Final = tuple( + [await TeamRepository(database).find_by_id(team_id, id_field="team_id") for team_id in sorted(user.teams or ())] + ) + for team in teams: + if team is not None and user_id in (team.members or ()): + await _table(TeamRepository(database)).update( + where={"team_id": team.team_id}, + data={"members": [member for member in team.members if member != user_id]}, + ) + removals: Final = tuple( + [ + await delete_team_member_in_transaction( + tx=tx, + data=TeamMemberDeleteRequest(team_id=team.team_id, user_id=user_id), + existing_team_row=team, + prisma_client=client, + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), + ) + for team in teams + if team is not None and any(member.user_id == user_id for member in team.members_with_roles or ()) + ] + ) + tokens: Final = await write_user_keys_blocked( + _table(VerificationTokenRepository(database)), user_id=user_id, blocked=True + ) + await _delete_rows_referencing_user(database, user_id=user_id) + await _table(UserRepository(database)).delete(where={"user_id": user_id}) + return SCIMUserDeletion(user_id=user_id, tokens=tokens, removals=removals) + + +async def finish_scim_user_deletion(result: SCIMUserDeletion | None) -> None: + if result is None: + return + for removal in result.removals: + await finish_team_member_removal(removal, UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)) + await finish_provisioned_user_update(result.user_id, result.tokens) + + +async def write_scim_group_deletion(tx: "Prisma", group_id: str, admin_group: str | None) -> tuple[str, ...]: + database: Final = _GroupWriteDatabase(tx) + await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, group_id) + team: Final = await TeamRepository(database).find_by_id(group_id, id_field="team_id") + if team is None: + return () + member_ids: Final = tuple(await _get_team_member_user_ids_from_team(team)) + for member_id in member_ids: + user: Final = await UserRepository(database).find_by_id(member_id, id_field="user_id") + if user is not None and group_id in (user.teams or ()): + await _table(UserRepository(database)).update( + where={"user_id": member_id}, data={"teams": [value for value in user.teams if value != group_id]} + ) + await _table(TeamRepository(database)).delete(where={"team_id": group_id}) + await write_scim_member_roles(database, member_ids, admin_group) + return member_ids + + @scim_router.delete( "/Users/{user_id}", status_code=204, @@ -1901,43 +1969,10 @@ async def delete_user( verbose_proxy_logger.debug("SCIM DELETE USER request for user_id=%s", user_id) try: prisma_client: Final = await _get_prisma_client_or_raise_exception() - existing_user: Final = await _check_user_exists(user_id) - - # Get teams user belongs to - found_teams: Final = tuple( - [ - await _table(TeamRepository(prisma_client)).find_unique(where={"team_id": team_id}) - for team_id in existing_user.teams or [] - ] - ) - teams: Final = tuple(team for team in found_teams if team) - - # Remove user from all teams - for team in teams: - current_members: Sequence[str] = team.members or [] - if user_id in current_members: - new_members = [m for m in current_members if m != user_id] - await _table(TeamRepository(prisma_client)).update( - where={"team_id": team.team_id}, data={"members": new_members} - ) - - team_row = LiteLLM_TeamTable.model_validate(team.model_dump()) - if any(member.user_id == user_id for member in team_row.members_with_roles or []): - await team_member_delete( - data=TeamMemberDeleteRequest(team_id=team_row.team_id, user_id=user_id), - user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), - ) - - await _set_user_keys_blocked(user_id=user_id, blocked=True) - - await _delete_rows_referencing_user(prisma_client, user_id=user_id) - - # Delete user - await _table(UserRepository(prisma_client)).delete(where={"user_id": user_id}) - - from litellm.proxy.proxy_server import user_api_key_cache - - await evict_and_broadcast(cache_keys=(user_id,), user_api_key_cache=user_api_key_cache) + await _check_user_exists(user_id) + async with prisma_client.tx() as tx: + result: Final = await write_scim_user_deletion(tx, prisma_client, user_id) + await finish_scim_user_deletion(result) return Response(status_code=204) except Exception as e: @@ -2874,25 +2909,10 @@ async def delete_group( verbose_proxy_logger.debug("SCIM DELETE GROUP request for group_id=%s", group_id) try: prisma_client: Final = await _get_prisma_client_or_raise_exception() - existing_team: Final = await _check_team_exists(group_id) - - member_ids: Final = await _get_team_member_user_ids_from_team(existing_team) - - # For each member, remove this team from their teams list - for member_id in member_ids: - user = await _table(UserRepository(prisma_client)).find_unique(where={"user_id": member_id}) - if user: - current_teams = user.teams or [] - if group_id in current_teams: - new_teams = [t for t in current_teams if t != group_id] - await _table(UserRepository(prisma_client)).update( - where={"user_id": member_id}, data={"teams": new_teams} - ) - - await _recompute_scim_member_roles(prisma_client, member_ids) - - # Delete team - await _table(TeamRepository(prisma_client)).delete(where={"team_id": group_id}) + await _check_team_exists(group_id) + admin_group: Final = await _get_scim_admin_group() + async with prisma_client.tx() as tx: + await write_scim_group_deletion(tx, group_id, admin_group) return Response(status_code=204) diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_agent_provisioning.py b/tests/test_litellm/proxy/management_endpoints/scim/test_agent_provisioning.py index 40eea1d7618..bd23720d9ca 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_agent_provisioning.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_agent_provisioning.py @@ -383,32 +383,26 @@ async def test_concurrent_native_profile_update_returns_conflict() -> None: @pytest.mark.asyncio @pytest.mark.parametrize("kind", ["Users", "Groups"]) -async def test_scoped_delete_propagates_human_and_team_deprovisioning( - kind: str, - monkeypatch: pytest.MonkeyPatch, -) -> None: - from unittest.mock import AsyncMock - - from litellm.proxy.management_endpoints.scim import scim_v2 +async def test_scoped_delete_propagates_human_and_team_deprovisioning(kind: str) -> None: + from litellm.proxy._types import LiteLLM_TeamTable, LiteLLM_UserTable service, tx, native = provisioning_fixture() - document: Final = SCIMUser(schemas=[], userName="human@example.com").model_dump(mode="json") - row: Final = native.model_copy(update={"kind": kind, "document": document}) + row: Final = native.model_copy(update={"kind": kind, "document": SCIMUser(schemas=[], userName="human").model_dump()}) tx.litellm_scimresource.find_unique.return_value = row - delete_user: Final = AsyncMock() - delete_group: Final = AsyncMock() - monkeypatch.setattr(scim_v2, "delete_user", delete_user) - monkeypatch.setattr(scim_v2, "delete_group", delete_group) + tx.query_raw = AsyncMock(return_value=[]) + tx.litellm_usertable.find_unique = AsyncMock(return_value=LiteLLM_UserTable(user_id=row.local_id, teams=[])) + tx.litellm_teamtable.find_unique = AsyncMock(return_value=LiteLLM_TeamTable(team_id=row.local_id, members_with_roles=[])) + tx.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + tx.litellm_usertable.delete = AsyncMock() + tx.litellm_teamtable.delete = AsyncMock() + tx.litellm_invitationlink.delete_many = AsyncMock() + tx.litellm_organizationmembership.delete_many = AsyncMock() + tx.litellm_teammembership.delete_many = AsyncMock() await service.delete(kind, row.id) - if kind == "Users": - delete_user.assert_awaited_once_with(user_id=row.local_id) - delete_group.assert_not_awaited() - else: - delete_group.assert_awaited_once_with(group_id=row.local_id) - delete_user.assert_not_awaited() + deletion: Final = tx.litellm_usertable.delete if kind == "Users" else tx.litellm_teamtable.delete + deletion.assert_awaited_once_with(where={"user_id" if kind == "Users" else "team_id": row.local_id}) tx.litellm_scimresource.update.assert_awaited_once_with( - where={"id": row.id}, - data={"active": False, "deleted": True, "member_ids": []}, + where={"id": row.id}, data={"active": False, "deleted": True, "member_ids": []}, ) @@ -423,7 +417,7 @@ async def test_failed_human_deletion_remains_retryable(monkeypatch: pytest.Monke service, tx, native = provisioning_fixture() row: Final = native.model_copy(update={"document": SCIMUser(schemas=[], userName="human@example.com").model_dump()}) tx.litellm_scimresource.find_unique.return_value = row - monkeypatch.setattr(scim_v2, "delete_user", AsyncMock(side_effect=HTTPException(503, "unavailable"))) + tx.litellm_usertable.find_unique = AsyncMock(side_effect=HTTPException(503, "unavailable")) with pytest.raises(HTTPException) as failure: await service.delete("Users", row.id) assert failure.value.status_code == 503 @@ -1244,3 +1238,78 @@ async def test_group_addition_reuses_member_writes_and_defers_existing_audit_and assert any(call.kwargs.get("cache_keys") == (human.local_id,) for call in invalidate.await_args_list) audit.assert_called_once() assert audit.call_args.kwargs["existing_user_ids"] == frozenset([human.local_id]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["Users", "Groups"]) +async def test_delete_retry_retires_directory_when_local_record_is_already_missing(kind, monkeypatch): + from litellm.proxy._types import ProxyException + from litellm.proxy.management_endpoints.scim import scim_v2 + + service, tx, native = provisioning_fixture() + row = native.model_copy(update={"kind": kind, "document": SCIMUser(schemas=[], userName="human").model_dump()}) + tx.litellm_scimresource.find_unique.return_value = row + tx.litellm_usertable.find_unique = AsyncMock(return_value=None) + tx.litellm_teamtable.find_unique = AsyncMock(return_value=None) + missing = ProxyException(message="not found", type="not_found", param=None, code=404) + monkeypatch.setattr(scim_v2, "delete_user", AsyncMock(side_effect=missing)) + monkeypatch.setattr(scim_v2, "delete_group", AsyncMock(side_effect=missing)) + await service.delete(kind, row.id) + assert tx.litellm_scimresource.update.call_args.kwargs["data"] == { + "active": False, "deleted": True, "member_ids": [], + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["Users", "Groups"]) +async def test_directory_retirement_failure_rolls_back_local_deletion(kind, monkeypatch): + from contextlib import asynccontextmanager + from litellm.proxy._types import LiteLLM_TeamTable, LiteLLM_UserTable + from litellm.proxy.management_endpoints.scim import scim_v2 + + service, tx, native = provisioning_fixture() + row = native.model_copy(update={"kind": kind, "document": SCIMUser(schemas=[], userName="human").model_dump()}) + state = {"local_exists": True, "directory_active": True} + transaction_writes = [] + + @asynccontextmanager + async def transaction(**kwargs): + before = dict(state) + writes = set() + transaction_writes.append(writes) + try: + yield tx + except BaseException: + state.update({key: before[key] for key in writes}) + raise + finally: + transaction_writes.pop() + + async def delete_local(**kwargs): + if "where" in kwargs: + transaction_writes[-1].add("local_exists") + state["local_exists"] = False + + async def retire(**kwargs): + transaction_writes[-1].add("directory_active") + state["directory_active"] = False + raise RuntimeError("directory write failed") + + service.client.tx = transaction + tx.query_raw = AsyncMock(return_value=[]) + tx.litellm_scimresource.find_unique.return_value = row + tx.litellm_scimresource.update = AsyncMock(side_effect=retire) + tx.litellm_usertable.find_unique = AsyncMock(return_value=LiteLLM_UserTable(user_id=row.local_id, teams=[])) + tx.litellm_teamtable.find_unique = AsyncMock(return_value=LiteLLM_TeamTable(team_id=row.local_id, members_with_roles=[], max_budget=99)) + tx.litellm_usertable.delete = AsyncMock(side_effect=delete_local) + tx.litellm_teamtable.delete = AsyncMock(side_effect=delete_local) + tx.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + tx.litellm_invitationlink.delete_many = AsyncMock() + tx.litellm_organizationmembership.delete_many = AsyncMock() + tx.litellm_teammembership.delete_many = AsyncMock() + monkeypatch.setattr(scim_v2, "delete_user", delete_local) + monkeypatch.setattr(scim_v2, "delete_group", delete_local) + with pytest.raises(RuntimeError, match="directory write failed"): + await service.delete(kind, row.id) + assert state == {"local_exists": True, "directory_active": True} + assert tx.litellm_teamtable.find_unique.return_value.max_budget == 99 diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_key_deactivation.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_key_deactivation.py index d35a676a28b..128e0a301df 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_key_deactivation.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_key_deactivation.py @@ -38,6 +38,7 @@ def _build_prisma_with_keys(user_keys, mock_user=None, updated_user=None): mock_client = MagicMock() mock_db = MagicMock() mock_client.db = mock_db + mock_client.tx.return_value.__aenter__.return_value = mock_db if mock_user is not None: mock_db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user) if updated_user is not None: diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py index dbcf622bbb1..c50ae9ce1cf 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py @@ -3032,17 +3032,19 @@ async def test_delete_group_recomputes_roles_for_members(mocker): deleting the admin group demotes everyone who was only admin through it.""" from litellm.proxy._types import Member - existing_team = mocker.MagicMock() + existing_team = LiteLLM_TeamTable(team_id="test-team-123") existing_team.members_with_roles = [ Member(user_id="user1", role="user"), Member(user_id="user2", role="user"), ] - member = mocker.MagicMock() + member = LiteLLM_UserTable(user_id="user1") member.teams = ["test-team-123"] mock_prisma_client = mocker.MagicMock() mock_prisma_client.db = mocker.MagicMock() + mock_prisma_client.tx.return_value.__aenter__.return_value = mock_prisma_client.db + mock_prisma_client.db.query_raw = AsyncMock(return_value=[]) mock_prisma_client.db.litellm_teamtable = mocker.MagicMock() mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=existing_team) mock_prisma_client.db.litellm_teamtable.delete = AsyncMock() @@ -3056,7 +3058,7 @@ async def test_delete_group_recomputes_roles_for_members(mocker): AsyncMock(return_value=mock_prisma_client), ) recompute_mock = mocker.patch( - "litellm.proxy.management_endpoints.scim.scim_v2._recompute_scim_member_roles", + "litellm.proxy.management_endpoints.scim.scim_v2.write_scim_member_roles", AsyncMock(), ) @@ -3516,6 +3518,8 @@ async def test_apply_group_patch_updates_does_not_write_legacy_members(mocker): def _mock_prisma_for_delete_user(mocker, team): mock_prisma_client = mocker.MagicMock() mock_prisma_client.db = mocker.MagicMock() + mock_prisma_client.tx.return_value.__aenter__.return_value = mock_prisma_client.db + mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) mock_prisma_client.db.litellm_teamtable = mocker.MagicMock() mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=team) mock_prisma_client.db.litellm_teamtable.update = AsyncMock() @@ -3525,6 +3529,8 @@ def _mock_prisma_for_delete_user(mocker, team): def _patch_delete_user_dependencies(mocker, mock_prisma_client, existing_user): + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=existing_user) + mocker.patch("litellm.proxy.management_endpoints.scim.scim_v2.finish_scim_user_deletion", AsyncMock()) mocker.patch( "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception", AsyncMock(return_value=mock_prisma_client), @@ -3551,7 +3557,7 @@ async def test_delete_user_prunes_members_with_roles(mocker): to the now-deleted user.""" user_id = "scim-del-user" - existing_user = mocker.MagicMock() + existing_user = LiteLLM_UserTable(user_id=user_id) existing_user.teams = ["team-1"] team = LiteLLM_TeamTable( @@ -3563,7 +3569,7 @@ async def test_delete_user_prunes_members_with_roles(mocker): mock_prisma_client = _mock_prisma_for_delete_user(mocker, team) _patch_delete_user_dependencies(mocker, mock_prisma_client, existing_user) team_member_delete_mock = mocker.patch( - "litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete", + "litellm.proxy.management_endpoints.scim.scim_v2.delete_team_member_in_transaction", AsyncMock(), ) @@ -3585,7 +3591,7 @@ async def test_delete_user_surfaces_prune_failure_and_keeps_user(mocker): so the IdP retries).""" user_id = "scim-del-user" - existing_user = mocker.MagicMock() + existing_user = LiteLLM_UserTable(user_id=user_id) existing_user.teams = ["team-1"] team = LiteLLM_TeamTable( @@ -3597,7 +3603,7 @@ async def test_delete_user_surfaces_prune_failure_and_keeps_user(mocker): mock_prisma_client = _mock_prisma_for_delete_user(mocker, team) _patch_delete_user_dependencies(mocker, mock_prisma_client, existing_user) mocker.patch( - "litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete", + "litellm.proxy.management_endpoints.scim.scim_v2.delete_team_member_in_transaction", AsyncMock(side_effect=Exception("database connection lost")), ) @@ -3614,7 +3620,7 @@ async def test_delete_user_skips_teams_where_not_a_member(mocker): user, so a stale legacy membership can't block the delete.""" user_id = "scim-del-user" - existing_user = mocker.MagicMock() + existing_user = LiteLLM_UserTable(user_id=user_id) existing_user.teams = ["team-1"] team = LiteLLM_TeamTable( @@ -3626,7 +3632,7 @@ async def test_delete_user_skips_teams_where_not_a_member(mocker): mock_prisma_client = _mock_prisma_for_delete_user(mocker, team) _patch_delete_user_dependencies(mocker, mock_prisma_client, existing_user) team_member_delete_mock = mocker.patch( - "litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete", + "litellm.proxy.management_endpoints.scim.scim_v2.delete_team_member_in_transaction", AsyncMock(), ) @@ -6033,6 +6039,7 @@ def _shadow_tenant_prisma( prisma_client = mocker.MagicMock() prisma_client.db = mocker.MagicMock() + prisma_client.tx.return_value.__aenter__.return_value = prisma_client.db prisma_client.db.litellm_usertable = mocker.MagicMock() prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=find_unique) prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=identity_rows) @@ -6076,10 +6083,12 @@ async def test_merge_placeholder_hands_the_group_to_the_shadowed_account(mocker, ) team_member_delete_mock = ( mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the endpoint - "litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete", AsyncMock() + "litellm.proxy.management_endpoints.scim.scim_v2.delete_team_member_in_transaction", AsyncMock() ) ) + mocker.patch("litellm.proxy.management_endpoints.scim.scim_v2.finish_scim_user_deletion", AsyncMock()) + with pytest.raises(HTTPException) as before: await _push_shadow_member(shadowed_tenant) assert before.value.status_code == 400