Files
litellm/tests/test_litellm/proxy/test_team_member_update.py
T
ryan-crabbe-berriandGitHub f31d059aa3 feat(ui): add budget duration to edit team member form (#29717)
* feat(ui): add budget duration to edit team member form

Editing a team member created a member budget with no duration, so the
budget never reset. This threads a budget reset period through the edit
flow end to end and reuses the shared duration dropdown so the options
stay in sync with the rest of the UI.

Resolves LIT-2651

* fix(proxy): validate member budget_duration and persist clears

Reject budget_duration values that can't be parsed, are non-positive, or overflow date math before any write, so a bad value can't be persisted and later crash the budget reset job.

Clearing the budget duration in the edit-member form now sends null and clears the column end to end, so the dropdown's clear control reflects a real change instead of being a no-op

* chore(ui): regenerate schema.d.ts for member budget_duration

Adds budget_duration to TeamMemberUpdateRequest/Response in the generated dashboard types so the Check UI API Types Sync gate passes
2026-06-06 17:24:55 -07:00

181 lines
6.3 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_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:
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
prisma_client.db.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()