test(management): close project lifecycle coverage gaps

This commit is contained in:
Yuneng Jiang 2026-09-16 23:43:25 -07:00
parent 4210f586c2
commit a40b6b3e44
No known key found for this signature in database
3 changed files with 82 additions and 59 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

@ -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")
@ -219,19 +229,15 @@ def test_restricted_actor_cannot_detach_key_from_project(gateway: Gateway) -> No
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 != []
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 read_rows(
'SELECT project_id, team_id FROM "LiteLLM_VerificationToken" WHERE token = %s',
(digest,),
) == before
assert _key_rows(digest) == before
@pytest.mark.covers(
@ -258,15 +264,15 @@ def test_cross_tenant_actor_cannot_read_update_or_detach_project_key(gateway: Ga
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 != []
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
@ -279,10 +285,8 @@ def test_cross_tenant_actor_cannot_read_update_or_detach_project_key(gateway: Ga
)
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 team in response.text
assert read_rows(
'SELECT project_id, team_id FROM "LiteLLM_VerificationToken" WHERE token = %s',
(digest,),
) == before
assert _key_rows(digest) == before

View file

@ -2,15 +2,14 @@ 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.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, '
'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',
@ -23,16 +22,23 @@ 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)
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"] is not None
assert row["budget_id"] == budget
assert row["blocked"] is False
assert row["max_budget"] == 7.0
@ -41,7 +47,9 @@ 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)
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",
{
@ -49,6 +57,7 @@ def test_project_update_persists_real_state(gateway: Gateway) -> None:
"project_alias": "updated-project",
"description": "after",
"max_budget": 9,
"blocked": True,
},
)
assert string_value(updated["project_id"]) == project
@ -60,7 +69,19 @@ def test_project_update_persists_real_state(gateway: Gateway) -> None:
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")
@ -68,38 +89,27 @@ def test_project_delete_with_attached_key_refuses_and_preserves_state(gateway: G
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]},
budget: Final = scenario.budget()
project: Final = scenario.project(
team, budget_id=budget, 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"])
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 project_id, team_id FROM "LiteLLM_VerificationToken" WHERE token = %s',
'SELECT token, key_alias, models, metadata, max_budget, team_id, project_id, budget_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
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