mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-10 09:04:10 +00:00
f62ae93e13
* test(proxy): add create_scratch_actor harness helper
Adds create_scratch_actor() to the management behavior-suite conftest and
extends create_scratch_team() with team_member_permissions / models kwargs,
needed by the PR3 team-key-permission and team-model matrices. The new
helper mints a scratch-prefixed user + verification token (+ org
memberships), all reclaimed by the existing scratch-prefix teardown.
* test(proxy): pin /key block, unblock, health, aliases behavior
Adds behavior-pinning matrices for POST /key/block, POST /key/unblock,
POST /key/health, and GET /key/aliases. Pins that the management-route gate
401s ORG_ADMIN-role callers before _check_key_admin_access runs, the
block/unblock round-trip on the blocked column, missing-key 404, and the
_apply_non_admin_alias_scope visibility rules for /key/aliases.
* test(proxy): pin /key/bulk_update + /team/key/bulk_update behavior
Adds behavior-pinning matrices for POST /key/bulk_update (PROXY_ADMIN-only;
ORG_ADMIN stopped 401 at the route gate, INTERNAL_USER-role 403 at the
handler) and POST /team/key/bulk_update (team-member-permission gate keyed
on KEY_UPDATE). Pins batch semantics: empty/over-cap 400, per-key failure
isolation into failed_updates, all_keys_in_team broadcast, and no-keys 404.
Adds an optional key_alias arg to create_scratch_key for multi-key scenarios.
* test(proxy): pin /key SA-generate, v2-info, reset-spend behavior
Adds behavior-pinning matrices for POST /key/service-account/generate
(team-membership + team-member-permission gating; SA keys carry no user_id),
POST /v2/key/info (per-key _can_user_query_key_info silently drops invisible
keys), and POST /key/{key}/reset_spend (PROXY_ADMIN or team admin only;
missing key 404, reset-value 400). Pins that ORG_ADMIN-role callers are
stopped 401 at the management-route gate on the two non-info routes.
* test(proxy): close PR1/PR2 key-side deferred coverage gaps
Closes the four key-side gaps deferred from PR1/PR2:
- 404 on missing key for /key/update and /key/delete (not 401/403)
- denied /key/update leaves max_budget/tpm_limit/rpm_limit untouched
- /key/regenerate enforces litellm.upperbound_key_generate_params (#26340)
- /key/list key_alias substring vs exact (admin-only) + team_id filter,
and a non-admin filtering a foreign team is 403
* test(proxy): pin /team block, unblock, available, filter/ui, members/me
Adds behavior-pinning matrices for POST /team/block + /team/unblock
(management-route gate fronts _verify_team_access; reachable only by
PROXY_ADMIN and an org admin of the team's own org), GET /team/available
(default empty path), GET /team/filter/ui (route-gated PROXY-ADMIN-only
despite the handler having no gate), and GET /team/{team_id}/members/me
(caller resolves its own membership; non-member 404, no-user_id key 400).
* test(proxy): pin /team model add/delete + permissions endpoints
Adds behavior-pinning matrices for POST /team/model/add + /team/model/delete
(route-gated PROXY-ADMIN-only; missing team 404), GET /team/permissions_list +
POST /team/permissions_update (self-managed; proxy/team/org admin pass), and
POST /team/permissions_bulk_update (PROXY_ADMIN-only). Pins the deliberate
divergence that the available-team self-join grants read access via
permissions_list but never write access via permissions_update.
* test(proxy): pin /team delete, bulk_member_add, v2/list, daily/activity
Adds behavior-pinning matrices for POST /team/delete (per-team
_verify_team_access; batch aborts whole on a missing id), POST
/team/bulk_member_add (route-gated PROXY-ADMIN-only; empty/over-cap 400),
GET /v2/team/list (_enforce_list_team_v2_access — bare query 401s regular
users, org-scoped for org admins) and GET /team/daily/activity (non-member
team_ids filter 404, the VERIA-43 fix).
* test(proxy): add route-coverage gate + close team org-relocation gap
Adds test_route_coverage.py (PR3.M1): parses every @router route literal
from the two management-endpoint source files and asserts each is exercised
by >=1 behavior-suite scenario — a permanent regression guard for future
routes. Closes the last PR1/PR2 deferred gap: the /team/update org-relocation
allowed branch, exercised by a dual-org-admin minted via create_scratch_actor.
test_team_model uses literal route URLs so the coverage parser resolves them.
* test(proxy): bound plain route params to one path segment in coverage gate
Plain path params ({team_id}) now compile to [^/?]+ instead of [^?]+, so a
parameter cannot span '/'. Starlette ':path' params still match across '/'.
Keeps the route-coverage guard from falsely reporting a future multi-segment
route as covered. All 37 routes remain covered.
282 lines
9.6 KiB
Python
282 lines
9.6 KiB
Python
"""Session-scoped async ASGI client for HTTP-boundary behavior tests."""
|
|
|
|
import os
|
|
import tempfile
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from typing import Any, AsyncIterator, Dict, Optional
|
|
|
|
import httpx
|
|
import pytest_asyncio
|
|
import yaml
|
|
from prisma import Json
|
|
|
|
from litellm.proxy.utils import hash_token
|
|
|
|
MASTER_KEY = "sk-1234"
|
|
SCRATCH_PREFIX = "scratch-"
|
|
|
|
|
|
def _write_minimal_proxy_config() -> str:
|
|
config = {
|
|
"general_settings": {"master_key": MASTER_KEY},
|
|
"litellm_settings": {},
|
|
}
|
|
database_url = os.environ.get("DATABASE_URL")
|
|
if database_url:
|
|
config["general_settings"]["database_url"] = database_url
|
|
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False)
|
|
yaml.dump(config, f)
|
|
f.close()
|
|
return f.name
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="session")
|
|
async def proxy_app():
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.proxy_server import (
|
|
app,
|
|
cleanup_router_config_variables,
|
|
initialize,
|
|
proxy_startup_event,
|
|
)
|
|
|
|
cleanup_router_config_variables()
|
|
config_path = _write_minimal_proxy_config()
|
|
|
|
# proxy_startup_event re-reads master_key from LITELLM_MASTER_KEY and
|
|
# unconditionally overwrites the global, even when initialize() already
|
|
# set it from the config YAML. Force (not setdefault) both vars: an
|
|
# ambient LITELLM_MASTER_KEY with a different value would make the proxy
|
|
# authenticate on that key while the tests still send MASTER_KEY.
|
|
os.environ["LITELLM_MASTER_KEY"] = MASTER_KEY
|
|
os.environ["CONFIG_FILE_PATH"] = config_path
|
|
|
|
await initialize(config=config_path)
|
|
|
|
# /key/regenerate is gated behind premium_user; flipping it lets the matrix
|
|
# pin authz behavior instead of the licensing gate.
|
|
proxy_server.premium_user = True
|
|
|
|
async with proxy_startup_event(app):
|
|
proxy_server.premium_user = True # lifespan re-runs _license_check
|
|
# The lifespan fires check_view_exists() as a background task; on a
|
|
# fresh DB the first auth call races it and resolves user_id=None.
|
|
if proxy_server.prisma_client is not None:
|
|
await proxy_server.prisma_client.check_view_exists()
|
|
yield app
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="session")
|
|
async def proxy_client(proxy_app) -> AsyncIterator[httpx.AsyncClient]:
|
|
transport = httpx.ASGITransport(app=proxy_app)
|
|
async with httpx.AsyncClient(
|
|
transport=transport, base_url="http://testserver"
|
|
) as client:
|
|
yield client
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="session")
|
|
async def prisma(proxy_app):
|
|
from litellm.proxy import proxy_server
|
|
|
|
assert proxy_server.prisma_client is not None
|
|
return proxy_server.prisma_client
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="session")
|
|
async def world(prisma):
|
|
from .actors import seed_world
|
|
|
|
return await seed_world(prisma)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Scratch:
|
|
prefix: str
|
|
|
|
def tag(self, suffix: str = "") -> str:
|
|
return f"{self.prefix}-{suffix}" if suffix else self.prefix
|
|
|
|
|
|
async def create_scratch_key(
|
|
proxy_client,
|
|
seeder_cleartext: str,
|
|
scratch_prefix: str,
|
|
*,
|
|
user_id: str,
|
|
team_id: Optional[str] = None,
|
|
organization_id: Optional[str] = None,
|
|
key_alias: Optional[str] = None,
|
|
) -> str:
|
|
"""Seed a scratch-tagged key via /key/generate; returns its cleartext.
|
|
|
|
Shared by the write-scenario matrices (key update/regenerate/delete).
|
|
key_alias defaults to scratch_prefix; pass a distinct scratch-prefixed
|
|
alias when a single scenario needs more than one key (/key/generate
|
|
enforces unique aliases).
|
|
"""
|
|
body: Dict[str, Any] = {
|
|
"key_alias": key_alias or scratch_prefix,
|
|
"user_id": user_id,
|
|
}
|
|
if team_id is not None:
|
|
body["team_id"] = team_id
|
|
if organization_id is not None:
|
|
body["organization_id"] = organization_id
|
|
resp = await proxy_client.post(
|
|
"/key/generate",
|
|
headers={"Authorization": f"Bearer {seeder_cleartext}"},
|
|
json=body,
|
|
)
|
|
assert resp.status_code == 200, f"setup failed: {resp.text}"
|
|
return resp.json()["key"]
|
|
|
|
|
|
async def create_scratch_team(
|
|
prisma,
|
|
team_id: str,
|
|
*,
|
|
organization_id: Optional[str] = None,
|
|
admin_user_ids: Optional[list] = None,
|
|
member_user_ids: Optional[list] = None,
|
|
team_member_permissions: Optional[list] = None,
|
|
models: Optional[list] = None,
|
|
) -> str:
|
|
"""Raw-seed a scratch-tagged team row; returns its team_id.
|
|
|
|
The target team for the team write matrices (update / member_*). Raw
|
|
prisma (not POST /team/new) avoids creation side effects — no creator
|
|
auto-add, no membership rows written onto the world's users — so seeding
|
|
never mutates the immutable read-world. The authz gates read the team's
|
|
members_with_roles JSON, so a raw-seeded team exercises them exactly as
|
|
a /team/new-created team would. team_id must start with the scratch
|
|
prefix so the `scratch` fixture reclaims the row.
|
|
|
|
team_member_permissions / models seed the matching raw columns — needed
|
|
by the team-key-permission and team-model matrices.
|
|
"""
|
|
admin_user_ids = list(admin_user_ids or [])
|
|
member_user_ids = list(member_user_ids or [])
|
|
members_with_roles = [
|
|
{"user_id": uid, "role": "admin"} for uid in admin_user_ids
|
|
] + [{"user_id": uid, "role": "user"} for uid in member_user_ids]
|
|
data: Dict[str, Any] = {
|
|
"team_id": team_id,
|
|
"team_alias": team_id,
|
|
"admins": admin_user_ids,
|
|
"members": admin_user_ids + member_user_ids,
|
|
"members_with_roles": Json(members_with_roles),
|
|
}
|
|
if organization_id is not None:
|
|
data["organization_id"] = organization_id
|
|
if team_member_permissions is not None:
|
|
data["team_member_permissions"] = team_member_permissions
|
|
if models is not None:
|
|
data["models"] = models
|
|
await prisma.db.litellm_teamtable.create(data=data)
|
|
return team_id
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SeededActor:
|
|
user_id: str
|
|
cleartext: str
|
|
hashed: str
|
|
|
|
|
|
async def create_scratch_actor(
|
|
prisma,
|
|
scratch_prefix: str,
|
|
*,
|
|
user_role: str,
|
|
org_admin_of: tuple = (),
|
|
organization_id: Optional[str] = None,
|
|
suffix: str = "actor",
|
|
) -> SeededActor:
|
|
"""Mint a scratch-prefixed user + verification token (+ org memberships).
|
|
|
|
Reclaimed by the existing `scratch` teardown, which sweeps
|
|
litellm_usertable, litellm_verificationtoken, and
|
|
litellm_organizationmembership by scratch prefix — no bespoke cleanup
|
|
needed. Does NOT write litellm_teammembership against world teams: the
|
|
teardown reclaims that table only by team_id prefix, so a scratch actor
|
|
needing team membership must join a scratch team instead. The cleartext
|
|
is hashed with the real hash_token so the key authenticates end-to-end;
|
|
models=[] satisfies LiteLLM_VerificationTokenView.
|
|
"""
|
|
user_id = f"{scratch_prefix}-{suffix}"
|
|
cleartext = "sk-" + uuid.uuid4().hex
|
|
hashed = hash_token(cleartext)
|
|
await prisma.db.litellm_usertable.create(
|
|
data={
|
|
"user_id": user_id,
|
|
"user_role": user_role,
|
|
"organization_id": organization_id,
|
|
}
|
|
)
|
|
token_data: Dict[str, Any] = {
|
|
"token": hashed,
|
|
"key_name": f"{scratch_prefix}-{suffix}-key",
|
|
"key_alias": f"{scratch_prefix}-{suffix}-alias",
|
|
"user_id": user_id,
|
|
"models": [],
|
|
}
|
|
if organization_id is not None:
|
|
token_data["organization_id"] = organization_id
|
|
await prisma.db.litellm_verificationtoken.create(data=token_data)
|
|
for org_id in org_admin_of:
|
|
await prisma.db.litellm_organizationmembership.create(
|
|
data={
|
|
"user_id": user_id,
|
|
"organization_id": org_id,
|
|
"user_role": "org_admin",
|
|
}
|
|
)
|
|
return SeededActor(user_id=user_id, cleartext=cleartext, hashed=hashed)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def scratch(prisma):
|
|
handle = Scratch(prefix=f"{SCRATCH_PREFIX}{uuid.uuid4().hex[:12]}")
|
|
try:
|
|
yield handle
|
|
finally:
|
|
# Children before parents to avoid FK violations.
|
|
await prisma.db.litellm_verificationtoken.delete_many(
|
|
where={
|
|
"OR": [
|
|
{"key_alias": {"startswith": handle.prefix}},
|
|
{"key_name": {"startswith": handle.prefix}},
|
|
]
|
|
}
|
|
)
|
|
await prisma.db.litellm_teammembership.delete_many(
|
|
where={"team_id": {"startswith": handle.prefix}}
|
|
)
|
|
await prisma.db.litellm_organizationmembership.delete_many(
|
|
where={"user_id": {"startswith": handle.prefix}}
|
|
)
|
|
await prisma.db.litellm_teamtable.delete_many(
|
|
where={"team_id": {"startswith": handle.prefix}}
|
|
)
|
|
await prisma.db.litellm_usertable.delete_many(
|
|
where={"user_id": {"startswith": handle.prefix}}
|
|
)
|
|
await prisma.db.litellm_budgettable.delete_many(
|
|
where={"budget_id": {"startswith": handle.prefix}}
|
|
)
|
|
# /team/member_add writes LiteLLM_UserTable.teams; the available-team
|
|
# self-join writes it on a world actor whose row must survive. Strip
|
|
# dangling scratch-team refs so the read-world stays immutable.
|
|
polluted = await prisma.db.litellm_usertable.find_many(
|
|
where={"teams": {"isEmpty": False}}
|
|
)
|
|
for user in polluted:
|
|
cleaned = [t for t in user.teams if not t.startswith(handle.prefix)]
|
|
if cleaned != list(user.teams):
|
|
await prisma.db.litellm_usertable.update(
|
|
where={"user_id": user.user_id},
|
|
data={"teams": {"set": cleaned}},
|
|
)
|