test(management): cover project authorization lifecycle

This commit is contained in:
Yuneng Jiang 2026-09-16 23:08:00 -07:00
parent 5ef40a630b
commit 4210f586c2
No known key found for this signature in database
3 changed files with 210 additions and 0 deletions

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

@ -198,3 +198,91 @@ 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 = read_rows(
'SELECT project_id, team_id FROM "LiteLLM_VerificationToken" WHERE token = %s',
(digest,),
)
assert before != []
denied: Final = gateway.request(
"POST", "/key/update", {"key": target, "project_id": None}, key=caller
)
assert denied.status_code == 403, denied.text
assert read_rows(
'SELECT project_id, team_id FROM "LiteLLM_VerificationToken" WHERE token = %s',
(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 = read_rows(
'SELECT project_id, team_id FROM "LiteLLM_VerificationToken" WHERE token = %s',
(digest,),
)
assert before != []
info_denied: Final = gateway.request(
"GET", "/key/info", params={"key": digest}, key=caller
)
assert info_denied.status_code == 403, 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 digest not in response.text
assert project not in response.text
assert team in response.text
assert read_rows(
'SELECT project_id, team_id FROM "LiteLLM_VerificationToken" WHERE token = %s',
(digest,),
) == before

View file

@ -0,0 +1,105 @@
from hashlib import sha256
from typing import Final
import pytest
from pydantic import JsonValue
from integration._support.client import Gateway, string_value
from integration._support.database import read_rows
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.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])
project: Final = scenario.project(team, models=[model], description="new project", max_budget=7)
rows: Final = _project_rows(project)
assert rows != []
assert len(rows) == 1
row: Final = rows[0]
assert row["project_id"] == project
assert row["team_id"] == team
assert row["description"] == "new project"
assert row["models"] == [model]
assert row["budget_id"] is not None
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])
project: Final = scenario.project(team, models=[model], description="before", max_budget=3)
updated: Final = gateway.post(
"/project/update",
{
"project_id": project,
"project_alias": "updated-project",
"description": "after",
"max_budget": 9,
},
)
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["max_budget"] == 9.0
@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])
created: Final = gateway.post(
"/project/new",
{"team_id": team, "project_alias": "delete-project", "models": [model]},
)
project: Final = string_value(created["project_id"])
created_key: Final = gateway.post(
"/key/generate",
{"team_id": team, "project_id": project, "models": [model]},
)
key: Final = string_value(created_key["key"])
digest: Final = sha256(key.encode()).hexdigest()
project_before: Final = _project_rows(project)
key_before: Final = read_rows(
'SELECT project_id, team_id FROM "LiteLLM_VerificationToken" WHERE token = %s',
(digest,),
)
assert project_before != []
assert key_before != []
try:
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 project_id, team_id FROM "LiteLLM_VerificationToken" WHERE token = %s',
(digest,),
) == key_before
finally:
if read_rows(
'SELECT token FROM "LiteLLM_VerificationToken" WHERE token = %s',
(digest,),
) != []:
gateway.post("/key/delete", {"keys": [key]})
if _project_rows(project) != []:
cleanup: Final = gateway.request("DELETE", "/project/delete", {"project_ids": [project]})
assert cleanup.status_code == 200, cleanup.text