Merge pull request #41573 from BerriAI/litellm_project-route-coverage

test(management): cover project authorization lifecycle
This commit is contained in:
yuneng-jiang 2026-09-17 10:30:55 -07:00 committed by GitHub
commit 11b99142eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 237 additions and 5 deletions

View file

@ -3,16 +3,15 @@ from __future__ import annotations
import os
import time
import uuid
from hashlib import sha256
from collections.abc import Callable, Iterator, Mapping
from contextlib import ExitStack, contextmanager
from dataclasses import dataclass
from hashlib import sha256
from typing import Final, TypeVar
import httpx
from pydantic import JsonValue, TypeAdapter
from integration._support.database import read_rows
from pydantic import JsonValue, TypeAdapter
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
T = TypeVar("T")
@ -124,6 +123,16 @@ class Scenario:
assert response.status_code == 200, response.text
assert read_rows('SELECT project_id FROM "LiteLLM_ProjectTable" WHERE project_id = %s', (identity,)) == []
def budget(self, **fields: JsonValue) -> str:
created: Final = self.gateway.post("/budget/new", fields)
identity: Final = string_value(created["budget_id"])
self.cleanups.callback(self.delete_budget, identity)
return identity
def delete_budget(self, identity: str) -> None:
self.gateway.post("/budget/delete", {"id": identity})
assert read_rows('SELECT budget_id FROM "LiteLLM_BudgetTable" WHERE budget_id = %s', (identity,)) == []
def user(self, **fields: JsonValue) -> str:
created: Final = self.gateway.post(
"/user/new", {"user_id": f"integration-{uuid.uuid4().hex}", "auto_create_key": False, **fields}

View file

@ -187,6 +187,23 @@
],
"tests/integration/spend/test_filtered_ledger.py::test_rotated_keys_users_and_model_groups_preserve_success_failure_cache_ledger": [
"quota_management.spend_tracking.filtered_ledger_preserves_owner_identity_and_totals"
],
"tests/integration/management/test_partial_update_sequences.py::test_restricted_actor_cannot_detach_key_from_project": [
"mgmt.key.update.project_detach_denied_to_restricted_actor"
],
"tests/integration/management/test_partial_update_sequences.py::test_cross_tenant_actor_cannot_read_update_or_detach_project_key": [
"mgmt.key.info.cross_tenant_key_is_denied",
"mgmt.key.update.cross_tenant_key_is_denied",
"mgmt.key.update.cross_tenant_project_detach_is_denied"
],
"tests/integration/management/test_project_lifecycle.py::test_project_new_persists_real_state": [
"mgmt.project.new.real_route_persists"
],
"tests/integration/management/test_project_lifecycle.py::test_project_update_persists_real_state": [
"mgmt.project.update.real_route_persists"
],
"tests/integration/management/test_project_lifecycle.py::test_project_delete_with_attached_key_refuses_and_preserves_state": [
"mgmt.project.delete.attached_key_refusal_preserves_state"
]
},
"browser": {

View file

@ -5,11 +5,21 @@ from typing import Final
import pytest
from hypothesis import strategies as st
from hypothesis.stateful import RuleBasedStateMachine, invariant, rule, run_state_machine_as_test
from pydantic import JsonValue
from integration._support.client import Gateway, object_value
from integration._support.database import read_rows
from integration._support.generation import LIFECYCLE_SETTINGS, bounded_http_requests
from pydantic import JsonValue
def _key_rows(digest: str) -> list[dict[str, JsonValue]]:
return read_rows(
'SELECT token, key_name, key_alias, models, aliases, config, router_settings, user_id, team_id, '
'agent_id, project_id, permissions, max_parallel_requests, metadata, blocked, tpm_limit, rpm_limit, '
'tpd_limit, max_budget, budget_duration, allowed_cache_controls, allowed_routes, key_type, policies, '
'access_group_ids, model_spend, model_max_budget, budget_fallbacks, budget_id, organization_id, '
'object_permission_id, budget_limits FROM "LiteLLM_VerificationToken" WHERE token = %s',
(digest,),
)
@pytest.mark.covers("mgmt.key.update.generated_sequences_preserve_state")
@ -198,3 +208,84 @@ def test_denied_key_update_preserves_saved_grants_and_serving(gateway: Gateway)
)
assert rejected.status_code == 403, rejected.text
assert rejected.json()["error"]["type"] == "key_model_access_denied"
@pytest.mark.covers("mgmt.key.update.project_detach_denied_to_restricted_actor")
def test_restricted_actor_cannot_detach_key_from_project(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
model: Final = scenario.model()
team: Final = scenario.team(models=[model], team_member_permissions=["/key/update"])
project: Final = scenario.project(team, models=[model])
member: Final = scenario.user(user_role="internal_user")
gateway.post(
"/team/member_add",
{"team_id": team, "member": {"user_id": member, "role": "user"}},
)
target: Final = scenario.key(user_id=member, team_id=team, project_id=project, models=[model])
caller: Final = scenario.key(
user_id=member,
team_id=team,
models=[model],
allowed_routes=["/key/update"],
)
digest: Final = sha256(target.encode()).hexdigest()
before: Final = _key_rows(digest)
assert len(before) == 1
assert before[0]["project_id"] == project
assert before[0]["team_id"] == team
denied: Final = gateway.request(
"POST", "/key/update", {"key": target, "project_id": None}, key=caller
)
assert denied.status_code == 403, denied.text
assert _key_rows(digest) == before
@pytest.mark.covers(
"mgmt.key.info.cross_tenant_key_is_denied",
"mgmt.key.update.cross_tenant_key_is_denied",
"mgmt.key.update.cross_tenant_project_detach_is_denied",
)
def test_cross_tenant_actor_cannot_read_update_or_detach_project_key(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
model: Final = scenario.model()
team: Final = scenario.team(models=[model])
foreign_team: Final = scenario.team(models=[model])
project: Final = scenario.project(team, models=[model])
foreign_user: Final = scenario.user(user_role="internal_user")
gateway.post(
"/team/member_add",
{"team_id": foreign_team, "member": {"user_id": foreign_user, "role": "user"}},
)
target: Final = scenario.key(team_id=team, project_id=project, models=[model])
caller: Final = scenario.key(
user_id=foreign_user,
team_id=foreign_team,
models=[model],
allowed_routes=["/key/info", "/key/update"],
)
digest: Final = sha256(target.encode()).hexdigest()
before: Final = _key_rows(digest)
assert len(before) == 1
assert before[0]["project_id"] == project
assert before[0]["team_id"] == team
info_denied: Final = gateway.request(
"GET", "/key/info", params={"key": digest}, key=caller
)
assert info_denied.status_code == 403, info_denied.text
assert target not in info_denied.text
assert digest not in info_denied.text
assert project not in info_denied.text
assert team not in info_denied.text
update_denied: Final = gateway.request(
"POST", "/key/update", {"key": target, "key_alias": "foreign-update"}, key=caller
)
assert update_denied.status_code == 401, update_denied.text
detach_denied: Final = gateway.request(
"POST", "/key/update", {"key": target, "project_id": None}, key=caller
)
assert detach_denied.status_code == 401, detach_denied.text
for response in (update_denied, detach_denied):
assert target not in response.text
assert digest not in response.text
assert project not in response.text
assert _key_rows(digest) == before

View file

@ -0,0 +1,115 @@
from hashlib import sha256
from typing import Final
import pytest
from integration._support.client import Gateway, object_value, string_value
from integration._support.database import read_rows
from pydantic import JsonValue
def _project_rows(project_id: str) -> list[dict[str, JsonValue]]:
return read_rows(
'SELECT p.project_id, p.project_alias, p.description, p.team_id, p.models, p.blocked, '
'p.budget_id, b.max_budget FROM "LiteLLM_ProjectTable" AS p '
'LEFT JOIN "LiteLLM_BudgetTable" AS b ON b.budget_id = p.budget_id '
'WHERE p.project_id = %s',
(project_id,),
)
@pytest.mark.covers("mgmt.project.new.real_route_persists")
def test_project_new_persists_real_state(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
model: Final = scenario.model()
team: Final = scenario.team(models=[model])
budget: Final = scenario.budget(max_budget=7)
project: Final = scenario.project(
team, project_alias="new-project", budget_id=budget, models=[model], description="new project"
)
key: Final = scenario.key(team_id=team, project_id=project, models=[model])
assert object_value(gateway.chat(model, key=key)["usage"])["total_tokens"] == 40
rows: Final = _project_rows(project)
assert rows != []
assert len(rows) == 1
row: Final = rows[0]
assert row["project_id"] == project
assert row["project_alias"] == "new-project"
assert row["team_id"] == team
assert row["description"] == "new project"
assert row["models"] == [model]
assert row["budget_id"] == budget
assert row["blocked"] is False
assert row["max_budget"] == 7.0
@pytest.mark.covers("mgmt.project.update.real_route_persists")
def test_project_update_persists_real_state(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
model: Final = scenario.model()
team: Final = scenario.team(models=[model])
budget: Final = scenario.budget(max_budget=3)
project: Final = scenario.project(team, budget_id=budget, models=[model], description="before")
key: Final = scenario.key(team_id=team, project_id=project, models=[model])
updated: Final = gateway.post(
"/project/update",
{
"project_id": project,
"project_alias": "updated-project",
"description": "after",
"max_budget": 9,
"blocked": True,
},
)
assert string_value(updated["project_id"]) == project
rows: Final = _project_rows(project)
assert rows != []
assert len(rows) == 1
row: Final = rows[0]
assert row["project_alias"] == "updated-project"
assert row["description"] == "after"
assert row["team_id"] == team
assert row["models"] == [model]
assert row["budget_id"] == budget
assert row["blocked"] is True
assert row["max_budget"] == 9.0
blocked: Final = gateway.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": "blocked project"}]},
key=key,
)
assert blocked.status_code == 401, blocked.text
assert object_value(blocked.json()["error"])["type"] == "auth_error"
gateway.post("/project/update", {"project_id": project, "blocked": False})
assert object_value(gateway.chat(model, key=key)["usage"])["total_tokens"] == 40
@pytest.mark.covers("mgmt.project.delete.attached_key_refusal_preserves_state")
def test_project_delete_with_attached_key_refuses_and_preserves_state(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
model: Final = scenario.model()
team: Final = scenario.team(models=[model])
budget: Final = scenario.budget()
project: Final = scenario.project(
team, budget_id=budget, project_alias="delete-project", models=[model]
)
key: Final = scenario.key(team_id=team, project_id=project, models=[model])
digest: Final = sha256(key.encode()).hexdigest()
project_before: Final = _project_rows(project)
key_before: Final = read_rows(
'SELECT token, key_alias, models, metadata, max_budget, team_id, project_id, budget_id '
'FROM "LiteLLM_VerificationToken" WHERE token = %s',
(digest,),
)
assert len(project_before) == 1
assert len(key_before) == 1
assert key_before[0]["project_id"] == project
assert key_before[0]["team_id"] == team
denied: Final = gateway.request("DELETE", "/project/delete", {"project_ids": [project]})
assert denied.status_code == 400, denied.text
assert _project_rows(project) == project_before
assert read_rows(
'SELECT token, key_alias, models, metadata, max_budget, team_id, project_id, budget_id '
'FROM "LiteLLM_VerificationToken" WHERE token = %s',
(digest,),
) == key_before