litellm/tests/integration/management/test_key_update.py
devin-ai-integration[bot] 780c504e00
test: migrate tests/proxy_behavior into integration and unit tiers (#45939)
* test: migrate tests/proxy_behavior into integration and unit tiers (partial)

* test(integration): run settings-dependent management tests on an owned proxy

* test(integration): assert stored values in the rewritten management rows

* test(integration): pin sibling team allocations against the org guaranteed throughput cap

* test(integration): keep Prisma clients out of the shared integration conftest import path

---------

Co-authored-by: yuneng <yuneng@berri.ai>
2026-10-11 08:28:42 -07:00

184 lines
6.6 KiB
Python

import uuid
import pytest
from litellm.proxy.utils import hash_token
from tests.integration._support.actors import TEAM_ALPHA, TEAM_BETA, Actor
from tests.integration._support.actors import create_scratch_key
pytestmark = pytest.mark.asyncio(loop_scope="session")
# (id, actor, target_shape, expected_status). Pinned against current gating:
# proxy_admin bypasses; org_admin is blocked by an early role gate (401);
# every other (INTERNAL_USER-roled) actor hits user_id-mismatch 403, no-team-
# admin 403, or team_member_permission 401 depending on target / membership.
_SCENARIOS = [
("self/proxy_admin", Actor.PROXY_ADMIN, "self", 200),
("self/org_admin", Actor.ORG_ADMIN, "self", 401),
("self/team_admin", Actor.TEAM_ADMIN, "self", 403),
("self/internal_user", Actor.INTERNAL_USER, "self", 403),
("self/owner", Actor.OWNER, "self", 403),
("self/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "self", 403),
("self/cross_org_user", Actor.CROSS_ORG_USER, "self", 403),
("self/service_account", Actor.SERVICE_ACCOUNT, "self", 403),
("owner_target/proxy_admin", Actor.PROXY_ADMIN, "owner", 200),
("owner_target/org_admin", Actor.ORG_ADMIN, "owner", 401),
("owner_target/team_admin", Actor.TEAM_ADMIN, "owner", 403),
("owner_target/internal_user", Actor.INTERNAL_USER, "owner", 403),
("owner_target/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "owner", 403),
("owner_target/cross_org_user", Actor.CROSS_ORG_USER, "owner", 403),
("owner_target/service_account", Actor.SERVICE_ACCOUNT, "owner", 403),
("cross_org_target/proxy_admin", Actor.PROXY_ADMIN, "cross_org", 200),
("cross_org_target/org_admin", Actor.ORG_ADMIN, "cross_org", 401),
("cross_org_target/team_admin", Actor.TEAM_ADMIN, "cross_org", 403),
("cross_org_target/owner", Actor.OWNER, "cross_org", 403),
("cross_org_target/cross_org_user", Actor.CROSS_ORG_USER, "cross_org", 401),
("cross_org_target/service_account", Actor.SERVICE_ACCOUNT, "cross_org", 403),
]
MARKER_MODEL = "behavior-pin-update-marker-model"
@pytest.mark.parametrize(
"actor,target_shape,expected_status",
[(a, t, s) for (_id, a, t, s) in _SCENARIOS],
ids=[s[0] for s in _SCENARIOS],
)
async def test_key_update_authz_matrix(
actor: Actor,
target_shape: str,
expected_status: int,
gateway_client,
db,
resources,
actors,
):
caller = actors.keys[actor]
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
if target_shape == "self":
target_cleartext = await create_scratch_key(
gateway_client, seeder, resources.prefix, user_id=caller.user_id
)
elif target_shape == "owner":
target_cleartext = await create_scratch_key(
gateway_client,
seeder,
resources.prefix,
user_id=actors.keys[Actor.OWNER].user_id,
team_id=actors.team_alpha_id,
)
elif target_shape == "cross_org":
target_cleartext = await create_scratch_key(
gateway_client,
seeder,
resources.prefix,
user_id=actors.keys[Actor.CROSS_ORG_USER].user_id,
team_id=actors.team_beta_id,
)
else:
pytest.fail(f"unknown target_shape={target_shape}")
target_hashed = hash_token(target_cleartext)
resp = await gateway_client.post(
"/key/update",
headers={"Authorization": f"Bearer {caller.cleartext}"},
json={"key": target_cleartext, "models": [MARKER_MODEL]},
)
assert (
resp.status_code == expected_status
), f"{actor.value} {target_shape}: {resp.status_code} {resp.text}"
row = await db.db.litellm_verificationtoken.find_unique(
where={"token": target_hashed}
)
assert row is not None
if expected_status == 200:
assert row.models == [MARKER_MODEL]
else:
assert row.models != [MARKER_MODEL], "denied but row mutated"
async def _seed_shape(gateway_client, seeder, prefix, actors, shape, caller) -> str:
if shape == "self":
return await create_scratch_key(
gateway_client, seeder, prefix, user_id=caller.user_id
)
if shape == "owner":
return await create_scratch_key(
gateway_client,
seeder,
prefix,
user_id=actors.keys[Actor.OWNER].user_id,
team_id=actors.team_alpha_id,
)
if shape == "cross_org":
return await create_scratch_key(
gateway_client,
seeder,
prefix,
user_id=actors.keys[Actor.CROSS_ORG_USER].user_id,
team_id=actors.team_beta_id,
)
pytest.fail(f"unknown shape={shape}") # pragma: no cover
async def test_key_update_missing_key_is_404(gateway_client, actors):
"""An update targeting a key absent from the DB is a 404 — not 401/403."""
resp = await gateway_client.post(
"/key/update",
headers={"Authorization": f"Bearer {actors.keys[Actor.PROXY_ADMIN].cleartext}"},
json={"key": "sk-" + uuid.uuid4().hex, "models": [MARKER_MODEL]},
)
assert resp.status_code == 404, resp.text
# A denied /key/update must not partially apply: the budget/limit columns are
# left untouched. Each scenario is a denial cell from the matrix above.
_DENIED_BUDGET = [
("team_admin/self", Actor.TEAM_ADMIN, "self", 403),
("internal_user/owner", Actor.INTERNAL_USER, "owner", 403),
("cross_org_user/cross_org", Actor.CROSS_ORG_USER, "cross_org", 401),
]
@pytest.mark.parametrize(
"actor,target_shape,expected_status",
[(a, t, s) for (_id, a, t, s) in _DENIED_BUDGET],
ids=[s[0] for s in _DENIED_BUDGET],
)
async def test_key_update_denied_does_not_touch_budget_counters(
actor: Actor,
target_shape: str,
expected_status: int,
gateway_client,
db,
resources,
actors,
):
caller = actors.keys[actor]
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
target = await _seed_shape(
gateway_client, seeder, resources.prefix, actors, target_shape, caller
)
target_hashed = hash_token(target)
resp = await gateway_client.post(
"/key/update",
headers={"Authorization": f"Bearer {caller.cleartext}"},
json={"key": target, "max_budget": 999.0, "tpm_limit": 888, "rpm_limit": 777},
)
assert (
resp.status_code == expected_status
), f"{actor.value} {target_shape}: {resp.status_code} {resp.text}"
row = await db.db.litellm_verificationtoken.find_unique(
where={"token": target_hashed}
)
assert row is not None
assert row.max_budget is None, "denied but max_budget applied"
assert row.tpm_limit is None, "denied but tpm_limit applied"
assert row.rpm_limit is None, "denied but rpm_limit applied"