mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
test(e2e): cover team admin editable fields on /team/update
Team admins are refused until a proxy admin enables a field, then limited to the enabled fields, and resending unchanged budget settings keeps the team's budget reset times
This commit is contained in:
parent
81ae5caa7e
commit
ab92a6637d
2 changed files with 292 additions and 10 deletions
|
|
@ -30,6 +30,9 @@
|
|||
- {id: mgmt.key.health.happy_path, module: mgmt, tier: P1, surface: api, assertions: [happy_path], source: "key_management_endpoints.py:4292", rationale: "Key health endpoint"}
|
||||
- {id: mgmt.key.bulk_update.happy_path, module: mgmt, tier: P1, surface: api, assertions: [happy_path], source: "key_management_endpoints.py:2677", rationale: "Batch key updates"}
|
||||
- {id: mgmt.team.update.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "team_endpoints.py:1582", rationale: "Metadata/budget updates persist"}
|
||||
- {id: mgmt.team.update.team_admin_forbidden_until_enabled, module: mgmt, tier: P0, surface: api, assertions: [team_admin_forbidden_until_enabled], source: "team_admin_field_permissions.py:156", rationale: "With no team admin editable fields enabled, a team admin's /team/update is 403 and /team/info reports editing disabled"}
|
||||
- {id: mgmt.team.update.team_admin_limited_to_enabled_fields, module: mgmt, tier: P0, surface: api, assertions: [team_admin_limited_to_enabled_fields], source: "team_admin_field_permissions.py:156", rationale: "A team admin may change only the enabled fields; a request that also changes any other field is 403 and writes nothing"}
|
||||
- {id: mgmt.team.update.team_admin_resend_keeps_budget_reset, module: mgmt, tier: P1, surface: api, assertions: [team_admin_resend_keeps_budget_reset], source: "team_admin_field_permissions.py:147", fail_before_fix: proven, rationale: "A team admin resending unchanged budget settings with an enabled field must not push the team's budget reset times back"}
|
||||
- {id: mgmt.team.delete.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "team_endpoints.py:1750", rationale: "Deletion prevents key access"}
|
||||
- {id: mgmt.team.block.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "team_endpoints.py", rationale: "Block suspends all members"}
|
||||
- {id: mgmt.team.info.happy_path, module: mgmt, tier: P1, surface: api, assertions: [happy_path], source: "team_endpoints.py:2244", rationale: "Metadata+members+budgets"}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Live e2e: the /team/* management routes' block, membership, and admin-only
|
||||
contract.
|
||||
contract, plus the team settings a team admin may change on /team/update once a
|
||||
proxy admin enables them under Settings > UI > Team admin editable fields.
|
||||
|
||||
Each test creates its team/user/key resources under unique names (deleted on
|
||||
teardown) and asserts both halves of the contract: the recorded state (the info
|
||||
|
|
@ -8,21 +9,25 @@ Team writes reach the read path once their db/cache entry propagates, so the
|
|||
read-backs poll to a deadline instead of asserting once.
|
||||
|
||||
Everything the shared harness does not already model lives here: the local
|
||||
request/response models for /team/block, /team/member_update, and the
|
||||
/team/info fields (blocked flag and per-member budget) these tests assert on.
|
||||
request/response models for /team/block, /team/member_update, the partial
|
||||
/team/update, the UI settings allow-list, and the /team/info fields (blocked
|
||||
flag, limits, budgets, per-member budget, the caller's edit access) these tests
|
||||
assert on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Literal
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Final, Literal
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from e2e_config import unique_marker
|
||||
from e2e_http import NoBody, StreamingResponse, unwrap
|
||||
from e2e_config import settle_propagation, unique_marker
|
||||
from e2e_http import NoBody, PartialBody, StreamingResponse, unwrap
|
||||
from lifecycle import ResourceManager
|
||||
from management_client import ManagementClient
|
||||
from models import (
|
||||
|
|
@ -39,6 +44,8 @@ pytestmark = pytest.mark.e2e
|
|||
|
||||
TeamRole = Literal["admin", "user"]
|
||||
|
||||
_TEAM_TPM_LIMIT: Final = 1000
|
||||
|
||||
|
||||
class TeamBlockBody(BaseModel):
|
||||
team_id: str
|
||||
|
|
@ -66,11 +73,37 @@ class TeamMembership(BaseModel):
|
|||
litellm_budget_table: MemberBudgetTable | None = None
|
||||
|
||||
|
||||
class TeamInfoData(BaseModel):
|
||||
class CallerEditAccess(BaseModel):
|
||||
kind: Literal["unrestricted", "team_admin", "team_admin_disabled", "none"]
|
||||
editable_fields: list[str] = []
|
||||
|
||||
|
||||
class BudgetWindow(BaseModel):
|
||||
budget_duration: str
|
||||
max_budget: float
|
||||
reset_at: str | None = None
|
||||
|
||||
|
||||
class TeamCustomMetadata(BaseModel):
|
||||
cost_center: str | None = None
|
||||
|
||||
|
||||
class TeamSettings(BaseModel):
|
||||
team_alias: str | None = None
|
||||
models: list[str] = []
|
||||
tpm_limit: int | None = None
|
||||
rpm_limit: int | None = None
|
||||
max_budget: float | None = None
|
||||
budget_duration: str | None = None
|
||||
budget_limits: list[BudgetWindow] | None = None
|
||||
metadata: TeamCustomMetadata | None = None
|
||||
|
||||
|
||||
class TeamInfoData(TeamSettings):
|
||||
blocked: bool | None = None
|
||||
members_with_roles: list[MemberRoleEntry] = []
|
||||
budget_reset_at: datetime | None = None
|
||||
caller_edit_access: CallerEditAccess | None = None
|
||||
|
||||
|
||||
class TeamInfoRead(BaseModel):
|
||||
|
|
@ -79,6 +112,27 @@ class TeamInfoRead(BaseModel):
|
|||
team_memberships: list[TeamMembership] = []
|
||||
|
||||
|
||||
class TeamWithAdminNewBody(TeamNewBody):
|
||||
tpm_limit: int
|
||||
members_with_roles: list[TeamMemberEntry]
|
||||
|
||||
|
||||
class TeamSettingsChange(PartialBody, TeamSettings):
|
||||
pass
|
||||
|
||||
|
||||
class TeamSettingsUpdate(TeamSettingsChange):
|
||||
team_id: str
|
||||
|
||||
|
||||
class TeamAdminEditableFields(BaseModel):
|
||||
team_admin_editable_team_fields: list[str] = []
|
||||
|
||||
|
||||
class UiSettingsRead(BaseModel):
|
||||
values: TeamAdminEditableFields
|
||||
|
||||
|
||||
def _poll[T](client: ManagementClient, attempt: Callable[[], T | None], failure: str) -> T:
|
||||
deadline = time.monotonic() + client.proxy.poll_timeout
|
||||
while time.monotonic() < deadline:
|
||||
|
|
@ -107,17 +161,27 @@ def _generate_key(client: ManagementClient, resources: ResourceManager, body: Ke
|
|||
return key
|
||||
|
||||
|
||||
def _read_team(client: ManagementClient, team_id: str) -> TeamInfoRead:
|
||||
def _read_team(client: ManagementClient, team_id: str, caller_key: str | None = None) -> TeamInfoRead:
|
||||
return unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/team/info",
|
||||
headers=client.proxy.transport.master,
|
||||
headers=client.proxy.transport.master if caller_key is None else client.proxy.transport.bearer(caller_key),
|
||||
params=TeamInfoParams(team_id=team_id),
|
||||
response_type=TeamInfoRead,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _poll_team(
|
||||
client: ManagementClient, team_id: str, ready: Callable[[TeamInfoData], bool], failure: str
|
||||
) -> TeamInfoData:
|
||||
def read() -> TeamInfoData | None:
|
||||
info = _read_team(client, team_id).team_info
|
||||
return info if ready(info) else None
|
||||
|
||||
return _poll(client, read, failure)
|
||||
|
||||
|
||||
def _set_blocked(client: ManagementClient, team_id: str, *, blocked: bool) -> None:
|
||||
_ = unwrap(
|
||||
client.proxy.transport.post(
|
||||
|
|
@ -301,3 +365,218 @@ class TestTeamManagementRoutes:
|
|||
client.add_team_member(team_id, member_id)
|
||||
member_key = _generate_key(client, resources, KeyGenerateBody(user_id=member_id, team_id=team_id))
|
||||
return member_id, other_id, member_key, team_id
|
||||
|
||||
|
||||
def _team_admin_editable_fields(client: ManagementClient) -> list[str]:
|
||||
return unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/get/ui_settings",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=UiSettingsRead,
|
||||
)
|
||||
).values.team_admin_editable_team_fields
|
||||
|
||||
|
||||
def _set_team_admin_editable_fields(client: ManagementClient, fields: list[str]) -> None:
|
||||
_ = unwrap(
|
||||
client.proxy.transport.patch(
|
||||
"/update/ui_settings",
|
||||
headers=client.proxy.transport.master,
|
||||
json=TeamAdminEditableFields(team_admin_editable_team_fields=fields),
|
||||
response_type=NoBody,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _team_admins_may_edit(client: ManagementClient, fields: list[str]) -> Generator[None]:
|
||||
"""The allow-list is proxy-wide, so restore whatever was there. Other replicas pick a change up on their
|
||||
config reload, which the wait covers before any team admin call lands on one of them."""
|
||||
original = _team_admin_editable_fields(client)
|
||||
_set_team_admin_editable_fields(client, fields)
|
||||
settle_propagation(time.monotonic())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_set_team_admin_editable_fields(client, original)
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def no_team_admin_editable_fields(client: ManagementClient) -> Generator[None]:
|
||||
with _team_admins_may_edit(client, []):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def tpm_limit_editable_by_team_admins(client: ManagementClient) -> Generator[None]:
|
||||
with _team_admins_may_edit(client, ["tpm_limit"]):
|
||||
yield
|
||||
|
||||
|
||||
def _team_with_admin(client: ManagementClient, resources: ResourceManager) -> tuple[str, str]:
|
||||
"""A team with a tpm_limit, and the key of a user who is an admin of that team."""
|
||||
admin_id = _create_user(client, resources, f"e2e-team-admin-{unique_marker()}@example.com")
|
||||
team_id = client.create_team(
|
||||
TeamWithAdminNewBody(
|
||||
team_alias=f"e2e-team-admin-{unique_marker()}",
|
||||
tpm_limit=_TEAM_TPM_LIMIT,
|
||||
members_with_roles=[TeamMemberEntry(role="admin", user_id=admin_id)],
|
||||
)
|
||||
)
|
||||
resources.defer(lambda: client.delete_team(team_id))
|
||||
return team_id, _generate_key(client, resources, KeyGenerateBody(user_id=admin_id))
|
||||
|
||||
|
||||
def _update_team_as(client: ManagementClient, caller_key: str, body: TeamSettingsUpdate) -> StreamingResponse:
|
||||
return client.proxy.transport.send("/team/update", headers=client.proxy.transport.bearer(caller_key), json=body)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_team_admin_editable_fields")
|
||||
class TestTeamAdminWithNoEditableFields:
|
||||
"""No proxy admin has enabled a team field for team admins, which is how every proxy starts."""
|
||||
|
||||
@pytest.mark.covers("mgmt.team.update.team_admin_forbidden_until_enabled")
|
||||
def test_team_admin_cannot_change_any_team_setting(
|
||||
self, client: ManagementClient, resources: ResourceManager
|
||||
) -> None:
|
||||
team_id, admin_key = _team_with_admin(client, resources)
|
||||
access = _read_team(client, team_id, admin_key).team_info.caller_edit_access
|
||||
assert access == CallerEditAccess(kind="team_admin_disabled"), (
|
||||
f"/team/info should tell the team admin that editing is disabled, got {access}"
|
||||
)
|
||||
|
||||
outcome = _update_team_as(client, admin_key, TeamSettingsUpdate(team_id=team_id, tpm_limit=5000))
|
||||
|
||||
assert outcome.status_code == 403, (
|
||||
f"/team/update by a team admin must be 403 while nothing is enabled, got {outcome.status_code}: "
|
||||
f"{outcome.body[:300]}"
|
||||
)
|
||||
assert "cannot edit team settings" in outcome.body, f"403 body should say why, got: {outcome.body[:300]}"
|
||||
tpm_limit = _read_team(client, team_id).team_info.tpm_limit
|
||||
assert tpm_limit == _TEAM_TPM_LIMIT, f"the refused update still changed tpm_limit to {tpm_limit}"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("tpm_limit_editable_by_team_admins")
|
||||
class TestTeamAdminWithTpmLimitEnabled:
|
||||
"""A proxy admin has enabled tpm_limit, so a team admin may change that setting and no other."""
|
||||
|
||||
@pytest.mark.covers("mgmt.team.update.team_admin_limited_to_enabled_fields")
|
||||
def test_team_admin_saves_the_settings_form_with_a_new_tpm_limit(
|
||||
self, client: ManagementClient, resources: ResourceManager
|
||||
) -> None:
|
||||
team_id, admin_key = _team_with_admin(client, resources)
|
||||
access = _read_team(client, team_id, admin_key).team_info.caller_edit_access
|
||||
assert access == CallerEditAccess(kind="team_admin", editable_fields=["tpm_limit"]), (
|
||||
f"/team/info should list tpm_limit as the team admin's only editable field, got {access}"
|
||||
)
|
||||
before = _read_team(client, team_id).team_info
|
||||
|
||||
outcome = _update_team_as(
|
||||
client,
|
||||
admin_key,
|
||||
TeamSettingsUpdate(team_id=team_id, team_alias=before.team_alias, models=before.models, tpm_limit=5000),
|
||||
)
|
||||
|
||||
assert outcome.status_code == 200, (
|
||||
f"a team admin resending the form with only tpm_limit changed must succeed, got {outcome.status_code}: "
|
||||
f"{outcome.body[:300]}"
|
||||
)
|
||||
after = _poll_team(
|
||||
client, team_id, lambda info: info.tpm_limit == 5000, "/team/info never reflected tpm_limit=5000"
|
||||
)
|
||||
assert after.model_copy(update={"tpm_limit": _TEAM_TPM_LIMIT}) == before, (
|
||||
f"the update changed more than tpm_limit: before {before}, after {after}"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("mgmt.team.update.team_admin_limited_to_enabled_fields")
|
||||
@pytest.mark.parametrize(
|
||||
"change",
|
||||
[
|
||||
pytest.param(TeamSettingsChange(rpm_limit=10), id="rpm_limit"),
|
||||
pytest.param(TeamSettingsChange(max_budget=0.5), id="max_budget"),
|
||||
pytest.param(TeamSettingsChange(team_alias="renamed-by-team-admin"), id="team_alias"),
|
||||
pytest.param(TeamSettingsChange(models=["gemini-2.5-flash"]), id="models"),
|
||||
pytest.param(TeamSettingsChange(budget_duration="1d"), id="budget_duration"),
|
||||
pytest.param(TeamSettingsChange(metadata=TeamCustomMetadata(cost_center="team-admin")), id="metadata"),
|
||||
],
|
||||
)
|
||||
def test_team_admin_cannot_change_a_setting_that_is_not_enabled(
|
||||
self, client: ManagementClient, resources: ResourceManager, change: TeamSettingsChange
|
||||
) -> None:
|
||||
(field,) = change.model_fields_set
|
||||
team_id, admin_key = _team_with_admin(client, resources)
|
||||
before = _read_team(client, team_id).team_info
|
||||
|
||||
outcome = _update_team_as(
|
||||
client,
|
||||
admin_key,
|
||||
TeamSettingsUpdate.model_validate(
|
||||
{**change.model_dump(exclude_unset=True), "team_id": team_id, "tpm_limit": 5000}
|
||||
),
|
||||
)
|
||||
|
||||
assert outcome.status_code == 403, (
|
||||
f"a team admin changing {field} must be 403, got {outcome.status_code}: {outcome.body[:300]}"
|
||||
)
|
||||
assert f"'{field}'" in outcome.body, f"403 body should name {field}, got: {outcome.body[:300]}"
|
||||
after = _read_team(client, team_id).team_info
|
||||
assert after == before, (
|
||||
f"the refused update still wrote to the team, the enabled tpm_limit included: before {before}, "
|
||||
f"after {after}"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("mgmt.team.update.team_admin_resend_keeps_budget_reset")
|
||||
def test_team_admin_resending_the_budget_settings_keeps_the_next_budget_reset(
|
||||
self, client: ManagementClient, resources: ResourceManager
|
||||
) -> None:
|
||||
"""A 120s budget resets at the start of the minute after next. Resending it once the next minute has
|
||||
started would push that reset a minute later, while the stored reset is still a minute out, so the
|
||||
proxy's budget reset job cannot be what moves it."""
|
||||
team_id, admin_key = _team_with_admin(client, resources)
|
||||
_ = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/team/update",
|
||||
headers=client.proxy.transport.master,
|
||||
json=TeamSettingsUpdate(
|
||||
team_id=team_id,
|
||||
budget_duration="120s",
|
||||
budget_limits=[BudgetWindow(budget_duration="120s", max_budget=5.0)],
|
||||
),
|
||||
response_type=NoBody,
|
||||
)
|
||||
)
|
||||
budgeted = _poll_team(
|
||||
client,
|
||||
team_id,
|
||||
lambda info: info.budget_reset_at is not None and bool(info.budget_limits),
|
||||
"/team/info never reflected the 120s budget the proxy admin set",
|
||||
)
|
||||
assert budgeted.budget_reset_at is not None
|
||||
next_minute = budgeted.budget_reset_at - timedelta(seconds=58)
|
||||
time.sleep(max(0.0, (next_minute - datetime.now(UTC)).total_seconds()))
|
||||
|
||||
outcome = _update_team_as(
|
||||
client,
|
||||
admin_key,
|
||||
TeamSettingsUpdate(
|
||||
team_id=team_id,
|
||||
tpm_limit=5000,
|
||||
budget_duration=budgeted.budget_duration,
|
||||
budget_limits=budgeted.budget_limits,
|
||||
),
|
||||
)
|
||||
|
||||
assert outcome.status_code == 200, (
|
||||
f"resending unchanged budget settings with a new tpm_limit must succeed, got {outcome.status_code}: "
|
||||
f"{outcome.body[:300]}"
|
||||
)
|
||||
after = _poll_team(
|
||||
client, team_id, lambda info: info.tpm_limit == 5000, "/team/info never reflected tpm_limit=5000"
|
||||
)
|
||||
assert after.budget_reset_at == budgeted.budget_reset_at, (
|
||||
f"the team admin pushed the budget reset from {budgeted.budget_reset_at} to {after.budget_reset_at}"
|
||||
)
|
||||
assert after.budget_limits == budgeted.budget_limits, (
|
||||
f"the team admin pushed the budget window resets from {budgeted.budget_limits} to {after.budget_limits}"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue