Added LiteLLM to the stack

This commit is contained in:
2025-08-18 09:40:50 +00:00
parent 0648c1968c
commit d220b04e32
2682 changed files with 533609 additions and 1 deletions

View File

@@ -0,0 +1,145 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from litellm.proxy._types import LiteLLM_UserTable
from litellm.proxy.management_endpoints.scim.scim_v2 import patch_user
from litellm.types.proxy.management_endpoints.scim_v2 import (
SCIMPatchOp,
SCIMPatchOperation,
SCIMUser,
SCIMUserEmail,
SCIMUserName,
)
@pytest.mark.asyncio
async def test_patch_user_updates_fields():
mock_user = LiteLLM_UserTable(
user_id="user-1",
user_email="test@example.com",
user_alias="Old",
teams=[],
metadata={},
)
# Create a proper copy to track updates
updated_user = LiteLLM_UserTable(
user_id="user-1",
user_email="test@example.com",
user_alias="New Name",
teams=[],
metadata={"scim_active": False, "scim_metadata": {}},
)
async def mock_update(*, where, data):
# Return the updated user object
return updated_user
mock_client = MagicMock()
mock_db = MagicMock()
mock_client.db = mock_db
mock_db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user)
mock_db.litellm_usertable.update = AsyncMock(side_effect=mock_update)
mock_db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
# Mock the transformation function to return a proper SCIMUser
mock_scim_user = SCIMUser(
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
id="user-1",
userName="user-1",
displayName="New Name",
name=SCIMUserName(familyName="Name", givenName="New"),
emails=[SCIMUserEmail(value="test@example.com")],
active=False,
)
patch_ops = SCIMPatchOp(
Operations=[
SCIMPatchOperation(op="replace", path="displayName", value="New Name"),
SCIMPatchOperation(op="replace", path="active", value="False"),
]
)
with patch("litellm.proxy.proxy_server.prisma_client", mock_client), \
patch("litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
AsyncMock(return_value=mock_scim_user)):
result = await patch_user(user_id="user-1", patch_ops=patch_ops)
mock_db.litellm_usertable.update.assert_called_once()
assert result.displayName == "New Name"
assert result.active is False
@pytest.mark.asyncio
async def test_patch_user_manages_group_memberships():
mock_user = LiteLLM_UserTable(
user_id="user-2",
user_email="test@example.com",
user_alias="Old",
teams=["old-team"],
metadata={},
)
# Create updated user with final teams
updated_user = LiteLLM_UserTable(
user_id="user-2",
user_email="test@example.com",
user_alias="Old",
teams=["new-team"],
metadata={"scim_metadata": {}},
)
async def mock_update(*, where, data):
# Return the updated user
return updated_user
mock_client = MagicMock()
mock_db = MagicMock()
mock_client.db = mock_db
mock_db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user)
mock_db.litellm_usertable.update = AsyncMock(side_effect=mock_update)
mock_db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
# Mock the transformation function to return a proper SCIMUser
mock_scim_user = SCIMUser(
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
id="user-2",
userName="user-2",
displayName="Old",
name=SCIMUserName(familyName="Family", givenName="Old"),
emails=[SCIMUserEmail(value="test@example.com")],
active=True,
)
async def mock_add(data, user_api_key_dict):
# Mock team member add
pass
async def mock_delete(data, user_api_key_dict):
# Mock team member delete
pass
patch_ops = SCIMPatchOp(
Operations=[
SCIMPatchOperation(op="add", path="groups", value=[{"value": "new-team"}]),
SCIMPatchOperation(op="remove", path="groups", value=[{"value": "old-team"}]),
]
)
with patch("litellm.proxy.proxy_server.prisma_client", mock_client), \
patch("litellm.proxy.management_endpoints.scim.scim_v2.team_member_add",
AsyncMock(side_effect=mock_add)) as mock_add_fn, \
patch("litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete",
AsyncMock(side_effect=mock_delete)) as mock_del_fn, \
patch("litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
AsyncMock(return_value=mock_scim_user)):
result = await patch_user(user_id="user-2", patch_ops=patch_ops)
assert mock_add_fn.called
assert mock_del_fn.called
# Check that the database update was called with the correct teams
call_args = mock_db.litellm_usertable.update.call_args
assert "new-team" in call_args[1]["data"]["teams"]
assert result == mock_scim_user

View File

@@ -0,0 +1,285 @@
import asyncio
import json
import os
import sys
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("../../../")
) # Adds the parent directory to the system path
from litellm.proxy._types import LiteLLM_TeamTable, LiteLLM_UserTable, Member
from litellm.proxy.management_endpoints.scim.scim_transformations import (
ScimTransformations,
)
from litellm.types.proxy.management_endpoints.scim_v2 import (
SCIMGroup,
SCIMPatchOp,
SCIMPatchOperation,
SCIMUser,
)
# Mock data
@pytest.fixture
def mock_user():
return LiteLLM_UserTable(
user_id="user-123",
user_email="test@example.com",
user_alias="Test User",
teams=["team-1", "team-2"],
created_at=None,
updated_at=None,
metadata={},
)
@pytest.fixture
def mock_user_with_scim_metadata():
return LiteLLM_UserTable(
user_id="user-456",
user_email="test2@example.com",
user_alias="Test User 2",
teams=["team-1"],
created_at=None,
updated_at=None,
metadata={"scim_metadata": {"givenName": "Test", "familyName": "User"}},
)
@pytest.fixture
def mock_user_minimal():
return LiteLLM_UserTable(
user_id="user-789",
user_email=None,
user_alias=None,
teams=[],
created_at=None,
updated_at=None,
metadata={},
)
@pytest.fixture
def mock_team():
return LiteLLM_TeamTable(
team_id="team-1",
team_alias="Test Team",
members_with_roles=[
Member(user_id="user-123", user_email="test@example.com", role="admin"),
Member(user_id="user-456", user_email="test2@example.com", role="user"),
],
created_at=None,
updated_at=None,
)
@pytest.fixture
def mock_team_minimal():
return LiteLLM_TeamTable(
team_id="team-2",
team_alias="Test Team 2",
members_with_roles=[Member(user_id="user-789", user_email=None, role="user")],
created_at=None,
updated_at=None,
)
@pytest.fixture
def mock_prisma_client():
mock_client = MagicMock()
mock_db = MagicMock()
mock_client.db = mock_db
mock_find_unique = AsyncMock()
mock_db.litellm_teamtable.find_unique = mock_find_unique
return mock_client, mock_find_unique
class TestScimTransformations:
@pytest.mark.asyncio
async def test_transform_litellm_user_to_scim_user(
self, mock_user, mock_prisma_client
):
mock_client, mock_find_unique = mock_prisma_client
# Mock the team lookup
team1 = LiteLLM_TeamTable(
team_id="team-1", team_alias="Team One", members_with_roles=[]
)
team2 = LiteLLM_TeamTable(
team_id="team-2", team_alias="Team Two", members_with_roles=[]
)
mock_find_unique.side_effect = [team1, team2]
with patch("litellm.proxy.proxy_server.prisma_client", mock_client):
scim_user = await ScimTransformations.transform_litellm_user_to_scim_user(
mock_user
)
assert scim_user.id == mock_user.user_id
assert scim_user.userName == mock_user.user_email
assert scim_user.displayName == mock_user.user_email
assert scim_user.name.familyName == mock_user.user_alias
assert scim_user.name.givenName == mock_user.user_alias
assert len(scim_user.emails) == 1
assert scim_user.emails[0].value == mock_user.user_email
assert len(scim_user.groups) == 2
assert scim_user.groups[0].value == "team-1"
assert scim_user.groups[0].display == "Team One"
assert scim_user.groups[1].value == "team-2"
assert scim_user.groups[1].display == "Team Two"
@pytest.mark.asyncio
async def test_transform_user_with_scim_metadata(
self, mock_user_with_scim_metadata, mock_prisma_client
):
mock_client, mock_find_unique = mock_prisma_client
# Mock the team lookup
team1 = LiteLLM_TeamTable(
team_id="team-1", team_alias="Team One", members_with_roles=[]
)
mock_find_unique.return_value = team1
with patch("litellm.proxy.proxy_server.prisma_client", mock_client):
scim_user = await ScimTransformations.transform_litellm_user_to_scim_user(
mock_user_with_scim_metadata
)
assert scim_user.name.givenName == "Test"
assert scim_user.name.familyName == "User"
@pytest.mark.asyncio
async def test_transform_litellm_team_to_scim_group(
self, mock_team, mock_prisma_client
):
mock_client, _ = mock_prisma_client
with patch("litellm.proxy.proxy_server.prisma_client", mock_client):
scim_group = await ScimTransformations.transform_litellm_team_to_scim_group(
mock_team
)
assert scim_group.id == mock_team.team_id
assert scim_group.displayName == mock_team.team_alias
assert len(scim_group.members) == 2
assert scim_group.members[0].value == "test@example.com"
assert scim_group.members[0].display == "test@example.com"
assert scim_group.members[1].value == "test2@example.com"
assert scim_group.members[1].display == "test2@example.com"
def test_get_scim_user_name(self, mock_user, mock_user_minimal):
# User with email
result = ScimTransformations._get_scim_user_name(mock_user)
assert result == mock_user.user_email
# User without email
result = ScimTransformations._get_scim_user_name(mock_user_minimal)
assert result == ScimTransformations.DEFAULT_SCIM_DISPLAY_NAME
def test_get_scim_family_name(
self, mock_user, mock_user_with_scim_metadata, mock_user_minimal
):
# User with alias
result = ScimTransformations._get_scim_family_name(mock_user)
assert result == mock_user.user_alias
# User with SCIM metadata
result = ScimTransformations._get_scim_family_name(mock_user_with_scim_metadata)
assert result == "User"
# User without alias or metadata
result = ScimTransformations._get_scim_family_name(mock_user_minimal)
assert result == ScimTransformations.DEFAULT_SCIM_FAMILY_NAME
def test_get_scim_given_name(
self, mock_user, mock_user_with_scim_metadata, mock_user_minimal
):
# User with alias
result = ScimTransformations._get_scim_given_name(mock_user)
assert result == mock_user.user_alias
# User with SCIM metadata
result = ScimTransformations._get_scim_given_name(mock_user_with_scim_metadata)
assert result == "Test"
# User without alias or metadata
result = ScimTransformations._get_scim_given_name(mock_user_minimal)
assert result == ScimTransformations.DEFAULT_SCIM_NAME
def test_get_scim_member_value(self):
# Member with email
member_with_email = Member(
user_id="user-123", user_email="test@example.com", role="admin"
)
result = ScimTransformations._get_scim_member_value(member_with_email)
assert result == member_with_email.user_email
# Member without email
member_without_email = Member(user_id="user-456", user_email=None, role="user")
result = ScimTransformations._get_scim_member_value(member_without_email)
assert result == ScimTransformations.DEFAULT_SCIM_MEMBER_VALUE
class TestSCIMPatchOperations:
"""Test SCIM PATCH operation validation and case-insensitive handling"""
def test_scim_patch_operation_lowercase(self):
"""Test that lowercase operations are accepted"""
op = SCIMPatchOperation(op="add", path="members", value=[{"value": "user123"}])
assert op.op == "add"
op = SCIMPatchOperation(op="remove", path='members[value eq "user123"]')
assert op.op == "remove"
op = SCIMPatchOperation(op="replace", path="displayName", value="New Name")
assert op.op == "replace"
def test_scim_patch_operation_uppercase(self):
"""Test that uppercase operations are normalized to lowercase"""
op = SCIMPatchOperation(op="ADD", path="members", value=[{"value": "user123"}])
assert op.op == "add"
op = SCIMPatchOperation(op="REMOVE", path='members[value eq "user123"]')
assert op.op == "remove"
op = SCIMPatchOperation(op="REPLACE", path="displayName", value="New Name")
assert op.op == "replace"
def test_scim_patch_operation_mixed_case(self):
"""Test that mixed case operations are normalized to lowercase"""
op = SCIMPatchOperation(op="Add", path="members", value=[{"value": "user123"}])
assert op.op == "add"
op = SCIMPatchOperation(op="Remove", path='members[value eq "user123"]')
assert op.op == "remove"
op = SCIMPatchOperation(op="Replace", path="displayName", value="New Name")
assert op.op == "replace"
def test_scim_patch_operation_with_optional_fields(self):
"""Test SCIMPatchOperation with and without optional fields"""
# Operation with all fields
op_full = SCIMPatchOperation(
op="Add",
path="members",
value=[{"value": "user123", "display": "User 123"}],
)
assert op_full.op == "add"
assert op_full.path == "members"
assert op_full.value == [{"value": "user123", "display": "User 123"}]
# Operation with minimal fields (only op is required)
op_minimal = SCIMPatchOperation(op="Remove")
assert op_minimal.op == "remove"
assert op_minimal.path is None
assert op_minimal.value is None

View File

@@ -0,0 +1,681 @@
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from litellm.proxy._types import LitellmUserRoles, NewUserRequest, ProxyException
from litellm.proxy.management_endpoints.scim.scim_v2 import (
UserProvisionerHelpers,
_handle_team_membership_changes,
create_user,
get_service_provider_config,
patch_user,
update_user,
)
from litellm.types.proxy.management_endpoints.scim_v2 import (
SCIMFeature,
SCIMPatchOp,
SCIMPatchOperation,
SCIMServiceProviderConfig,
SCIMUser,
SCIMUserEmail,
SCIMUserGroup,
SCIMUserName,
)
@pytest.mark.asyncio
async def test_create_user_existing_user_conflict(mocker):
"""If a user already exists, create_user should raise ScimUserAlreadyExists"""
scim_user = SCIMUser(
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
userName="existing-user",
name=SCIMUserName(familyName="User", givenName="Existing"),
emails=[SCIMUserEmail(value="existing@example.com")],
)
# Create a properly structured mock for the prisma client
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value={"user_id": "existing-user"})
# Mock the _get_prisma_client_or_raise_exception to return our mock
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
AsyncMock(return_value=mock_prisma_client),
)
mocked_new_user = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.new_user",
AsyncMock(),
)
with pytest.raises(HTTPException) as exc_info:
await create_user(user=scim_user)
# Check that it's an HTTPException with status 409
assert exc_info.value.status_code == 409
assert "existing-user" in str(exc_info.value.detail)
mocked_new_user.assert_not_called()
@pytest.mark.asyncio
async def test_create_user_defaults_to_viewer(mocker, monkeypatch):
"""If no role provided, new user should default to viewer"""
scim_user = SCIMUser(
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
userName="new-user",
name=SCIMUserName(familyName="User", givenName="New"),
emails=[SCIMUserEmail(value="new@example.com")],
)
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
monkeypatch.setattr(
"litellm.default_internal_user_params", None, raising=False
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
AsyncMock(return_value=mock_prisma_client),
)
new_user_mock = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.new_user",
AsyncMock(return_value=NewUserRequest(user_id="new-user")),
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
AsyncMock(return_value=scim_user),
)
await create_user(user=scim_user)
called_args = new_user_mock.call_args.kwargs["data"]
assert called_args.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY
@pytest.mark.asyncio
async def test_create_user_uses_default_internal_user_params_role(mocker, monkeypatch):
"""If role is set in default_internal_user_params, new user should use that role"""
scim_user = SCIMUser(
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
userName="new-user",
name=SCIMUserName(familyName="User", givenName="New"),
emails=[SCIMUserEmail(value="new@example.com")],
)
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
# Set default_internal_user_params with a specific role
default_params = {
"user_role": LitellmUserRoles.PROXY_ADMIN,
}
monkeypatch.setattr(
"litellm.default_internal_user_params", default_params, raising=False
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
AsyncMock(return_value=mock_prisma_client),
)
new_user_mock = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.new_user",
AsyncMock(return_value=NewUserRequest(user_id="new-user")),
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
AsyncMock(return_value=scim_user),
)
await create_user(user=scim_user)
called_args = new_user_mock.call_args.kwargs["data"]
assert called_args.user_role == LitellmUserRoles.PROXY_ADMIN
@pytest.mark.asyncio
async def test_handle_existing_user_by_email_no_email(mocker):
"""Should return None when new_user_request has no email"""
mock_prisma_client = mocker.MagicMock()
new_user_request = NewUserRequest(
user_id="test-user",
user_email=None, # No email provided
user_alias="Test User",
teams=[],
metadata={},
auto_create_key=False,
)
result = await UserProvisionerHelpers.handle_existing_user_by_email(
prisma_client=mock_prisma_client,
new_user_request=new_user_request
)
assert result is None
@pytest.mark.asyncio
async def test_handle_existing_user_by_email_no_existing_user(mocker):
"""Should return None when no existing user is found with the email"""
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
new_user_request = NewUserRequest(
user_id="test-user",
user_email="test@example.com",
user_alias="Test User",
teams=["team1"],
metadata={"key": "value"},
auto_create_key=False,
)
result = await UserProvisionerHelpers.handle_existing_user_by_email(
prisma_client=mock_prisma_client,
new_user_request=new_user_request
)
assert result is None
mock_prisma_client.db.litellm_usertable.find_first.assert_called_once_with(
where={"user_email": "test@example.com"}
)
@pytest.mark.asyncio
async def test_handle_existing_user_by_email_existing_user_updated(mocker):
"""Should update existing user and return SCIMUser when user with email exists"""
# Mock existing user - create a proper mock object with attributes
existing_user = mocker.MagicMock()
existing_user.user_id = "old-user-id"
existing_user.user_email = "test@example.com"
existing_user.user_alias = "Old Name"
existing_user.teams = ["old-team"]
existing_user.metadata = {"old": "data"}
# Mock updated user
updated_user = {
"user_id": "new-user-id",
"user_email": "test@example.com",
"user_alias": "New Name",
"teams": ["new-team"],
"metadata": '{"new": "data"}'
}
# Mock SCIM user to be returned
mock_scim_user = SCIMUser(
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
id="new-user-id",
userName="new-user-id",
name=SCIMUserName(familyName="Name", givenName="New"),
emails=[SCIMUserEmail(value="test@example.com")],
)
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=existing_user)
mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=updated_user)
# Mock the transformation function
mock_transform = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
AsyncMock(return_value=mock_scim_user)
)
new_user_request = NewUserRequest(
user_id="new-user-id",
user_email="test@example.com",
user_alias="New Name",
teams=["new-team"],
metadata={"new": "data"},
auto_create_key=False,
)
result = await UserProvisionerHelpers.handle_existing_user_by_email(
prisma_client=mock_prisma_client,
new_user_request=new_user_request
)
# Verify the result
assert result == mock_scim_user
# Verify database operations
mock_prisma_client.db.litellm_usertable.find_first.assert_called_once_with(
where={"user_email": "test@example.com"}
)
mock_prisma_client.db.litellm_usertable.update.assert_called_once_with(
where={"user_id": "old-user-id"},
data={
"user_id": "new-user-id",
"user_email": "test@example.com",
"user_alias": "New Name",
"teams": ["new-team"],
"metadata": '{"new": "data"}',
},
)
# Verify transformation was called
mock_transform.assert_called_once_with(updated_user)
@pytest.mark.asyncio
async def test_handle_team_membership_changes_no_changes(mocker):
"""Should not call patch_team_membership when existing teams equal new teams"""
mock_patch_team_membership = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.patch_team_membership",
AsyncMock()
)
# Same teams - no changes
await _handle_team_membership_changes(
user_id="test-user",
existing_teams=["team1", "team2"],
new_teams=["team1", "team2"]
)
# Should not be called since no changes
mock_patch_team_membership.assert_not_called()
@pytest.mark.asyncio
async def test_handle_team_membership_changes_add_teams(mocker):
"""Should call patch_team_membership with teams to add"""
mock_patch_team_membership = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.patch_team_membership",
AsyncMock()
)
# Adding teams
await _handle_team_membership_changes(
user_id="test-user",
existing_teams=["team1"],
new_teams=["team1", "team2", "team3"]
)
# Verify the call was made once
mock_patch_team_membership.assert_called_once()
# Check the arguments more flexibly to handle order variations
call_args = mock_patch_team_membership.call_args
assert call_args[1]["user_id"] == "test-user"
assert set(call_args[1]["teams_ids_to_add_user_to"]) == {"team2", "team3"}
assert call_args[1]["teams_ids_to_remove_user_from"] == []
@pytest.mark.asyncio
async def test_handle_team_membership_changes_remove_teams(mocker):
"""Should call patch_team_membership with teams to remove"""
mock_patch_team_membership = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.patch_team_membership",
AsyncMock()
)
# Removing teams
await _handle_team_membership_changes(
user_id="test-user",
existing_teams=["team1", "team2", "team3"],
new_teams=["team1"]
)
# Verify the call was made once
mock_patch_team_membership.assert_called_once()
# Check the arguments more flexibly to handle order variations
call_args = mock_patch_team_membership.call_args
assert call_args[1]["user_id"] == "test-user"
assert call_args[1]["teams_ids_to_add_user_to"] == []
assert set(call_args[1]["teams_ids_to_remove_user_from"]) == {"team2", "team3"}
@pytest.mark.asyncio
async def test_handle_team_membership_changes_add_and_remove(mocker):
"""Should call patch_team_membership with both teams to add and remove"""
mock_patch_team_membership = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.patch_team_membership",
AsyncMock()
)
# Both adding and removing teams
await _handle_team_membership_changes(
user_id="test-user",
existing_teams=["team1", "team2"],
new_teams=["team2", "team3"]
)
# Verify the call was made once
mock_patch_team_membership.assert_called_once()
# Check the arguments - team1 should be removed, team3 should be added, team2 stays
call_args = mock_patch_team_membership.call_args
assert call_args[1]["user_id"] == "test-user"
assert call_args[1]["teams_ids_to_add_user_to"] == ["team3"]
assert call_args[1]["teams_ids_to_remove_user_from"] == ["team1"]
@pytest.mark.asyncio
async def test_update_user_success(mocker):
"""Should successfully update user with PUT request"""
# Mock existing user
existing_user = mocker.MagicMock()
existing_user.teams = ["old-team"]
# Mock updated user
updated_user = {
"user_id": "test-user",
"user_email": "updated@example.com",
"user_alias": "Updated User",
"teams": ["new-team"],
"metadata": '{"scim_metadata": {"givenName": "Updated", "familyName": "User"}}'
}
# Mock SCIM user for request
scim_user = SCIMUser(
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
userName="test-user",
name=SCIMUserName(familyName="User", givenName="Updated"),
emails=[SCIMUserEmail(value="updated@example.com")],
groups=[SCIMUserGroup(value="new-team")]
)
# Mock SCIM user for response
response_scim_user = SCIMUser(
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
id="test-user",
userName="test-user",
name=SCIMUserName(familyName="User", givenName="Updated"),
emails=[SCIMUserEmail(value="updated@example.com")],
)
# Mock prisma client
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=updated_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_user_exists",
AsyncMock(return_value=existing_user)
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._handle_team_membership_changes",
AsyncMock()
)
mock_transform = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
AsyncMock(return_value=response_scim_user)
)
# Call update_user
result = await update_user(user_id="test-user", user=scim_user)
# Verify result
assert result == response_scim_user
# Verify database update was called with correct data
mock_prisma_client.db.litellm_usertable.update.assert_called_once()
call_args = mock_prisma_client.db.litellm_usertable.update.call_args
assert call_args[1]["where"] == {"user_id": "test-user"}
assert call_args[1]["data"]["user_email"] == "updated@example.com"
assert call_args[1]["data"]["teams"] == ["new-team"]
@pytest.mark.asyncio
async def test_update_user_not_found(mocker):
"""Should raise 404 when user doesn't exist"""
scim_user = SCIMUser(
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
userName="nonexistent-user",
name=SCIMUserName(familyName="User", givenName="Test"),
emails=[SCIMUserEmail(value="test@example.com")],
)
# Mock dependencies to raise HTTPException for user not found
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
AsyncMock(return_value=mocker.MagicMock())
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._check_user_exists",
AsyncMock(side_effect=HTTPException(status_code=404, detail={"error": "User not found"}))
)
# Should raise ProxyException (which wraps the HTTPException)
with pytest.raises(ProxyException):
await update_user(user_id="nonexistent-user", user=scim_user)
@pytest.mark.asyncio
async def test_patch_user_success(mocker):
"""Should successfully patch user with PATCH request"""
# Mock existing user
existing_user = mocker.MagicMock()
existing_user.teams = ["team1"]
existing_user.metadata = {}
# Mock updated user
updated_user = {
"user_id": "test-user",
"user_alias": "Patched User",
"teams": ["team1", "team2"],
"metadata": '{"scim_metadata": {}}'
}
# Mock patch operations
patch_ops = SCIMPatchOp(
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
Operations=[
SCIMPatchOperation(op="replace", path="displayName", value="Patched User"),
SCIMPatchOperation(op="add", path="groups", value=[{"value": "team2"}])
]
)
# Mock response SCIM user
response_scim_user = SCIMUser(
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
id="test-user",
userName="test-user",
name=SCIMUserName(familyName="User", givenName="Patched"),
)
# Mock prisma client
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=updated_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_user_exists",
AsyncMock(return_value=existing_user)
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._handle_team_membership_changes",
AsyncMock()
)
mock_transform = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
AsyncMock(return_value=response_scim_user)
)
# Call patch_user
result = await patch_user(user_id="test-user", patch_ops=patch_ops)
# Verify result
assert result == response_scim_user
# Verify database update was called
mock_prisma_client.db.litellm_usertable.update.assert_called_once()
call_args = mock_prisma_client.db.litellm_usertable.update.call_args
assert call_args[1]["where"] == {"user_id": "test-user"}
@pytest.mark.asyncio
async def test_patch_user_not_found(mocker):
"""Should raise 404 when user doesn't exist for patch"""
patch_ops = SCIMPatchOp(
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
Operations=[
SCIMPatchOperation(op="replace", path="displayName", value="New Name")
]
)
# Mock dependencies to raise HTTPException for user not found
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
AsyncMock(return_value=mocker.MagicMock())
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._check_user_exists",
AsyncMock(side_effect=HTTPException(status_code=404, detail={"error": "User not found"}))
)
# Should raise ProxyException (which wraps the HTTPException)
with pytest.raises(ProxyException):
await patch_user(user_id="nonexistent-user", patch_ops=patch_ops)
@pytest.mark.asyncio
async def test_get_service_provider_config(mocker):
"""Test the get_service_provider_config endpoint"""
# Mock the Request object
mock_request = mocker.MagicMock()
mock_request.url = "https://example.com/scim/v2/ServiceProviderConfig"
# Call the endpoint
result = await get_service_provider_config(mock_request)
# Verify it returns the correct response
assert isinstance(result, SCIMServiceProviderConfig)
assert result.schemas == ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"]
assert result.patch.supported is True
assert result.bulk.supported is False
assert result.meta is not None
assert result.meta["resourceType"] == "ServiceProviderConfig"
@pytest.mark.asyncio
async def test_update_group_metadata_serialization_issue(mocker):
"""
Test that update_group properly serializes metadata to avoid Prisma DataError.
This test reproduces the issue where metadata was passed as a dict instead of
a JSON string, causing: "Invalid argument type. `metadata` should be of any
of the following types: `JsonNullValueInput`, `Json`"
"""
from litellm.proxy.management_endpoints.scim.scim_v2 import update_group
from litellm.types.proxy.management_endpoints.scim_v2 import SCIMGroup, SCIMMember
# Create test data
group_id = "test-group-id"
scim_group = SCIMGroup(
schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"],
id=group_id,
displayName="Test Group",
members=[SCIMMember(value="user1", display="User One")]
)
# Mock existing team with metadata
mock_existing_team = mocker.MagicMock()
mock_existing_team.team_id = group_id
mock_existing_team.team_alias = "Old Group Name"
mock_existing_team.members = ["user1"]
mock_existing_team.metadata = {"existing_key": "existing_value"}
mock_existing_team.created_at = None
mock_existing_team.updated_at = None
# Mock updated team response
mock_updated_team = mocker.MagicMock()
mock_updated_team.team_id = group_id
mock_updated_team.team_alias = "Test Group"
mock_updated_team.members = ["user1"]
mock_updated_team.created_at = None
mock_updated_team.updated_at = None
# Create a properly structured mock for the 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 team operations
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team)
mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=mock_updated_team)
# Mock user operations
mock_user = mocker.MagicMock()
mock_user.user_id = "user1"
mock_user.user_email = "user1@example.com" # Add proper string value for user_email
mock_user.teams = [group_id]
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user)
mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=mock_user)
# Mock the _get_prisma_client_or_raise_exception to return our mock
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
AsyncMock(return_value=mock_prisma_client),
)
# Mock the transformation function
mock_scim_group_response = SCIMGroup(
schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"],
id=group_id,
displayName="Test Group",
members=[SCIMMember(value="user1", display="User One")]
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_team_to_scim_group",
AsyncMock(return_value=mock_scim_group_response),
)
# Call the function that had the bug
result = 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()
# Get the call arguments to verify metadata serialization
call_args = mock_prisma_client.db.litellm_teamtable.update.call_args
update_data = call_args[1]["data"]
# Verify that metadata is properly serialized as a string, not a dict
# This is the critical check that would have caught the original bug
assert "metadata" in update_data
metadata = update_data["metadata"]
# The fix should ensure metadata is serialized as a JSON string
assert isinstance(metadata, str), f"metadata should be a JSON string, but got {type(metadata)}"
# Verify we can parse it back to verify it contains the expected data
import json
parsed_metadata = json.loads(metadata)
assert "existing_key" in parsed_metadata
assert "scim_data" in parsed_metadata
assert parsed_metadata["existing_key"] == "existing_value"