litellm/tests/integration/management/test_f7_key_coverage_push.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

906 lines
33 KiB
Python
Raw Permalink 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.

import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Final, Optional
import pytest
from prisma import Json
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, create_scratch_team
pytestmark = pytest.mark.asyncio(loop_scope="session")
async def _seed_token(
db,
scratch_prefix: str,
*,
suffix: str = "tok",
user_id: str,
spend: float = 0.0,
max_budget: Optional[float] = None,
metadata: Optional[dict] = None,
team_id: Optional[str] = None,
) -> str:
cleartext = "sk-" + uuid.uuid4().hex
data: Dict[str, Any] = {
"token": hash_token(cleartext),
"key_alias": f"{scratch_prefix}-{suffix}",
"key_name": f"{scratch_prefix}-{suffix}",
"user_id": user_id,
"models": [],
"spend": spend,
}
if max_budget is not None:
data["max_budget"] = max_budget
if metadata is not None:
data["metadata"] = Json(metadata)
if team_id is not None:
data["team_id"] = team_id
await db.db.litellm_verificationtoken.create(data=data)
return cleartext
# ---------------------------------------------------------------------------
# /key/health — `metadata.logging` flips the handler into test_key_logging,
# which lives at lines 5990–6067. The healthy-no-logging path is already
# covered by the existing test_key_health.py; this adds the logging-set
# path. The mock_response inside test_key_logging means no real LLM call
# fires — only the callback-name validation and the post-call sweep.
# ---------------------------------------------------------------------------
async def test_key_health_with_logging_metadata_runs_test_logging(
gateway_client, db, resources, actors
):
cleartext = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.INTERNAL_USER].user_id,
metadata={
"logging": [
{"callback_name": "noop-resources-callback"},
]
},
)
resp = await gateway_client.post(
"/key/health",
headers={"Authorization": f"Bearer {cleartext}"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
# Either healthy or unhealthy — both pin the logging branch.
assert body["key"] in ("healthy", "unhealthy")
assert "logging_callbacks" in body
assert body["logging_callbacks"]["callbacks"] == ["noop-resources-callback"]
async def test_key_health_with_missing_callback_name_rejected(
gateway_client, db, resources, actors
):
"""test_key_logging raises ValueError if a callback dict lacks
callback_name — wrapped by the outer try/except into a 500."""
cleartext = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.PROXY_ADMIN].user_id,
metadata={"logging": [{"not_callback_name": "x"}]},
)
resp = await gateway_client.post(
"/key/health",
headers={"Authorization": f"Bearer {cleartext}"},
)
# The outer handler currently wraps the inner ValueError as a 500;
# accept any rejection envelope so a future 400/422 conversion doesn't
# trip this test. The named-guard substring is the real pin.
assert resp.status_code in (400, 422, 500), resp.text
assert "callback_name" in resp.text
# ---------------------------------------------------------------------------
# /key/reset_spend — 404 + happy path + non-admin reject. Pins
# _validate_reset_spend_value (lines 4565–4596) and
# _check_proxy_or_team_admin_for_key (lines 4536–4562).
# ---------------------------------------------------------------------------
async def test_reset_spend_ghost_key_404(gateway_client, resources, actors):
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
resp = await gateway_client.post(
f"/key/sk-{resources.prefix}-ghost/reset_spend",
headers={"Authorization": f"Bearer {seeder}"},
json={"reset_to": 0.0},
)
assert resp.status_code == 404, resp.text
async def test_reset_spend_happy_path(gateway_client, db, resources, actors):
cleartext = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.PROXY_ADMIN].user_id,
spend=5.0,
)
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
resp = await gateway_client.post(
f"/key/{cleartext}/reset_spend",
headers={"Authorization": f"Bearer {seeder}"},
json={"reset_to": 1.0},
)
assert resp.status_code == 200, resp.text
assert resp.json()["spend"] == 1.0
assert resp.json()["previous_spend"] == 5.0
row = await db.db.litellm_verificationtoken.find_unique(
where={"token": hash_token(cleartext)}
)
assert row is not None
assert row.spend == 1.0
# Pydantic catches non-float at the request layer (422), so the inner
# `isinstance(reset_to, (int, float))` guard at line 4568 is unreachable
# from the HTTP boundary. Only the negative + above-current-spend branches
# fire as the handler's own 400.
@pytest.mark.parametrize(
"reset_to,expected_detail",
[
(-1.0, "must be >= 0"),
(100.0, "must be <= current spend"), # current spend = 5.0
],
ids=["negative", "above_current_spend"],
)
async def test_reset_spend_validate_value_branches(
reset_to, expected_detail: str, gateway_client, db, resources, actors
):
cleartext = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.PROXY_ADMIN].user_id,
spend=5.0,
)
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
resp = await gateway_client.post(
f"/key/{cleartext}/reset_spend",
headers={"Authorization": f"Bearer {seeder}"},
json={"reset_to": reset_to},
)
assert resp.status_code == 400, resp.text
assert expected_detail in resp.text, resp.text
# Row spend must be unchanged.
row = await db.db.litellm_verificationtoken.find_unique(
where={"token": hash_token(cleartext)}
)
assert row.spend == 5.0, "spend mutated despite validation rejection"
async def test_reset_spend_non_admin_caller_rejected(
gateway_client, db, resources, actors
):
"""_check_proxy_or_team_admin_for_key raises 403 when caller is neither
proxy admin nor admin of the key's team."""
# Seed a key against actors.team_beta_id (no actor in our actors is admin of beta).
cleartext = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.CROSS_ORG_USER].user_id,
spend=5.0,
team_id=actors.team_beta_id,
)
# Caller is a member of actors.team_alpha_id but not admin of actors.team_beta_id.
initiator = actors.keys[Actor.INTERNAL_USER].cleartext
resp = await gateway_client.post(
f"/key/{cleartext}/reset_spend",
headers={"Authorization": f"Bearer {initiator}"},
json={"reset_to": 1.0},
)
# Route-level admin gate may fire first (401) or the helper's own 403 —
# both prove the path is guarded; pin either as rejection.
assert resp.status_code in (401, 403), resp.text
row = await db.db.litellm_verificationtoken.find_unique(
where={"token": hash_token(cleartext)}
)
assert row.spend == 5.0, "spend mutated despite rejection"
# ---------------------------------------------------------------------------
# /key/regenerate — ghost key 404 + happy path + new_key override. Pins
# the route handler body lines 4382–4533 and _execute_virtual_key_regeneration
# entry-point lines around 4220–4240.
# ---------------------------------------------------------------------------
async def test_regenerate_ghost_key_404(gateway_client, resources, actors):
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
resp = await gateway_client.post(
f"/key/sk-{resources.prefix}-ghost/regenerate",
headers={"Authorization": f"Bearer {seeder}"},
json={},
)
assert resp.status_code == 404, resp.text
async def test_regenerate_no_key_supplied_400(gateway_client, actors):
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
resp = await gateway_client.post(
"/key/regenerate",
headers={"Authorization": f"Bearer {seeder}"},
json={},
)
assert resp.status_code == 400, resp.text
error = resp.json()["error"]
assert error["code"] == "400", resp.text
assert "No key passed in." in error["message"], resp.text
async def test_regenerate_happy_path(gateway_client, db, resources, actors):
cleartext = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.PROXY_ADMIN].user_id,
)
old_hash = hash_token(cleartext)
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
resp = await gateway_client.post(
f"/key/{cleartext}/regenerate",
headers={"Authorization": f"Bearer {seeder}"},
json={},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["key"].startswith("sk-")
new_hash = hash_token(body["key"])
assert new_hash != old_hash
# Old token should no longer be in active tokens (deleted by regenerate).
old_row = await db.db.litellm_verificationtoken.find_unique(
where={"token": old_hash}
)
assert old_row is None, "old token still present after regenerate"
# New token should be active.
new_row = await db.db.litellm_verificationtoken.find_unique(
where={"token": new_hash}
)
assert new_row is not None, "new token not written after regenerate"
async def test_regenerate_with_explicit_new_key(gateway_client, db, resources, actors):
cleartext = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.PROXY_ADMIN].user_id,
suffix="explicit",
)
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
new_key = "sk-" + uuid.uuid4().hex
resp = await gateway_client.post(
f"/key/{cleartext}/regenerate",
headers={"Authorization": f"Bearer {seeder}"},
json={"new_key": new_key},
)
assert resp.status_code == 200, resp.text
assert resp.json()["key"] == new_key
new_row = await db.db.litellm_verificationtoken.find_unique(
where={"token": hash_token(new_key)}
)
assert new_row is not None
# ---------------------------------------------------------------------------
# /key/generate duplicate-alias rejection — pins _enforce_unique_key_alias
# (lines 6118–6133). Two keys cannot share an alias.
# ---------------------------------------------------------------------------
async def test_generate_duplicate_alias_rejected(gateway_client, db, resources, actors):
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
alias = resources.prefix + "-shared-alias"
first = await gateway_client.post(
"/key/generate",
headers={"Authorization": f"Bearer {seeder}"},
json={"key_alias": alias},
)
assert first.status_code == 200, first.text
second = await gateway_client.post(
"/key/generate",
headers={"Authorization": f"Bearer {seeder}"},
json={"key_alias": alias},
)
assert second.status_code == 400, second.text
assert "already exists" in second.text, second.text
# ---------------------------------------------------------------------------
# /key/generate with malformed model_max_budget → 400 from
# validate_model_max_budget (lines 6148–6169).
# ---------------------------------------------------------------------------
async def test_generate_with_invalid_model_max_budget_rejected(
gateway_client, resources, actors
):
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
resp = await gateway_client.post(
"/key/generate",
headers={"Authorization": f"Bearer {seeder}"},
json={
"key_alias": resources.prefix,
# Wrong shape — budget_limit should be numeric; passing a dict
# for the model value bypasses the BudgetConfig parse and trips
# the validator's exception wrap.
"model_max_budget": {"gpt-4": {"budget_limit": "not-a-number"}},
},
)
# validate_model_max_budget raises ValueError, which the outer handler
# currently wraps as a 500. Pin only the named-guard substring; accept
# 400/422/500 so a future error-envelope improvement doesn't trip this.
assert resp.status_code in (400, 422, 500), resp.text
assert "Invalid model_max_budget" in resp.text, resp.text
# ---------------------------------------------------------------------------
# /key/list — pins validate_key_list_check (lines 4695–4790). The handler
# already runs via Phase 1–3's test_key_list.py for the PROXY_ADMIN bypass;
# this adds the non-admin user_id-mismatch + team_id-mismatch +
# organization_id-mismatch branches.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"filter_kwarg,expected_substring",
[
({"user_id": "behavior-pin-proxy_admin"}, "check another user"),
({"team_id": "behavior-pin-team-beta"}, "check this team"),
(
{"organization_id": "behavior-pin-org-b"},
"check this organization",
),
],
ids=["user_mismatch", "team_mismatch", "org_mismatch"],
)
async def test_key_list_non_admin_authz_branches(
filter_kwarg, expected_substring: str, gateway_client, actors
):
"""Non-admin caller hits validate_key_list_check's three rejection
branches. INTERNAL_USER (Org A, actors.team_alpha_id member) is the caller; each
filter targets a foreign user/team/org and trips the matching guard."""
caller = actors.keys[Actor.INTERNAL_USER].cleartext
qs = "&".join(f"{k}={v}" for k, v in filter_kwarg.items())
resp = await gateway_client.get(
f"/key/list?{qs}",
headers={"Authorization": f"Bearer {caller}"},
)
assert resp.status_code == 403, resp.text
assert expected_substring in resp.text, resp.text
# ---------------------------------------------------------------------------
# /key/bulk_update — pins the whole admin-only handler body (lines 2622–2733)
# including the per-key try/except branch (2688–2727) via a mixed batch
# of one existing key + one ghost.
# ---------------------------------------------------------------------------
async def test_key_bulk_update_mixed_success_and_failure(
gateway_client, db, resources, actors
):
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
existing = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.PROXY_ADMIN].user_id,
suffix="bulk1",
)
resp = await gateway_client.post(
"/key/bulk_update",
headers={"Authorization": f"Bearer {seeder}"},
json={
"keys": [
{"key": existing, "max_budget": 5.0},
{"key": "sk-ghost-" + resources.prefix, "max_budget": 5.0},
]
},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["total_requested"] == 2
assert len(body["successful_updates"]) == 1
assert len(body["failed_updates"]) == 1
# Re-read confirms the successful key was actually updated.
row = await db.db.litellm_verificationtoken.find_unique(
where={"token": hash_token(existing)}
)
assert row.max_budget == 5.0
async def test_key_bulk_update_non_admin_rejected(gateway_client, actors):
"""Lines 2631–2635 — admin-only role gate."""
caller = actors.keys[Actor.INTERNAL_USER].cleartext
resp = await gateway_client.post(
"/key/bulk_update",
headers={"Authorization": f"Bearer {caller}"},
json={"keys": [{"key": "sk-x", "max_budget": 1.0}]},
)
assert resp.status_code in (401, 403), resp.text
async def test_key_bulk_update_empty_keys_rejected(gateway_client, actors):
"""Line 2643–2647 — empty keys list rejected."""
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
resp = await gateway_client.post(
"/key/bulk_update",
headers={"Authorization": f"Bearer {seeder}"},
json={"keys": []},
)
assert resp.status_code == 400, resp.text
assert "No keys" in resp.text
async def test_key_bulk_update_exceeds_max_batch_rejected(gateway_client, actors):
"""Lines 2649–2656 — over-batch-size rejection. 501 ghost keys are fine
here because validation fires before any update runs."""
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
over_batch = [{"key": f"sk-{i}", "max_budget": 1.0} for i in range(501)]
resp = await gateway_client.post(
"/key/bulk_update",
headers={"Authorization": f"Bearer {seeder}"},
json={"keys": over_batch},
)
assert resp.status_code == 400, resp.text
assert "500" in resp.text or "Maximum" in resp.text
# ---------------------------------------------------------------------------
# /key/update with extended fields — pins prepare_key_update_data
# branches: duration (1794–1802), budget_duration (1804–1815),
# model_max_budget validation (1838–1840), and the reserved-metadata
# immutability check (1718–1731).
# ---------------------------------------------------------------------------
async def test_key_update_with_duration_and_budget_duration(
gateway_client, db, resources, actors
):
cleartext = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.PROXY_ADMIN].user_id,
)
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
resp = await gateway_client.post(
"/key/update",
headers={"Authorization": f"Bearer {seeder}"},
json={
"key": cleartext,
"duration": "1h",
"budget_duration": "30d",
},
)
assert resp.status_code == 200, resp.text
row = await db.db.litellm_verificationtoken.find_unique(
where={"token": hash_token(cleartext)}
)
assert row is not None
assert row.budget_duration == "30d"
now = datetime.now(timezone.utc)
assert timedelta(minutes=55) < row.expires - now <= timedelta(hours=1), row.expires
assert now < row.budget_reset_at <= now + timedelta(days=31), row.budget_reset_at
info = await gateway_client.get(
f"/key/info?key={hash_token(cleartext)}",
headers={"Authorization": f"Bearer {seeder}"},
)
assert info.status_code == 200, info.text
assert info.json()["info"]["expires"] == row.expires.isoformat()
assert info.json()["info"]["budget_reset_at"] == row.budget_reset_at.isoformat()
async def test_key_update_with_clear_duration(gateway_client, db, resources, actors):
cleartext = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.PROXY_ADMIN].user_id,
)
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
expiring = await gateway_client.post(
"/key/update",
headers={"Authorization": f"Bearer {seeder}"},
json={"key": cleartext, "duration": "1h"},
)
assert expiring.status_code == 200, expiring.text
expiring_row = await db.db.litellm_verificationtoken.find_unique(
where={"token": hash_token(cleartext)}
)
assert expiring_row.expires is not None, "duration=1h did not set expires"
resp = await gateway_client.post(
"/key/update",
headers={"Authorization": f"Bearer {seeder}"},
json={"key": cleartext, "duration": "-1"},
)
assert resp.status_code == 200, resp.text
row = await db.db.litellm_verificationtoken.find_unique(
where={"token": hash_token(cleartext)}
)
assert row.expires is None, "expires not cleared by duration=-1"
# ---------------------------------------------------------------------------
# /team/key/bulk_update — pins handler body lines 2797–2950 including:
# - missing team_id 400 (2803–2807)
# - over-batch-size 400 (2810–2816)
# - all_keys_in_team scan (2818–2841)
# - explicit key_ids dedupe (2842–2863)
# - non-admin permission gate (2866–2882)
# - per-key loop with mixed success/404 (2902–2944)
# ---------------------------------------------------------------------------
async def test_team_key_bulk_update_no_selector_rejected(gateway_client, actors):
"""Pydantic root-validator catches missing key_ids/all_keys_in_team
at the request layer → 422."""
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
resp = await gateway_client.post(
"/team/key/bulk_update",
headers={"Authorization": f"Bearer {seeder}"},
json={
"team_id": actors.team_alpha_id,
"update_fields": {"max_budget": 5.0},
},
)
assert resp.status_code == 422, resp.text
assert "key_ids" in resp.text or "all_keys_in_team" in resp.text
async def test_team_key_bulk_update_all_keys_in_team(
gateway_client, db, resources, actors
):
"""Pin the all_keys_in_team branch (lines 2818–2841) plus the
per-key success path. Seed two resources keys against the resources team."""
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
team_id = await create_scratch_team(
db,
team_id=resources.tag("team"),
admin_user_ids=[actors.keys[Actor.PROXY_ADMIN].user_id],
)
k1 = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.PROXY_ADMIN].user_id,
suffix="t1",
team_id=team_id,
)
k2 = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.PROXY_ADMIN].user_id,
suffix="t2",
team_id=team_id,
)
resp = await gateway_client.post(
"/team/key/bulk_update",
headers={"Authorization": f"Bearer {seeder}"},
json={
"team_id": team_id,
"all_keys_in_team": True,
"update_fields": {"max_budget": 7.0},
},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["total_requested"] == 2
# Re-read both keys; max_budget should be set.
for k in (k1, k2):
row = await db.db.litellm_verificationtoken.find_unique(
where={"token": hash_token(k)}
)
assert row.max_budget == 7.0
async def test_team_key_bulk_update_explicit_key_ids_mixed(
gateway_client, db, resources, actors
):
"""Pin the explicit-key_ids dedupe + per-key 404 path (lines 2842–2944).
Send a real key + a ghost key under the same team_id."""
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
team_id = await create_scratch_team(
db,
team_id=resources.tag("team"),
admin_user_ids=[actors.keys[Actor.PROXY_ADMIN].user_id],
)
real_key = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.PROXY_ADMIN].user_id,
suffix="real",
team_id=team_id,
)
resp = await gateway_client.post(
"/team/key/bulk_update",
headers={"Authorization": f"Bearer {seeder}"},
json={
"team_id": team_id,
"key_ids": [real_key, real_key, "sk-ghost-" + resources.prefix],
"update_fields": {"max_budget": 9.0},
},
)
assert resp.status_code == 200, resp.text
body = resp.json()
# Dedupe collapses the two real_key entries → 2 unique tokens.
assert body["total_requested"] == 2
assert len(body["successful_updates"]) == 1
assert len(body["failed_updates"]) == 1
# ---------------------------------------------------------------------------
# /key/aliases — pins the handler body lines 5108–5207. PROXY_ADMIN hits
# the broad-scope path; a non-admin caller hits the scoped path through
# _apply_non_admin_alias_scope.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# /key/info — pins the handler body (lines 3253–3303). PROXY_ADMIN with an
# explicit ghost key 404s; with a real key 200s. Phase 1–3 covered the
# auth matrix; this adds the explicit-key path the matrix doesn't hit.
# ---------------------------------------------------------------------------
async def test_key_info_ghost_key_404(gateway_client, resources, actors):
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
resp = await gateway_client.get(
f"/key/info?key=sk-{resources.prefix}-ghost",
headers={"Authorization": f"Bearer {seeder}"},
)
assert resp.status_code == 404, resp.text
async def test_key_info_explicit_existing_key(gateway_client, db, resources, actors):
cleartext = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.PROXY_ADMIN].user_id,
suffix="info-test",
)
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
resp = await gateway_client.get(
f"/key/info?key={cleartext}",
headers={"Authorization": f"Bearer {seeder}"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["key"] == cleartext
assert "info" in body
# Token hash is stripped from the response (line 3296).
assert "token" not in body["info"]
async def test_key_info_no_key_uses_auth_header(gateway_client, actors):
caller = actors.keys[Actor.INTERNAL_USER].cleartext
resp = await gateway_client.get(
"/key/info",
headers={"Authorization": f"Bearer {caller}"},
)
assert resp.status_code == 200, resp.text
body: Final = resp.json()
assert body["key"] == hash_token(caller)
assert body["info"]["user_id"] == actors.keys[Actor.INTERNAL_USER].user_id
assert body["info"]["status"] == "active"
assert "token" not in body["info"]
# ---------------------------------------------------------------------------
# /key/generate with budget_limits — pins budget_limits initialization
# inside generate_key_helper_fn (lines 3427–3436).
# ---------------------------------------------------------------------------
async def test_key_generate_with_budget_limits(gateway_client, db, resources, actors):
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
resp = await gateway_client.post(
"/key/generate",
headers={"Authorization": f"Bearer {seeder}"},
json={
"key_alias": resources.prefix,
"budget_limits": [
{"max_budget": 5.0, "budget_duration": "1d"},
{"max_budget": 20.0, "budget_duration": "30d"},
],
},
)
assert resp.status_code == 200, resp.text
cleartext: Final = resp.json()["key"]
info: Final = await gateway_client.get(
f"/key/info?key={hash_token(cleartext)}",
headers={"Authorization": f"Bearer {seeder}"},
)
assert info.status_code == 200, info.text
windows: Final = info.json()["info"]["budget_limits"]
assert [
(window["max_budget"], window["budget_duration"]) for window in windows
] == [(5.0, "1d"), (20.0, "30d")]
async def test_key_aliases_proxy_admin_unscoped(gateway_client, db, resources, actors):
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
alias = resources.prefix + "-aliases-test"
await gateway_client.post(
"/key/generate",
headers={"Authorization": f"Bearer {seeder}"},
json={"key_alias": alias},
)
resp = await gateway_client.get(
"/key/aliases?size=10",
headers={"Authorization": f"Bearer {seeder}"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert "aliases" in body
assert "total_count" in body
assert body["current_page"] == 1
assert body["size"] == 10
assert (
alias in body["aliases"]
), f"newly created alias not in list: {body['aliases']}"
async def test_key_aliases_with_search_filter(gateway_client, db, resources, actors):
"""Pin the `search` ILIKE branch (lines 5166–5168)."""
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
unique_alias = resources.prefix + "-search-unique-tag"
await gateway_client.post(
"/key/generate",
headers={"Authorization": f"Bearer {seeder}"},
json={"key_alias": unique_alias},
)
resp = await gateway_client.get(
f"/key/aliases?search={resources.prefix}-search",
headers={"Authorization": f"Bearer {seeder}"},
)
assert resp.status_code == 200, resp.text
assert unique_alias in resp.json()["aliases"]
# ---------------------------------------------------------------------------
# /key/list with extended filters — pins _build_filter_conditions branches
# at lines 5280–5388. Each scenario varies one filter so a different
# branch fires.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"filter_name",
[
"created_by",
"team_keys",
"full_object",
"sort",
"alias_substring",
],
ids=["created_by", "team_keys", "full_object", "sort", "alias_substring"],
)
async def test_key_list_filter_branches(filter_name: str, gateway_client, resources, actors):
admin: Final = actors.keys[Actor.PROXY_ADMIN]
key_alias: Final = resources.tag("filter-branch")
cleartext: Final = await create_scratch_key(
gateway_client,
admin.cleartext,
resources.prefix,
user_id=admin.user_id,
key_alias=key_alias,
team_id=actors.team_alpha_id,
)
query: Final = {
"created_by": "include_created_by_keys=true",
"team_keys": "include_team_keys=true",
"full_object": "return_full_object=true",
"sort": "sort_by=created_at&sort_order=asc",
"alias_substring": f"key_alias={key_alias[:-5]}&substring_matching=true",
}[filter_name]
list_query: Final = (
query
if filter_name == "alias_substring"
else f"{query}&key_alias={key_alias}"
)
resp: Final = await gateway_client.get(
f"/key/list?{list_query}",
headers={"Authorization": f"Bearer {admin.cleartext}"},
)
assert resp.status_code == 200, resp.text
response_body: Final = resp.json()
entries: Final = response_body["keys"]
assert response_body["total_count"] == 1
returned_hashes: Final = frozenset(
entry["token"] if isinstance(entry, dict) else entry for entry in entries
)
assert returned_hashes == {hash_token(cleartext)}
# ---------------------------------------------------------------------------
# /key/regenerate with grace_period — pins _insert_deprecated_key body
# (lines 4168–4202). The old token gets retained in
# LiteLLM_DeprecatedVerificationToken; assert it lands there.
# ---------------------------------------------------------------------------
async def test_regenerate_with_grace_period_inserts_deprecated_row(
gateway_client, db, resources, actors
):
cleartext = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.PROXY_ADMIN].user_id,
suffix="grace",
)
old_hash = hash_token(cleartext)
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
resp = await gateway_client.post(
f"/key/{cleartext}/regenerate",
headers={"Authorization": f"Bearer {seeder}"},
json={"grace_period": "1h"},
)
assert resp.status_code == 200, resp.text
new_key = resp.json()["key"]
# Old token should now be in the deprecated table.
deprecated_row = await db.db.litellm_deprecatedverificationtoken.find_unique(
where={"token": old_hash}
)
assert deprecated_row is not None, "old token not retained in deprecated table"
assert deprecated_row.active_token_id == hash_token(new_key)
assert deprecated_row.revoke_at is not None
# Manual cleanup — resources prefix sweep doesn't cover this table.
await db.db.litellm_deprecatedverificationtoken.delete(
where={"token": old_hash}
)
async def test_regenerate_with_invalid_grace_period_format(
gateway_client, db, resources, actors
):
"""Invalid grace_period format falls through silently (line 4170–4175);
regenerate still succeeds but no deprecated row is inserted."""
cleartext = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.PROXY_ADMIN].user_id,
suffix="grace-bad",
)
old_hash = hash_token(cleartext)
seeder = actors.keys[Actor.PROXY_ADMIN].cleartext
resp = await gateway_client.post(
f"/key/{cleartext}/regenerate",
headers={"Authorization": f"Bearer {seeder}"},
json={"grace_period": "totally-not-a-duration"},
)
assert resp.status_code == 200, resp.text
deprecated_row = await db.db.litellm_deprecatedverificationtoken.find_unique(
where={"token": old_hash}
)
assert deprecated_row is None, "deprecated row created despite invalid grace_period"
async def test_key_list_key_hash_filter_unauthorized(
gateway_client, db, resources, actors
):
"""validate_key_list_check's key_hash branch (lines 4766–4789): a
cross-tenant non-admin caller asks for a key_hash they don't own → 403.
`user_belongs_to_keys_team` returns True for any team member, so a
same-team caller is allowed to query peer keys by hash (intentional
per the helper's policy). The 403 path requires a caller who is neither
the key owner, team member, nor admin — i.e. CROSS_ORG_USER.
"""
cleartext = await _seed_token(
db,
resources.prefix,
user_id=actors.keys[Actor.OWNER].user_id,
team_id=actors.team_alpha_id,
)
caller = actors.keys[Actor.CROSS_ORG_USER].cleartext
resp = await gateway_client.get(
f"/key/list?key_hash={hash_token(cleartext)}",
headers={"Authorization": f"Bearer {caller}"},
)
assert resp.status_code == 403, resp.text