Files
litellm/tests/proxy_behavior/management/test_team_new.py
T
yuneng-jiang 67e6e5e1df test(proxy): behavior-pinning matrix for team management endpoints (#28441)
* test(proxy): behavior-pinning matrix for team management endpoints

PR2 (Team Tier-1) of the management-endpoint behavior-pinning effort.
Extends the tests/proxy_behavior/management/ harness PR1 built and adds
the actor x target-resource authz matrix for the 7 team endpoints:
/team/new, /team/info, /team/list, /team/update, /team/member_add,
/team/member_delete, /team/member_update.

Tests-only, no production code changes.

Harness extensions:
- actors.py: ORG_B_ADMIN actor (org admin of ORG_B) and TEAM_GAMMA (an
  ORG_A team with no actor members), so team-targeting endpoints get a
  clean own / same-org-other / cross-org target axis.
- conftest.py: create_scratch_team() raw-seeds target teams without
  /team/new side effects; the scratch teardown now also strips dangling
  scratch-team refs from LiteLLM_UserTable.teams.

156 new scenarios; status codes pinned to observed handler behavior.

* test(proxy): record mutmut run blockers in PR2 triage doc

Attempted a scoped local mutmut run for G5; it did not complete. Record
the three concrete blockers in mutmut_triage/pr2-team-tier1.md so the next
attempt has a head start:

1. mutmut's mutants/ sandbox is import-shadowed by the worktree source.
2. the legacy mock suite and the real-DB behavior suite cannot share a
   pytest session (mock suite globally patches prisma_client).
3. the CI mutation-test.yml workflow starts no Postgres, so its stats
   phase now aborts on the behavior-suite tests PR1 added to tests_dir.

mutmut stays a deferred follow-up (as in PR1); the binding pre-merge
signal remains the behavior matrix (G1) and the G4 regression-replay.

* test(proxy): drop suite README + triage doc, trim test comments

Remove the two prose docs from the behavior suite (README.md and
mutmut_triage/pr2-team-tier1.md) and tighten the comment blocks on the
team test files + harness down to the load-bearing parts (the gate each
matrix pins, plus genuinely surprising results). No behavior change —
all 286 scenarios still pass.

* test(proxy): remove mutmut tests_dir comment
2026-05-21 16:57:25 -07:00

140 lines
5.2 KiB
Python

from typing import Any, Dict
import pytest
from .actors import Actor
pytestmark = pytest.mark.asyncio(loop_scope="session")
# POST /team/new — actor x org-target matrix (org_target picks the request's
# organization_id: none / ORG_A / ORG_B). Pinned against the role gate, which
# 401s every denial: PROXY_ADMIN always passes; any other caller must name an
# organization_id AND be ORG_ADMIN of that org.
_SCENARIOS = [
("none/proxy_admin", Actor.PROXY_ADMIN, "none", 200),
("none/org_admin", Actor.ORG_ADMIN, "none", 401),
("none/team_admin", Actor.TEAM_ADMIN, "none", 401),
("none/internal_user", Actor.INTERNAL_USER, "none", 401),
("none/owner", Actor.OWNER, "none", 401),
("none/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "none", 401),
("none/cross_org_user", Actor.CROSS_ORG_USER, "none", 401),
("none/service_account", Actor.SERVICE_ACCOUNT, "none", 401),
("none/org_b_admin", Actor.ORG_B_ADMIN, "none", 401),
("org_a/proxy_admin", Actor.PROXY_ADMIN, "org_a", 200),
("org_a/org_admin", Actor.ORG_ADMIN, "org_a", 200),
("org_a/team_admin", Actor.TEAM_ADMIN, "org_a", 401),
("org_a/internal_user", Actor.INTERNAL_USER, "org_a", 401),
("org_a/owner", Actor.OWNER, "org_a", 401),
("org_a/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "org_a", 401),
("org_a/cross_org_user", Actor.CROSS_ORG_USER, "org_a", 401),
("org_a/service_account", Actor.SERVICE_ACCOUNT, "org_a", 401),
("org_a/org_b_admin", Actor.ORG_B_ADMIN, "org_a", 401),
("org_b/proxy_admin", Actor.PROXY_ADMIN, "org_b", 200),
("org_b/org_admin", Actor.ORG_ADMIN, "org_b", 401),
("org_b/team_admin", Actor.TEAM_ADMIN, "org_b", 401),
("org_b/internal_user", Actor.INTERNAL_USER, "org_b", 401),
("org_b/owner", Actor.OWNER, "org_b", 401),
("org_b/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "org_b", 401),
("org_b/cross_org_user", Actor.CROSS_ORG_USER, "org_b", 401),
("org_b/service_account", Actor.SERVICE_ACCOUNT, "org_b", 401),
("org_b/org_b_admin", Actor.ORG_B_ADMIN, "org_b", 200),
]
@pytest.mark.parametrize(
"actor,org_target,expected_status",
[(a, o, s) for (_id, a, o, s) in _SCENARIOS],
ids=[s[0] for s in _SCENARIOS],
)
async def test_team_new_authz_matrix(
actor: Actor,
org_target: str,
expected_status: int,
proxy_client,
prisma,
scratch,
world,
):
caller = world.keys[actor]
org_id = {
"none": None,
"org_a": world.org_a_id,
"org_b": world.org_b_id,
}[org_target]
body: Dict[str, Any] = {"team_id": scratch.prefix, "team_alias": scratch.prefix}
if org_id is not None:
body["organization_id"] = org_id
resp = await proxy_client.post(
"/team/new",
headers={"Authorization": f"Bearer {caller.cleartext}"},
json=body,
)
assert (
resp.status_code == expected_status
), f"{actor.value} org={org_target}: {resp.status_code} {resp.text}"
row = await prisma.db.litellm_teamtable.find_unique(
where={"team_id": scratch.prefix}
)
if expected_status == 200:
assert row is not None
assert row.organization_id == org_id
else:
assert row is None, f"{actor.value}: denied but team row leaked"
async def test_team_new_rejects_negative_budget(proxy_client, prisma, scratch, world):
"""Input-validation pin: max_budget < 0 is a 400, no row created."""
resp = await proxy_client.post(
"/team/new",
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
json={"team_id": scratch.prefix, "max_budget": -1},
)
assert resp.status_code == 400, resp.text
row = await prisma.db.litellm_teamtable.find_unique(
where={"team_id": scratch.prefix}
)
assert row is None
async def test_team_new_rejects_duplicate_team_id(proxy_client, prisma, scratch, world):
"""Input-validation pin: a colliding team_id is a 400 on the second call."""
seeder = world.keys[Actor.PROXY_ADMIN].cleartext
first = await proxy_client.post(
"/team/new",
headers={"Authorization": f"Bearer {seeder}"},
json={"team_id": scratch.prefix, "team_alias": scratch.prefix},
)
assert first.status_code == 200, first.text
second = await proxy_client.post(
"/team/new",
headers={"Authorization": f"Bearer {seeder}"},
json={"team_id": scratch.prefix, "team_alias": scratch.prefix},
)
assert second.status_code == 400, second.text
async def test_team_new_unknown_organization_is_500(
proxy_client, prisma, scratch, world
):
"""SURFACED, NOT ENDORSED: a /team/new with an organization_id that does
not exist currently fails 500 (the role-resolution layer raises before
the handler's own 400 'Organization not found' check is reached)."""
resp = await proxy_client.post(
"/team/new",
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
json={
"team_id": scratch.prefix,
"organization_id": scratch.tag("no-such-org"),
},
)
assert resp.status_code == 500, resp.text
row = await prisma.db.litellm_teamtable.find_unique(
where={"team_id": scratch.prefix}
)
assert row is None