mirror of
https://github.com/tiennm99/litellm.git
synced 2026-06-17 16:48:54 +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.
92 lines
3.4 KiB
Python
92 lines
3.4 KiB
Python
"""PR3.M1 — codified route coverage.
|
|
|
|
Every route declared in the two management-endpoint source files must be
|
|
exercised by at least one behavior-suite scenario. This is a permanent
|
|
regression guard: a future route added without a behavior test fails CI here,
|
|
the same way test_no_management_imports.py codifies the G3 import grep.
|
|
"""
|
|
|
|
import ast
|
|
import pathlib
|
|
import re
|
|
|
|
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
|
|
SOURCE_FILES = [
|
|
REPO_ROOT / "litellm/proxy/management_endpoints/key_management_endpoints.py",
|
|
REPO_ROOT / "litellm/proxy/management_endpoints/team_endpoints.py",
|
|
]
|
|
TEST_DIR = pathlib.Path(__file__).resolve().parent
|
|
SELF = pathlib.Path(__file__).resolve()
|
|
|
|
# Captures the route literal from `@router.<method>("<literal>"` — `\s*` spans
|
|
# newlines so multi-line decorators are matched too.
|
|
_ROUTE_DECORATOR = re.compile(
|
|
r"@router\.(?:get|post|put|delete|patch)\(\s*[\"']([^\"']+)[\"']"
|
|
)
|
|
|
|
|
|
def _source_routes() -> set:
|
|
routes: set = set()
|
|
for path in SOURCE_FILES:
|
|
routes.update(_ROUTE_DECORATOR.findall(path.read_text()))
|
|
return routes
|
|
|
|
|
|
def _route_to_regex(route: str) -> re.Pattern:
|
|
# A plain path param ({team_id}) matches a single path segment; a Starlette
|
|
# ':path' param ({key:path}) matches across '/'. Keeping plain params
|
|
# slash-bounded stops a loose regex from falsely reporting a future
|
|
# multi-segment route as already covered.
|
|
pattern = ["^"]
|
|
pos = 0
|
|
for match in re.finditer(r"\{([^}]+)\}", route):
|
|
pattern.append(re.escape(route[pos : match.start()]))
|
|
pattern.append("[^?]+" if match.group(1).endswith(":path") else "[^/?]+")
|
|
pos = match.end()
|
|
pattern.append(re.escape(route[pos:]) + "$")
|
|
return re.compile("".join(pattern))
|
|
|
|
|
|
def _test_urls() -> set:
|
|
"""Every request-URL string literal across the behavior test suite.
|
|
|
|
f-strings are reconstructed with each interpolation collapsed to a single
|
|
placeholder char, so f"/key/{target}/regenerate" becomes /key/X/regenerate.
|
|
Query strings are dropped — coverage is a path-level property.
|
|
"""
|
|
urls: set = set()
|
|
for path in sorted(TEST_DIR.glob("test_*.py")):
|
|
if path.resolve() == SELF:
|
|
continue
|
|
tree = ast.parse(path.read_text())
|
|
for node in ast.walk(tree):
|
|
literal = None
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
literal = node.value
|
|
elif isinstance(node, ast.JoinedStr):
|
|
chunks = []
|
|
for value in node.values:
|
|
if isinstance(value, ast.Constant) and isinstance(value.value, str):
|
|
chunks.append(value.value)
|
|
else:
|
|
chunks.append("X") # interpolated path / query segment
|
|
literal = "".join(chunks)
|
|
if literal and literal.startswith("/"):
|
|
urls.add(literal.split("?", 1)[0])
|
|
return urls
|
|
|
|
|
|
def test_every_management_route_has_a_behavior_scenario():
|
|
routes = _source_routes()
|
|
assert routes, "no @router routes parsed — the decorator regex is stale"
|
|
|
|
urls = _test_urls()
|
|
uncovered = sorted(
|
|
route
|
|
for route in routes
|
|
if not any(_route_to_regex(route).match(url) for url in urls)
|
|
)
|
|
assert (
|
|
not uncovered
|
|
), "management routes with no behavior-suite scenario:\n " + "\n ".join(uncovered)
|