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:
ryan-crabbe-berri 2026-09-16 16:26:52 -07:00
parent 81ae5caa7e
commit ab92a6637d
2 changed files with 292 additions and 10 deletions

View file

@ -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"}

View file

@ -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}"
)