mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-25 01:02:15 +00:00
The member add, delete and role-change audit rows were awaited on the request path, so a slow audit sink held the response, and the roster was serialized before checking whether audit logging is enabled at all. Membership audit work is now scheduled after the transaction commits and skipped outright when auditing is off. member_update read the roster outside the team advisory lock and wrote it back, so a concurrent add or delete could be lost. It now takes the lock, rereads the roster, and builds the before and after snapshots from that read. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
190 lines
6.6 KiB
Python
190 lines
6.6 KiB
Python
import types
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from starlette.requests import Request
|
|
|
|
import litellm.proxy.proxy_server as proxy_server
|
|
import litellm.proxy.management_endpoints.team_endpoints as team_endpoints
|
|
from litellm.proxy._types import (
|
|
LiteLLM_TeamTable,
|
|
LitellmUserRoles,
|
|
Member,
|
|
TeamMemberUpdateRequest,
|
|
UserAPIKeyAuth,
|
|
)
|
|
from litellm.proxy.management_endpoints.team_endpoints import (
|
|
TEAM_ADVISORY_LOCK_SQL,
|
|
team_member_update,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ateam_member_update_admin_requires_premium(monkeypatch):
|
|
# Arrange: patch prisma_client and premium_user
|
|
monkeypatch.setattr(proxy_server, "prisma_client", object())
|
|
monkeypatch.setattr(proxy_server, "premium_user", False)
|
|
|
|
# Create a request body that tries to set role=admin
|
|
data = TeamMemberUpdateRequest(
|
|
team_id="team-1234",
|
|
user_id="user-1",
|
|
user_email=None,
|
|
role="admin",
|
|
max_budget_in_team=None,
|
|
)
|
|
scope = {"type": "http", "method": "POST", "path": "/team/member_update"}
|
|
request = Request(scope)
|
|
|
|
# We don't need a full auth object since premium check happens before auth is used
|
|
auth = object()
|
|
|
|
# Act & Assert: expect HTTPException 400 with the exact premium feature message
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await team_member_update(data, request, auth)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
expected_msg = (
|
|
"Assigning team admins is a premium feature. You must be a LiteLLM Enterprise user to use this feature. "
|
|
"If you have a license please set `LITELLM_LICENSE` in your env. Get a 7 day trial key here: https://www.litellm.ai/#trial. "
|
|
"Pricing: https://www.litellm.ai/#pricing"
|
|
)
|
|
assert exc_info.value.detail == expected_msg
|
|
|
|
|
|
@pytest.fixture
|
|
def happy_path_upsert(monkeypatch):
|
|
"""Stub out the DB and the budget upsert so a team_member_update call reaches
|
|
_upsert_budget_and_membership, and hand back that mock to inspect the patch."""
|
|
team_row = LiteLLM_TeamTable(
|
|
team_id="team-1234",
|
|
members_with_roles=[Member(user_id="user-1", role="user")],
|
|
metadata={},
|
|
)
|
|
|
|
prisma_client = MagicMock()
|
|
prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
|
|
prisma_client.db.litellm_teamtable.update = AsyncMock()
|
|
|
|
class _FakeTx:
|
|
litellm_teamtable = prisma_client.db.litellm_teamtable
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
return False
|
|
|
|
async def query_raw(self, sql, team_id):
|
|
if sql == TEAM_ADVISORY_LOCK_SQL:
|
|
return []
|
|
return [{"members_with_roles": team_row.model_dump()["members_with_roles"]}]
|
|
|
|
prisma_client.tx = MagicMock(return_value=_FakeTx())
|
|
|
|
monkeypatch.setattr(proxy_server, "prisma_client", prisma_client)
|
|
monkeypatch.setattr(proxy_server, "premium_user", False)
|
|
monkeypatch.setattr(
|
|
team_endpoints,
|
|
"team_info",
|
|
AsyncMock(
|
|
return_value={
|
|
"team_info": team_row,
|
|
"team_memberships": [
|
|
types.SimpleNamespace(user_id="user-1", budget_id="bud-1")
|
|
],
|
|
}
|
|
),
|
|
)
|
|
upsert_mock = AsyncMock()
|
|
monkeypatch.setattr(team_endpoints, "_upsert_budget_and_membership", upsert_mock)
|
|
return upsert_mock
|
|
|
|
|
|
def _member_update_request(**overrides):
|
|
data = TeamMemberUpdateRequest(
|
|
team_id="team-1234", user_id="user-1", role="user", **overrides
|
|
)
|
|
request = Request({"type": "http", "method": "POST", "path": "/team/member_update"})
|
|
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN.value, user_id="admin")
|
|
return data, request, auth
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_member_update_sends_provided_fields_as_patch(happy_path_upsert):
|
|
"""Fields the request sets must reach _upsert_budget_and_membership as a
|
|
budget patch, otherwise the member budget is never written/reset."""
|
|
data, request, auth = _member_update_request(
|
|
max_budget_in_team=10.0, budget_duration="30d"
|
|
)
|
|
|
|
response = await team_member_update(data, request, auth)
|
|
|
|
happy_path_upsert.assert_awaited_once()
|
|
assert happy_path_upsert.await_args.kwargs["budget_patch"] == {
|
|
"max_budget": 10.0,
|
|
"budget_duration": "30d",
|
|
}
|
|
assert response.budget_duration == "30d"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_member_update_explicit_null_clears_field(happy_path_upsert):
|
|
"""An explicitly-null field must be forwarded as None so the column is
|
|
cleared, rather than silently dropped."""
|
|
data, request, auth = _member_update_request(budget_duration=None)
|
|
|
|
await team_member_update(data, request, auth)
|
|
|
|
assert happy_path_upsert.await_args.kwargs["budget_patch"] == {
|
|
"budget_duration": None
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_member_update_omits_unset_fields_from_patch(happy_path_upsert):
|
|
"""A request that touches no budget fields must produce an empty patch so the
|
|
member's existing budget is left untouched."""
|
|
data, request, auth = _member_update_request()
|
|
|
|
await team_member_update(data, request, auth)
|
|
|
|
assert happy_path_upsert.await_args.kwargs["budget_patch"] == {}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad_duration",
|
|
[
|
|
"not-a-duration", # unparseable garbage
|
|
"10x", # unsupported unit
|
|
"0d", # zero-length window
|
|
"999999999999999999999999d", # overflows datetime math
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_team_member_update_rejects_invalid_budget_duration(
|
|
monkeypatch, bad_duration
|
|
):
|
|
"""An invalid budget_duration must be rejected with a 400 before any DB
|
|
write, so it can never be persisted and later break the budget reset job."""
|
|
monkeypatch.setattr(proxy_server, "prisma_client", object())
|
|
monkeypatch.setattr(proxy_server, "premium_user", False)
|
|
upsert_mock = AsyncMock()
|
|
monkeypatch.setattr(team_endpoints, "_upsert_budget_and_membership", upsert_mock)
|
|
|
|
data = TeamMemberUpdateRequest(
|
|
team_id="team-1234",
|
|
user_id="user-1",
|
|
role="user",
|
|
budget_duration=bad_duration,
|
|
)
|
|
request = Request({"type": "http", "method": "POST", "path": "/team/member_update"})
|
|
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN.value, user_id="admin")
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await team_member_update(data, request, auth)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert "budget_duration" in str(exc_info.value.detail)
|
|
upsert_mock.assert_not_called()
|