Files
litellm/tests/proxy_behavior/management/test_team_update.py
T
yuneng-jiangandGitHub 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

177 lines
6.3 KiB
Python

import pytest
from .actors import Actor
from .conftest import create_scratch_team
pytestmark = pytest.mark.asyncio(loop_scope="session")
# POST /team/update — actor x team-shape matrix (shapes built by _seed_target).
# Each request carries the team's own organization_id so a non-proxy-admin can
# reach the org-scoped branch of the route-permission gate (401 on denial),
# which fronts the handler's _verify_team_access. Only PROXY_ADMIN and an
# ORG_ADMIN of the team's org pass: an internal_user team admin is filtered by
# the route gate before _verify_team_access's team-admin branch is reached.
MARKER_ALIAS = "behavior-pin-update-marker-alias"
_MATRIX = [
("alpha/proxy_admin", Actor.PROXY_ADMIN, "alpha", 200),
("alpha/org_admin", Actor.ORG_ADMIN, "alpha", 200),
("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 401),
("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 401),
("alpha/owner", Actor.OWNER, "alpha", 401),
("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 401),
("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 401),
("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 401),
("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 401),
("beta/proxy_admin", Actor.PROXY_ADMIN, "beta", 200),
("beta/org_admin", Actor.ORG_ADMIN, "beta", 401),
("beta/team_admin", Actor.TEAM_ADMIN, "beta", 401),
("beta/internal_user", Actor.INTERNAL_USER, "beta", 401),
("beta/owner", Actor.OWNER, "beta", 401),
("beta/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "beta", 401),
("beta/cross_org_user", Actor.CROSS_ORG_USER, "beta", 401),
("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 401),
("beta/org_b_admin", Actor.ORG_B_ADMIN, "beta", 200),
]
async def _seed_target(prisma, world, shape: str, team_id: str) -> str:
"""Raw-seed the scratch target team; returns its organization_id."""
if shape == "alpha":
await create_scratch_team(
prisma,
team_id,
organization_id=world.org_a_id,
admin_user_ids=[world.keys[Actor.TEAM_ADMIN].user_id],
member_user_ids=[
world.keys[Actor.INTERNAL_USER].user_id,
world.keys[Actor.OWNER].user_id,
world.keys[Actor.UNRELATED_SAME_ORG].user_id,
world.keys[Actor.SERVICE_ACCOUNT].user_id,
],
)
return world.org_a_id
if shape == "beta":
await create_scratch_team(
prisma,
team_id,
organization_id=world.org_b_id,
member_user_ids=[world.keys[Actor.CROSS_ORG_USER].user_id],
)
return world.org_b_id
pytest.fail(f"unknown shape={shape}") # pragma: no cover
@pytest.mark.parametrize(
"actor,shape,expected_status",
[(a, sh, s) for (_id, a, sh, s) in _MATRIX],
ids=[s[0] for s in _MATRIX],
)
async def test_team_update_authz_matrix(
actor: Actor,
shape: str,
expected_status: int,
proxy_client,
prisma,
scratch,
world,
):
org_id = await _seed_target(prisma, world, shape, scratch.prefix)
caller = world.keys[actor]
resp = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {caller.cleartext}"},
json={
"team_id": scratch.prefix,
"team_alias": MARKER_ALIAS,
"organization_id": org_id,
},
)
assert (
resp.status_code == expected_status
), f"{actor.value} {shape}: {resp.status_code} {resp.text}"
row = await prisma.db.litellm_teamtable.find_unique(
where={"team_id": scratch.prefix}
)
assert row is not None
if expected_status == 200:
assert row.team_alias == MARKER_ALIAS
else:
assert row.team_alias != MARKER_ALIAS, "denied but team mutated"
async def test_team_update_requires_proxy_admin_without_org_context(
proxy_client, prisma, scratch, world
):
"""With no organization_id in the body the route gate has no org context
and falls back to proxy-admin-only: an org admin of the team's own org
is 401, PROXY_ADMIN is 200."""
await _seed_target(prisma, world, "alpha", scratch.prefix)
denied = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {world.keys[Actor.ORG_ADMIN].cleartext}"},
json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS},
)
assert denied.status_code == 401, denied.text
allowed = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS},
)
assert allowed.status_code == 200, allowed.text
# Relocation gate — moving a team to a different org. The scratch team starts
# in ORG_A; each scenario relocates it to ORG_B. PROXY_ADMIN bypasses;
# ORG_B_ADMIN clears the route gate (dest-org admin) but fails
# _verify_team_access on the source team (403); the rest fail the route gate
# (401). The relocation-allowed branch needs a caller who is org admin of both
# orgs — no seeded actor is, so it is left to a later slice.
_RELOCATION = [
("proxy_admin", Actor.PROXY_ADMIN, 200),
("org_b_admin", Actor.ORG_B_ADMIN, 403),
("org_admin", Actor.ORG_ADMIN, 401),
("team_admin", Actor.TEAM_ADMIN, 401),
("internal_user", Actor.INTERNAL_USER, 401),
]
@pytest.mark.parametrize(
"actor,expected_status",
[(a, s) for (_id, a, s) in _RELOCATION],
ids=[s[0] for s in _RELOCATION],
)
async def test_team_update_org_relocation_gate(
actor: Actor,
expected_status: int,
proxy_client,
prisma,
scratch,
world,
):
await _seed_target(prisma, world, "alpha", scratch.prefix)
caller = world.keys[actor]
resp = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {caller.cleartext}"},
json={"team_id": scratch.prefix, "organization_id": world.org_b_id},
)
assert (
resp.status_code == expected_status
), f"{actor.value}: {resp.status_code} {resp.text}"
row = await prisma.db.litellm_teamtable.find_unique(
where={"team_id": scratch.prefix}
)
assert row is not None
if expected_status == 200:
assert row.organization_id == world.org_b_id
else:
assert row.organization_id == world.org_a_id, "denied but team relocated"