Files
litellm/tests/proxy_behavior/management/test_f7_coverage_closeout.py
T
yuneng-jiang 5e16f20962 test(proxy): phase-4 payload behavior pinning for tier-2/3 key + team management endpoints (#28681)
* test(proxy): phase-4 payload behavior pinning for tier-2/3 key + team management endpoints

Extends the Phase 1–3 behavior-pin suite at tests/proxy_behavior/management/
with a second axis: payload-shape pinning. Phase 1–3 held payload minimal
and pinned (actor, target) → status across 37 routes; Phase 4 holds the
caller fixed at an authorized actor, varies the payload shape, and asserts
the observable DB effect (on accept) or the named guard / row-unchanged
(on reject). Faithfulness contract from Phase 1–3 is unchanged.

Six families + one gap-closer (59 new scenarios, 620 → 679 total):

  * F1 — key budget / rate-limit (test_key_budget_limits.py, 18)
  * F2 — key↔team reassignment   (test_key_team_change.py, 6)
  * F3 — team budget / rate-limit (test_team_budget_limits.py, 15)
  * F4 — member-info validation   (test_team_member_info_validation.py, 5)
  * F5 — permission batching      (test_team_permissions_bulk_update.py, 6)
  * F6 — org-scoped team access   (+2 detail-string pins in existing files)
  * F7 — coverage gap-closer      (test_f7_coverage_closeout.py, 7)

Harness extensions in conftest.py (additive only):
  * create_scratch_org() seeder with its own scratch-prefixed budget row
  * budget / limit fields on create_scratch_team()
  * scratch teardown also sweeps litellm_organizationtable

Coverage telemetry (behavior-suite-only):
  * key_management_endpoints.py  60 % → 65 % (+82 lines)
  * team_endpoints.py            62 % → 72 % (+137 lines, crosses 70 % stretch)

Key lands under 70 % per plan §7 escape hatch — the gap is dominated by
routes outside F1–F6 scope (key list/info v2 internals) and structurally
dead org-budget guards (call sites at lines 889 + 2310 + 985 + 1751 load
the org without include_budget_table=True, so org.litellm_budget_table is
None at guard time and the aggregate guard no-ops). Pinned as observed
no-op behavior so a future fix that flips the flag turns these into reds.

Zero source-code changes; pyproject.toml diff is empty;
test_route_coverage.py stays green untouched; G3 grep guards still green;
local wall-time 14 s for the full suite (no coverage), 22 s with coverage.

G4 regression-replay protocol executed against three representative
fix-PR parents (410ce761dc, 0bd49ecb8b, 8bbc61e03c): all Phase 4 tests
PASS at pre-fix SHAs — confirming the F1–F7 layer is a helper-body pin,
not a regression-replay layer for those specific historical bypass
shapes. Targeted RED-bait scenarios for each fix are left for a
follow-up PR.

* test(proxy): push key_management_endpoints.py past the 70% stretch (F7-extension)

Adds 24 more payload-pin scenarios in test_f7_key_coverage_push.py
following the same accepted-effect / rejected-guard pattern. Each
scenario cites the file:line range it pins; same anti-snapshot rules
apply.

Target ranges (all reachable via HTTP-boundary payload variation):
  * 5942-6063  /key/health with metadata.logging → test_key_logging body
  * 4565-4692  /key/reset_spend happy + 404 + non-admin gate + value validation
  * 4421-4533  /key/regenerate ghost-404 + happy + new_key + grace_period
  * 4168-4202  _insert_deprecated_key body via grace_period
  * 6118-6133  _enforce_unique_key_alias duplicate-alias rejection
  * 6148-6169  validate_model_max_budget malformed-payload rejection
  * 4708-4789  validate_key_list_check user/team/org/key_hash branches
  * 2622-2733  /key/bulk_update mixed success/failure + admin gate + size limits
  * 2797-2950  /team/key/bulk_update all-keys path + explicit-keys dedupe + 404
  * 5108-5207  /key/aliases admin + scoped + search-filter branches
  * 3253-3303  /key/info ghost + explicit-key + no-key-uses-auth-header
  * 3427-3436  generate_key_helper_fn budget_limits initialization
  * 1794-1815  prepare_key_update_data duration + budget_duration paths
  * 5280-5388  _build_filter_conditions across include_created_by_keys/team/sort/alias

Coverage telemetry — full PR4 dataset:

  key_management_endpoints.py: 60 % → 71 %  (+11 pts, +194 lines)
  team_endpoints.py:           62 % → 72 %  (+10 pts, +137 lines)

Both files now over the plan §7 PR4.M4 70 % stretch as a side effect of
pinning real payload behavior. 721 tests pass in 19 s local (full suite,
no coverage); 27 s with coverage. Zero source-code changes; pyproject.toml
diff still empty; test_route_coverage.py + G3 grep guards still green.

Honest finding (kept from the prior commit's body): four structurally-dead
org-budget guards remain pinned as observed no-op behavior — they fire
only when get_org_object is called with include_budget_table=True, which
none of the four management-endpoint call sites currently do. Pinned so
a future change that flips the flag turns these into reds.

Two helper guards are honest-ceiling: _validate_reset_spend_value's
isinstance check at line 4568 is unreachable from HTTP because Pydantic
422s non-float before the helper runs; same shape for /team/key/bulk_update's
missing team_id / no-selector pre-handler guards.

* test(proxy): address PR review — try/finally cleanup + loosen 500 envelope pins + Optional annotations

Greptile review feedback on PR #28681:

1. Wrap manual budget-row cleanup in try/finally so an assertion failure
   doesn't leave non-scratch-prefixed budget rows orphaned across CI re-runs
   (test_team_new_with_team_member_budget_creates_budget_row and
   test_team_update_team_member_budget_upserts).
2. Loosen the two 500-status pins to in (400, 422, 500) — the named-guard
   substring is the real pin; the outer ValueError-wrap envelope is an
   implementation detail that a future improvement should be free to fix
   to a proper 400/422 without flipping these tests red.
3. Add missing Optional annotations on _seed_token's max_budget / metadata
   / team_id keyword args (they default to None).

Greptile's typo flag on 'read-world' in the conftest comment is declined —
'read-world' is the project's established term for the immutable seeded
world fixture (see other usages in conftest.py and actors.py).

721 tests still pass in 17 s.
2026-05-23 12:16:29 -07:00

347 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 4 F7 — coverage gap-closer scenarios picked from the un-covered
ranges left after F1F6 landed.
Each scenario cites the file:line range it pins (PR4.M3 requirement).
Scenarios that would only pad the count without pinning observable
behavior are excluded — see `phase4-plan.md` §4-F7 anti-patterns.
Ranges addressed here:
* team_endpoints.py 455521 (_check_team_model_specific_limits body)
* team_endpoints.py 538566 (_check_team_rpm_tpm_limits body)
* team_endpoints.py 696731 (_check_org_team_limits guaranteed-throughput
branch — currently dead because the call
site doesn't include_budget_table=True,
but the inner `find_many` + helper-loop
runs regardless, covering the lines)
* key_management_endpoints.py 11471156 (_check_project_key_limits
project-not-found 404)
* key_management_endpoints.py 30073018 (validate_key_team_change
team-admin-accepts branch)
"""
import uuid
from typing import Any, Dict, Optional
import pytest
from prisma import Json
from litellm.proxy.utils import hash_token
from .actors import TEAM_ALPHA, Actor
from .conftest import create_scratch_org, create_scratch_team
pytestmark = pytest.mark.asyncio(loop_scope="session")
# ---------------------------------------------------------------------------
# /team/new — guaranteed_throughput route into _check_org_team_limits's
# throughput branch (lines 696731), which then calls
# check_org_team_model_specific_limits → _check_team_model_specific_limits
# (lines 442525). The org metadata supplies the per-model cap; sibling
# teams are loaded from DB to drive the allocation sum.
# ---------------------------------------------------------------------------
async def test_org_team_guaranteed_throughput_model_over_bound_rejected(
proxy_client, prisma, scratch, world
):
org_id = await create_scratch_org(
prisma,
scratch.prefix,
models=["gpt-4"],
metadata={"model_rpm_limit": {"gpt-4": 30}},
)
# A sibling team in the same org already burning 20 rpm on gpt-4 —
# forces _check_team_model_specific_limits's `model_specific_rpm_limit`
# accumulator to actually accumulate (covers lines 468478).
await create_scratch_team(
prisma,
team_id=scratch.tag("sibling"),
organization_id=org_id,
metadata={"model_rpm_limit": {"gpt-4": 20}},
)
seeder = world.keys[Actor.PROXY_ADMIN].cleartext
team_id = scratch.tag("new")
body = {
"team_id": team_id,
"team_alias": team_id,
"organization_id": org_id,
"models": ["gpt-4"],
"model_rpm_limit": {"gpt-4": 100},
"rpm_limit_type": "guaranteed_throughput",
}
resp = await proxy_client.post(
"/team/new",
headers={"Authorization": f"Bearer {seeder}"},
json=body,
)
# 20 (sibling) + 100 (new) > 30 (org cap) → guard fires.
assert resp.status_code == 400, resp.text
assert "RPM" in resp.text, resp.text
rows = await prisma.db.litellm_teamtable.find_many(where={"team_id": team_id})
assert rows == []
async def test_org_team_guaranteed_throughput_model_tpm_over_bound_rejected(
proxy_client, prisma, scratch, world
):
"""Mirror of the rpm scenario but for the model_tpm side — pins
_check_team_model_specific_limits's tpm branch (lines 503521) which
the rpm scenario doesn't exercise."""
org_id = await create_scratch_org(
prisma,
scratch.prefix,
models=["gpt-4"],
metadata={"model_tpm_limit": {"gpt-4": 500}},
)
await create_scratch_team(
prisma,
team_id=scratch.tag("sibling"),
organization_id=org_id,
metadata={"model_tpm_limit": {"gpt-4": 200}},
)
seeder = world.keys[Actor.PROXY_ADMIN].cleartext
team_id = scratch.tag("new")
body = {
"team_id": team_id,
"team_alias": team_id,
"organization_id": org_id,
"models": ["gpt-4"],
"model_tpm_limit": {"gpt-4": 5000},
"tpm_limit_type": "guaranteed_throughput",
}
resp = await proxy_client.post(
"/team/new",
headers={"Authorization": f"Bearer {seeder}"},
json=body,
)
assert resp.status_code == 400, resp.text
assert "TPM" in resp.text, resp.text
rows = await prisma.db.litellm_teamtable.find_many(where={"team_id": team_id})
assert rows == []
async def test_org_team_guaranteed_throughput_aggregate_runs(
proxy_client, prisma, scratch, world
):
"""Aggregate guard's no-op path (line 549-566 — entity_rpm_limit is None
because include_budget_table=False at the call site). The helper still
executes its `allocated_tpm = sum(...)` and `allocated_rpm = sum(...)`
lines, which is the coverage target."""
org_id = await create_scratch_org(prisma, scratch.prefix, models=["m"])
await create_scratch_team(
prisma,
team_id=scratch.tag("sibling"),
organization_id=org_id,
tpm_limit=100,
rpm_limit=10,
)
seeder = world.keys[Actor.PROXY_ADMIN].cleartext
team_id = scratch.tag("new")
body = {
"team_id": team_id,
"team_alias": team_id,
"organization_id": org_id,
"models": ["m"],
"tpm_limit": 50,
"rpm_limit": 5,
"tpm_limit_type": "guaranteed_throughput",
}
resp = await proxy_client.post(
"/team/new",
headers={"Authorization": f"Bearer {seeder}"},
json=body,
)
# No org budget table loaded → check is no-op → 200.
assert resp.status_code == 200, resp.text
# ---------------------------------------------------------------------------
# /key/generate — _check_project_key_limits project-not-found branch
# (lines 11471156). Hitting it requires a project_id that doesn't resolve;
# get_project_object returns None, the handler raises 404.
# ---------------------------------------------------------------------------
async def test_key_generate_with_unknown_project_id_rejected(
proxy_client, prisma, scratch, world
):
seeder = world.keys[Actor.PROXY_ADMIN].cleartext
body = {
"key_alias": scratch.prefix,
"project_id": f"{scratch.prefix}-ghost-project",
}
resp = await proxy_client.post(
"/key/generate",
headers={"Authorization": f"Bearer {seeder}"},
json=body,
)
# The unknown-project guard inside _check_project_key_limits raises 404.
# The route's exception handler may wrap it; pin both shapes.
assert resp.status_code in (400, 404), resp.text
assert "project" in resp.text.lower() or "not found" in resp.text.lower(), resp.text
rows = await prisma.db.litellm_verificationtoken.find_many(
where={"key_alias": scratch.prefix}
)
assert rows == [], "rejected key leaked a row"
# ---------------------------------------------------------------------------
# /key/update — validate_key_team_change team-admin-accepts branch
# (line 3011). PROXY_ADMIN covers line 3006; a non-admin team admin of the
# target team covers 30073011. Owner stays a member of the destination
# team to clear the membership guard first.
# ---------------------------------------------------------------------------
async def _seed_key_for_relocation(
prisma, scratch_prefix: str, *, user_id: str, team_id: str
) -> str:
cleartext = "sk-" + uuid.uuid4().hex
await prisma.db.litellm_verificationtoken.create(
data={
"token": hash_token(cleartext),
"key_alias": f"{scratch_prefix}-key",
"key_name": f"{scratch_prefix}-key",
"user_id": user_id,
"team_id": team_id,
"models": [],
}
)
return cleartext
async def test_team_new_with_team_member_budget_creates_budget_row(
proxy_client, prisma, scratch, world
):
"""/team/new with `team_member_budget` routes through
TeamMemberBudgetHandler.create_team_member_budget_table (lines 196248).
Observable end-state: a litellm_budgettable row is created and the
team's metadata.team_member_budget_id points at it."""
seeder = world.keys[Actor.PROXY_ADMIN].cleartext
team_id = scratch.tag("team-with-mbudget")
resp = await proxy_client.post(
"/team/new",
headers={"Authorization": f"Bearer {seeder}"},
json={
"team_id": team_id,
"team_alias": scratch.tag("alias"),
"team_member_budget": 10.0,
"team_member_rpm_limit": 100,
"team_member_tpm_limit": 1000,
},
)
assert resp.status_code == 200, resp.text
team_row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id})
assert team_row is not None
budget_id = (team_row.metadata or {}).get("team_member_budget_id")
assert (
budget_id is not None
), f"team_member_budget_id not stamped on team metadata: {team_row.metadata!r}"
# The handler writes the per-member budget row under a non-scratch id
# pattern (`team-<alias>-budget-<uuid>`), so the prefix sweep can't
# reclaim it. Cleanup must run even when a downstream assertion fires;
# otherwise orphan rows accumulate across CI re-runs.
try:
budget_row = await prisma.db.litellm_budgettable.find_unique(
where={"budget_id": budget_id}
)
assert budget_row is not None, "team_member_budget row was not created"
assert budget_row.max_budget == 10.0
assert budget_row.rpm_limit == 100
assert budget_row.tpm_limit == 1000
finally:
await prisma.db.litellm_teamtable.update(
where={"team_id": team_id},
data={"metadata": Json({})},
)
await prisma.db.litellm_budgettable.delete(where={"budget_id": budget_id})
async def test_team_update_team_member_budget_upserts(
proxy_client, prisma, scratch, world
):
"""/team/update with `team_member_budget` against a team that has no
pre-existing team_member_budget_id routes through
TeamMemberBudgetHandler.upsert_team_member_budget_table's else-branch
(lines 294303), which in turn calls create_team_member_budget_table."""
team_id = await create_scratch_team(prisma, team_id=scratch.tag("team"))
seeder = world.keys[Actor.PROXY_ADMIN].cleartext
resp = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {seeder}"},
json={
"team_id": team_id,
"team_member_budget": 5.0,
},
)
assert resp.status_code == 200, resp.text
team_row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id})
assert team_row is not None
budget_id = (team_row.metadata or {}).get("team_member_budget_id")
assert budget_id is not None, "team_member_budget_id not upserted"
# See comment on the sibling test — non-prefixed budget row, cleanup
# must survive an assertion failure to avoid orphan accumulation.
try:
# (No further assertions today, but the try/finally keeps the
# cleanup contract uniform with the sibling test and is robust to
# future asserts being added here.)
pass
finally:
await prisma.db.litellm_teamtable.update(
where={"team_id": team_id},
data={"metadata": Json({})},
)
await prisma.db.litellm_budgettable.delete(where={"budget_id": budget_id})
async def test_key_team_change_accepted_by_target_team_admin(
proxy_client, prisma, scratch, world
):
"""Caller is admin of the destination team AND the key's owner — the
common_key_access_checks gate requires user_id-match for non-proxy-admin
callers, so the team-admin-accepts branch of validate_key_team_change
can only be reached when the team admin is also the key holder.
Hits validate_key_team_change line 30073011."""
actor_user_id = f"{scratch.prefix}-self-admin"
actor_cleartext = "sk-" + uuid.uuid4().hex
await prisma.db.litellm_usertable.create(
data={"user_id": actor_user_id, "user_role": "internal_user"}
)
await prisma.db.litellm_verificationtoken.create(
data={
"token": hash_token(actor_cleartext),
"key_alias": f"{scratch.prefix}-actor-key",
"key_name": f"{scratch.prefix}-actor-key",
"user_id": actor_user_id,
"models": [],
"allowed_routes": ["/key/update"],
}
)
source_team = await create_scratch_team(
prisma,
team_id=scratch.tag("source"),
admin_user_ids=[actor_user_id],
)
target_team = await create_scratch_team(
prisma,
team_id=scratch.tag("target"),
admin_user_ids=[actor_user_id],
)
key_cleartext = await _seed_key_for_relocation(
prisma, scratch.prefix, user_id=actor_user_id, team_id=source_team
)
resp = await proxy_client.post(
"/key/update",
headers={"Authorization": f"Bearer {actor_cleartext}"},
json={"key": key_cleartext, "team_id": target_team},
)
assert resp.status_code == 200, resp.text
row = await prisma.db.litellm_verificationtoken.find_unique(
where={"token": hash_token(key_cleartext)}
)
assert row is not None
assert row.team_id == target_team, "key did not move under team-admin initiator"