Merge commit 'refs/pull/17420/head' of github.com:BerriAI/litellm into litellm_hakhundov_contribution

This commit is contained in:
yuneng-jiang 2026-01-09 14:32:31 -08:00
commit 572f3f6d70
4 changed files with 134 additions and 403 deletions

View file

@ -40,7 +40,9 @@ class ScimTransformations:
user_updated_at = user.updated_at.isoformat() if user.updated_at else None
emails = []
if user.user_email:
# Only add email if it's a valid email address (contains @)
# user_email can be a UUID when users are created without an email
if user.user_email and "@" in user.user_email:
emails.append(SCIMUserEmail(value=user.user_email, primary=True))
return SCIMUser(
@ -126,7 +128,7 @@ class ScimTransformations:
for member in team.members_with_roles or []:
if isinstance(member, dict):
member = Member(**member)
scim_members.append(
SCIMMember(
value=ScimTransformations._get_scim_member_value(member),
@ -161,7 +163,7 @@ class ScimTransformations:
elif hasattr(member, "user_id"):
return member.user_id or ScimTransformations.DEFAULT_SCIM_MEMBER_VALUE
return ScimTransformations.DEFAULT_SCIM_MEMBER_VALUE
@staticmethod
def _get_scim_member_display(member: Member) -> str:
"""

View file

@ -208,20 +208,35 @@ def _build_scim_metadata(
async def _extract_group_member_ids(group: SCIMGroup) -> GroupMemberExtractionResult:
"""
Extract member IDs from SCIMGroup, creating users that don't exist.
Extract member IDs from SCIMGroup, validating that all users exist.
Per SCIM 2.0 protocol, groups should only reference existing users.
Users must be created via POST /Users before being added to groups.
Returns:
GroupMemberExtractionResult with existing members, created users, and all member IDs
GroupMemberExtractionResult with existing members and all member IDs
Raises:
HTTPException: If any member user does not exist (400 Bad Request)
"""
prisma_client = await _get_prisma_client_or_raise_exception()
existing_member_ids = []
created_users = []
created_users = [] # Always empty - users must exist before group membership
all_member_ids = []
if group.members:
for member in group.members:
user_id = member.value
# Validate user_id is not empty
if not user_id or not user_id.strip():
raise HTTPException(
status_code=400,
detail={
"error": "Invalid member: user ID cannot be empty."
},
)
# Check if user exists
user = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_id}
@ -231,16 +246,17 @@ async def _extract_group_member_ids(group: SCIMGroup) -> GroupMemberExtractionRe
existing_member_ids.append(user_id)
all_member_ids.append(user_id)
else:
# Create the user if they don't exist using our helper
created_user = await _create_user_if_not_exists(
user_id=user_id, created_via="scim_group_membership"
# User doesn't exist - reject per SCIM 2.0 protocol
# This prevents security issues where users not assigned to app
# get provisioned via group membership
raise HTTPException(
status_code=400,
detail={
"error": f"User with ID '{user_id}' does not exist. "
"Please create the user first via POST /Users before adding to group."
},
)
if created_user:
created_users.append(created_user)
all_member_ids.append(user_id)
# If creation failed, user is skipped (logged in helper)
return GroupMemberExtractionResult(
existing_member_ids=existing_member_ids,
created_users=created_users,
@ -312,7 +328,7 @@ async def _create_user_if_not_exists(
new_user_request = NewUserRequest(
user_id=user_id,
user_email=user_id, # We don't have email from group membership
user_email=None, # We don't have email from group membership
user_alias=None,
teams=[], # Teams will be added separately
metadata={"created_via": created_via},
@ -1039,7 +1055,7 @@ async def create_group(
detail={"error": f"Group already exists with ID: {team_id}"},
)
# Extract and process group members (creating users that don't exist)
# Extract and validate group members (all users must exist)
member_result = await _extract_group_member_ids(group)
members_with_roles = [
Member(user_id=member_id, role="user")
@ -1087,7 +1103,7 @@ async def update_group(
prisma_client = await _get_prisma_client_or_raise_exception()
existing_team = await _check_team_exists(group_id)
# Extract and process group members (creating users that don't exist)
# Extract and validate group members (all users must exist)
member_result = await _extract_group_member_ids(group)
verbose_proxy_logger.debug(
f"SCIM PUT GROUP all_member_ids: {member_result.all_member_ids}"
@ -1204,24 +1220,33 @@ async def _process_group_patch_operations(
elif path.startswith("members"):
# Handle member operations
member_values = _extract_group_values(value)
# Create users that don't exist and get all valid member IDs
# Validate all users exist - per SCIM 2.0, users must exist before group membership
valid_members = []
for member_id in member_values:
# Validate member_id is not empty
if not member_id or not member_id.strip():
raise HTTPException(
status_code=400,
detail={
"error": "Invalid member: user ID cannot be empty."
},
)
user = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": member_id}
)
if user:
valid_members.append(member_id)
else:
# Create the user if they don't exist using our helper
created_user = await _create_user_if_not_exists(
user_id=member_id, created_via="scim_group_patch"
# User doesn't exist - reject per SCIM 2.0 protocol
raise HTTPException(
status_code=400,
detail={
"error": f"User with ID '{member_id}' does not exist. "
"Please create the user first via POST /Users before adding to group."
},
)
if created_user:
valid_members.append(member_id)
# If creation failed, user is skipped (logged in helper)
if op_type == "replace":
final_members = set(valid_members)
elif op_type == "add":

View file

@ -1,14 +1,8 @@
import asyncio
import json
import os
import sys
from litellm._uuid import uuid
from typing import Optional, cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
sys.path.insert(
0, os.path.abspath("../../../")
@ -19,10 +13,7 @@ from litellm.proxy.management_endpoints.scim.scim_transformations import (
ScimTransformations,
)
from litellm.types.proxy.management_endpoints.scim_v2 import (
SCIMGroup,
SCIMPatchOp,
SCIMPatchOperation,
SCIMUser,
)
@ -229,6 +220,63 @@ class TestScimTransformations:
result = ScimTransformations._get_scim_member_value(member_without_email)
assert result == member_without_email.user_id
@pytest.mark.asyncio
async def test_transform_user_with_uuid_as_email(self, mock_prisma_client):
"""
Test that users with UUID in user_email don't cause validation errors.
This tests the defensive fix that validates email contains '@' before creating SCIMUserEmail.
"""
mock_client, mock_find_unique = mock_prisma_client
user_with_uuid_email = LiteLLM_UserTable(
user_id="21df4e37-2f38-4f2e-a21b-c33cb939ff5b",
user_email="21df4e37-2f38-4f2e-a21b-c33cb939ff5b", # UUID as email (bug scenario)
user_alias=None,
teams=[],
created_at=None,
updated_at=None,
metadata={},
)
mock_find_unique.return_value = None
with patch("litellm.proxy.proxy_server.prisma_client", mock_client):
scim_user = await ScimTransformations.transform_litellm_user_to_scim_user(
user_with_uuid_email
)
assert scim_user.id == user_with_uuid_email.user_id
assert scim_user.emails is None or len(scim_user.emails) == 0
@pytest.mark.asyncio
async def test_transform_user_with_none_email(self, mock_prisma_client):
"""
Test that users with user_email=None are transformed correctly.
This tests the root cause fix.
"""
mock_client, mock_find_unique = mock_prisma_client
user_with_none_email = LiteLLM_UserTable(
user_id="user-from-group",
user_email=None,
user_alias=None,
teams=[],
created_at=None,
updated_at=None,
metadata={},
)
mock_find_unique.return_value = None
with patch("litellm.proxy.proxy_server.prisma_client", mock_client):
scim_user = await ScimTransformations.transform_litellm_user_to_scim_user(
user_with_none_email
)
assert scim_user.id == user_with_none_email.user_id
assert scim_user.emails is None or len(scim_user.emails) == 0
class TestSCIMPatchOperations:
"""Test SCIM PATCH operation validation and case-insensitive handling"""

View file

@ -16,7 +16,6 @@ from litellm.proxy.management_endpoints.scim.scim_v2 import (
update_user,
)
from litellm.types.proxy.management_endpoints.scim_v2 import (
SCIMFeature,
SCIMGroup,
SCIMMember,
SCIMPatchOp,
@ -429,7 +428,7 @@ async def test_update_user_success(mocker):
"litellm.proxy.management_endpoints.scim.scim_v2._handle_team_membership_changes",
AsyncMock()
)
mock_transform = mocker.patch(
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
AsyncMock(return_value=response_scim_user)
)
@ -525,7 +524,7 @@ async def test_patch_user_success(mocker):
"litellm.proxy.management_endpoints.scim.scim_v2._handle_team_membership_changes",
AsyncMock()
)
mock_transform = mocker.patch(
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
AsyncMock(return_value=response_scim_user)
)
@ -661,7 +660,7 @@ async def test_update_group_metadata_serialization_issue(mocker):
)
# Call the function that had the bug
result = await update_group(group_id=group_id, group=scim_group)
await update_group(group_id=group_id, group=scim_group)
# Verify the team update was called
mock_prisma_client.db.litellm_teamtable.update.assert_called_once()
@ -697,7 +696,6 @@ async def test_team_membership_management(mocker):
from litellm.proxy.management_endpoints.scim.scim_v2 import (
_get_team_member_user_ids_from_team,
_handle_group_membership_changes,
patch_team_membership,
)
# Mock team with members_with_roles as source of truth
@ -773,7 +771,6 @@ async def test_update_group_e2e(mocker):
from litellm.proxy.management_endpoints.scim.scim_transformations import (
ScimTransformations,
)
from litellm.proxy.utils import safe_dumps
# Setup test data
group_id = "test-team-123"
@ -916,10 +913,11 @@ async def test_update_group_e2e(mocker):
@pytest.mark.asyncio
async def test_create_group_with_nonexistent_users_creates_users(mocker):
async def test_create_group_with_nonexistent_users_rejects(mocker):
"""
Test that creating a group with non-existent users creates those users.
This tests the scenario: Group Push ['new user', existing users...]
Test that creating a group with non-existent users is rejected.
Per SCIM 2.0 protocol, users must exist before being added to groups.
This prevents security issues where users not assigned to app get provisioned via group membership.
"""
# Test data
group_id = "test-group-123"
@ -935,7 +933,7 @@ async def test_create_group_with_nonexistent_users_creates_users(mocker):
)
#########################################################
# We expect new-user-1 and new-user-2 to be created
# We expect the request to be rejected with 400 error
#########################################################
# Mock prisma client
@ -964,95 +962,21 @@ async def test_create_group_with_nonexistent_users_creates_users(mocker):
AsyncMock(return_value=mock_prisma_client)
)
# Mock new_user function to track user creation
mock_new_user = mocker.patch(
"litellm.proxy.management_endpoints.internal_user_endpoints.new_user",
AsyncMock()
)
# Execute the create_group function - should raise HTTPException
with pytest.raises(HTTPException) as exc_info:
await create_group(group=scim_group)
# Mock created users return values
def mock_new_user_side_effect(data):
from litellm.proxy._types import NewUserResponse
return NewUserResponse(
key="sk-test-key-" + data.user_id, # Required field from GenerateKeyResponse
user_id=data.user_id,
user_email=data.user_email,
metadata=data.metadata,
teams=data.teams,
user_role=data.user_role
)
mock_new_user.side_effect = mock_new_user_side_effect
# Mock new_team function
mock_created_team = mocker.MagicMock()
mock_created_team.team_id = group_id
mock_created_team.team_alias = "Test Group"
mock_new_team = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.new_team",
AsyncMock(return_value=mock_created_team)
)
# Mock SCIM transformation
expected_scim_response = SCIMGroup(
schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"],
id=group_id,
displayName="Test Group",
members=[
SCIMMember(value="existing-user", display="existing-user"),
SCIMMember(value="new-user-1", display="new-user-1"),
SCIMMember(value="new-user-2", display="new-user-2")
]
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_team_to_scim_group",
AsyncMock(return_value=expected_scim_response)
)
# Execute the create_group function
result = await create_group(group=scim_group)
#########################################################
# Assert that new-user-1 and new-user-2 were created
#########################################################
# Verify that new_user was called exactly twice (for new-user-1 and new-user-2)
assert mock_new_user.call_count == 2
# Check the user creation calls
created_user_ids = set()
for call in mock_new_user.call_args_list:
user_request = call.kwargs["data"]
created_user_ids.add(user_request.user_id)
assert user_request.metadata["created_via"] == "scim_group_membership"
assert user_request.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY
assert user_request.auto_create_key is False
assert user_request.teams == [] # Teams added separately
assert created_user_ids == {"new-user-1", "new-user-2"}
# Verify team creation was called with all members (existing + created)
mock_new_team.assert_called_once()
team_request = mock_new_team.call_args.kwargs["data"]
assert team_request.team_id == group_id
assert team_request.team_alias == "Test Group"
# Verify all members are in the team (existing + newly created)
member_user_ids = {member.user_id for member in team_request.members_with_roles}
assert member_user_ids == {"existing-user", "new-user-1", "new-user-2"}
# Verify response
assert result.id == group_id
assert result.displayName == "Test Group"
assert len(result.members) == 3
# Verify it's a 400 Bad Request
assert exc_info.value.status_code == 400
assert "does not exist" in str(exc_info.value.detail)
assert "new-user-1" in str(exc_info.value.detail) or "new-user-2" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_update_group_with_nonexistent_users_creates_users(mocker):
async def test_update_group_with_nonexistent_users_rejects(mocker):
"""
Test that updating a group with non-existent users creates those users.
This tests the scenario where a group is updated with members that don't exist in user table.
Test that updating a group with non-existent users is rejected.
Per SCIM 2.0 protocol, users must exist before being added to groups.
"""
# Test data
group_id = "existing-group-456"
@ -1115,279 +1039,11 @@ async def test_update_group_with_nonexistent_users_creates_users(mocker):
AsyncMock(return_value=mock_existing_team)
)
# Mock new_user function to track user creation
mock_new_user = mocker.patch(
"litellm.proxy.management_endpoints.internal_user_endpoints.new_user",
AsyncMock()
)
# Execute the update_group function - should raise HTTPException
with pytest.raises(HTTPException) as exc_info:
await update_group(group_id=group_id, group=scim_group_update)
# Mock created users return values
def mock_new_user_side_effect(data):
from litellm.proxy._types import NewUserResponse
return NewUserResponse(
key="sk-test-key-" + data.user_id, # Required field from GenerateKeyResponse
user_id=data.user_id,
user_email=data.user_email,
metadata=data.metadata,
teams=data.teams,
user_role=data.user_role
)
mock_new_user.side_effect = mock_new_user_side_effect
# Mock group membership changes
mock_handle_group_membership_changes = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._handle_group_membership_changes",
AsyncMock()
)
# Mock SCIM transformation
expected_scim_response = SCIMGroup(
schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"],
id=group_id,
displayName="Updated Group Name",
members=[
SCIMMember(value="existing-user", display="existing-user"),
SCIMMember(value="new-user-3", display="new-user-3"),
SCIMMember(value="new-user-4", display="new-user-4")
]
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_team_to_scim_group",
AsyncMock(return_value=expected_scim_response)
)
# Execute the update_group function
result = await update_group(group_id=group_id, group=scim_group_update)
# Verify that new_user was called exactly twice (for new-user-3 and new-user-4)
assert mock_new_user.call_count == 2
# Check the user creation calls
created_user_ids = set()
for call in mock_new_user.call_args_list:
user_request = call.kwargs["data"]
created_user_ids.add(user_request.user_id)
assert user_request.metadata["created_via"] == "scim_group_membership"
assert user_request.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY
assert user_request.auto_create_key is False
assert user_request.teams == [] # Teams added separately
assert created_user_ids == {"new-user-3", "new-user-4"}
# Verify team update was called
mock_prisma_client.db.litellm_teamtable.update.assert_called_once()
update_call = mock_prisma_client.db.litellm_teamtable.update.call_args
assert update_call[1]["where"]["team_id"] == group_id
assert update_call[1]["data"]["team_alias"] == "Updated Group Name"
# Verify group membership changes were handled with all members (existing + created)
mock_handle_group_membership_changes.assert_called_once()
membership_call = mock_handle_group_membership_changes.call_args
assert membership_call[1]["group_id"] == group_id
assert membership_call[1]["final_members"] == {"existing-user", "new-user-3", "new-user-4"}
# Verify response
assert result.id == group_id
assert result.displayName == "Updated Group Name"
assert len(result.members) == 3
@pytest.mark.asyncio
async def test_patch_group_refreshes_team_data_to_prevent_race_conditions(mocker):
"""
Test that patch_group refreshes team data from database:
1. After applying updates (to get latest state before membership changes)
2. After membership changes (to get final state for response)
This prevents race conditions when multiple PATCH requests come in simultaneously.
"""
from litellm.proxy._types import LiteLLM_TeamTable, Member
group_id = "test-group-123"
# Mock existing team
existing_team = LiteLLM_TeamTable(
team_id=group_id,
team_alias="Original Team",
members=["user1", "user2"],
members_with_roles=[
Member(user_id="user1", role="user"),
Member(user_id="user2", role="user")
],
metadata={}
)
# Mock team after applying updates (simulating what _apply_group_patch_updates returns)
updated_team_after_patch = LiteLLM_TeamTable(
team_id=group_id,
team_alias="Updated Team",
members=["user1", "user2", "user3"], # user3 added in patch
members_with_roles=[
Member(user_id="user1", role="user"),
Member(user_id="user2", role="user"),
Member(user_id="user3", role="user")
],
metadata={}
)
# Mock refreshed team (simulating concurrent update - user4 was added by another request)
refreshed_team_before_membership = LiteLLM_TeamTable(
team_id=group_id,
team_alias="Updated Team",
members=["user1", "user2", "user3", "user4"], # user4 added concurrently
members_with_roles=[
Member(user_id="user1", role="user"),
Member(user_id="user2", role="user"),
Member(user_id="user3", role="user"),
Member(user_id="user4", role="user") # Concurrent addition
],
metadata={}
)
# Mock final refreshed team after membership changes
final_refreshed_team = LiteLLM_TeamTable(
team_id=group_id,
team_alias="Updated Team",
members=["user1", "user2", "user3", "user4", "user5"], # user5 added via membership change
members_with_roles=[
Member(user_id="user1", role="user"),
Member(user_id="user2", role="user"),
Member(user_id="user3", role="user"),
Member(user_id="user4", role="user"),
Member(user_id="user5", role="user") # Added via membership change
],
metadata={}
)
# Mock SCIM patch operations - adding user3 and user5
patch_ops = SCIMPatchOp(
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
Operations=[
SCIMPatchOperation(op="add", path="members", value=[{"value": "user3"}, {"value": "user5"}])
]
)
# Mock prisma client
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
# Mock user lookups (all users exist)
mock_user = mocker.MagicMock()
mock_user.user_id = "test-user"
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user)
# Mock dependencies
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
AsyncMock(return_value=mock_prisma_client)
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._check_team_exists",
AsyncMock(return_value=existing_team)
)
# Mock _process_group_patch_operations
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._process_group_patch_operations",
AsyncMock(return_value=(
{"team_alias": "Updated Team"},
{"user1", "user2", "user3", "user5"} # final_members after processing patch
))
)
# Mock _apply_group_patch_updates to return updated_team_after_patch
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._apply_group_patch_updates",
AsyncMock(return_value=updated_team_after_patch)
)
# Mock find_unique calls for refresh operations
# First refresh (after applying updates) - returns team with concurrent update (user4)
# Second refresh (after membership changes) - returns final team (with user5)
# Need to add model_dump() method to mock Prisma model objects
mock_refreshed_team_before_membership = mocker.MagicMock()
# model_dump() should return a dict that can be used to construct LiteLLM_TeamTable
mock_refreshed_team_before_membership.model_dump = mocker.Mock(return_value={
"team_id": refreshed_team_before_membership.team_id,
"team_alias": refreshed_team_before_membership.team_alias,
"members": refreshed_team_before_membership.members,
"members_with_roles": refreshed_team_before_membership.members_with_roles,
"metadata": refreshed_team_before_membership.metadata,
})
mock_final_refreshed_team = mocker.MagicMock()
mock_final_refreshed_team.model_dump = mocker.Mock(return_value={
"team_id": final_refreshed_team.team_id,
"team_alias": final_refreshed_team.team_alias,
"members": final_refreshed_team.members,
"members_with_roles": final_refreshed_team.members_with_roles,
"metadata": final_refreshed_team.metadata,
})
refresh_calls = [mock_refreshed_team_before_membership, mock_final_refreshed_team]
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(side_effect=refresh_calls)
# Mock _handle_group_membership_changes
mock_handle_group_membership_changes = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._handle_group_membership_changes",
AsyncMock()
)
# Mock SCIM transformation
expected_scim_response = SCIMGroup(
schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"],
id=group_id,
displayName="Updated Team",
members=[
SCIMMember(value="user1", display="user1"),
SCIMMember(value="user2", display="user2"),
SCIMMember(value="user3", display="user3"),
SCIMMember(value="user4", display="user4"),
SCIMMember(value="user5", display="user5")
]
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_team_to_scim_group",
AsyncMock(return_value=expected_scim_response)
)
# Execute patch_group
result = await patch_group(group_id=group_id, patch_ops=patch_ops)
# Verify that find_unique was called twice (for the two refreshes)
assert mock_prisma_client.db.litellm_teamtable.find_unique.call_count == 2
# Verify first refresh was called after applying updates
first_refresh_call = mock_prisma_client.db.litellm_teamtable.find_unique.call_args_list[0]
assert first_refresh_call[1]["where"]["team_id"] == group_id
# Verify that _handle_group_membership_changes was called with refreshed members
# It should use refreshed_current_members (user1, user2, user3, user4) not updated_team_after_patch members
mock_handle_group_membership_changes.assert_called_once()
membership_call = mock_handle_group_membership_changes.call_args
# _handle_group_membership_changes is called with positional arguments: (group_id, current_members, final_members)
assert membership_call[0][0] == group_id
# current_members should be from refreshed_team_before_membership (includes user4 from concurrent update)
assert membership_call[0][1] == {"user1", "user2", "user3", "user4"}
# final_members should be from patch operations (user1, user2, user3, user5)
assert membership_call[0][2] == {"user1", "user2", "user3", "user5"}
# Verify second refresh was called after membership changes
second_refresh_call = mock_prisma_client.db.litellm_teamtable.find_unique.call_args_list[1]
assert second_refresh_call[1]["where"]["team_id"] == group_id
# Verify SCIM transformation was called with final_refreshed_team (not updated_team_after_patch)
from litellm.proxy.management_endpoints.scim.scim_v2 import ScimTransformations
ScimTransformations.transform_litellm_team_to_scim_group.assert_called_once()
transform_call = ScimTransformations.transform_litellm_team_to_scim_group.call_args[0][0]
# Verify it was called with final_refreshed_team (has user5)
assert isinstance(transform_call, LiteLLM_TeamTable)
member_ids = {member.user_id for member in transform_call.members_with_roles}
assert member_ids == {"user1", "user2", "user3", "user4", "user5"}
# Verify response
assert result.id == group_id
assert result.displayName == "Updated Team"
# Verify it's a 400 Bad Request
assert exc_info.value.status_code == 400
assert "does not exist" in str(exc_info.value.detail)
assert "new-user-3" in str(exc_info.value.detail) or "new-user-4" in str(exc_info.value.detail)