test(proxy_behavior): address Greptile review — env force, pagination, dedup

- conftest: force LITELLM_MASTER_KEY / CONFIG_FILE_PATH unconditionally
  instead of setdefault. An ambient LITELLM_MASTER_KEY with a different
  value would make the proxy authenticate on that key while the tests
  still send MASTER_KEY → silent 401s.
- test_key_list: paginate /key/list instead of a single size=100 request.
  size is capped at 100 by the endpoint, so on a non-fresh DB a single
  page could truncate PROXY_ADMIN's view and a seeded key could fall off
  the page. Walk total_pages.
- conftest: hoist the duplicated _create_scratch_key helper (copy-pasted
  and already diverged across test_key_{update,regenerate,delete}.py)
  into a single shared create_scratch_key.
- Delete regression_replay/README.md — G4 regression-replay evidence
  belongs in the PR description, not a committed doc file (repo docs
  policy + the effort's own plan both say so). Content moved to the PR.
This commit is contained in:
Yuneng Jiang 2026-05-20 19:01:27 -07:00
parent cc00ad9af1
commit 016b7c6e09
No known key found for this signature in database
6 changed files with 68 additions and 198 deletions

View file

@ -4,7 +4,7 @@ import os
import tempfile
import uuid
from dataclasses import dataclass
from typing import AsyncIterator
from typing import Any, AsyncIterator, Dict, Optional
import httpx
import pytest_asyncio
@ -44,10 +44,11 @@ async def proxy_app():
# 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. Without these env vars, the entire auth
# stack degrades to user_id=None / non-PROXY_ADMIN for every token.
os.environ.setdefault("LITELLM_MASTER_KEY", MASTER_KEY)
os.environ.setdefault("CONFIG_FILE_PATH", config_path)
# 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)
@ -96,6 +97,33 @@ class Scratch:
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,
) -> str:
"""Seed a scratch-tagged key via /key/generate; returns its cleartext.
Shared by the write-scenario matrices (key update/regenerate/delete).
"""
body: Dict[str, Any] = {"key_alias": 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"]
@pytest_asyncio.fixture
async def scratch(prisma):
handle = Scratch(prefix=f"{SCRATCH_PREFIX}{uuid.uuid4().hex[:12]}")

View file

@ -1,104 +0,0 @@
# G4 — Regression-replay set for PR1 (Key Tier-1)
This directory documents the regression-replay verification for the
behavior-pinning suite. For each in-scope recent fix-PR touching
`litellm/proxy/management_endpoints/key_management_endpoints.py`, we record:
- which scenario(s) in the matrix catch the behavior the fix introduced,
- a RED-at-parent / GREEN-at-tip transcript for at least one canonical PR.
## Methodology
For a given fix-PR `<sha>` with parent `<parent>`:
```bash
# 1. Save current handler
cp litellm/proxy/management_endpoints/key_management_endpoints.py /tmp/key_mgmt.HEAD.py
# 2. Replace with the pre-fix version
git show <parent>:litellm/proxy/management_endpoints/key_management_endpoints.py \
> litellm/proxy/management_endpoints/key_management_endpoints.py
# 3. Run the catching slice — expect RED on the scenarios that flip
DATABASE_URL=postgresql://litellm:litellm@localhost:5432/litellm_test \
uv run --no-sync pytest tests/proxy_behavior/management/<test_file>.py -v
# 4. Restore + confirm GREEN
cp /tmp/key_mgmt.HEAD.py litellm/proxy/management_endpoints/key_management_endpoints.py
DATABASE_URL=postgresql://litellm:litellm@localhost:5432/litellm_test \
uv run --no-sync pytest tests/proxy_behavior/management/<test_file>.py -v
```
When the fix changed multiple files, the in-place swap is constrained to the
handler module — if a referenced helper moved between modules the swap may
not run; in that case use `git worktree add` for a clean replay.
## Replay table
| # | Fix SHA | Subject (truncated) | Endpoint | Catching scenarios | Verified |
|---|---------|---------------------|----------|--------------------|----------|
| 1 | `c7c3df2b02` | extend /key/update admin check to non-budget fields | `/key/update` | `test_key_update.py::self/{team_admin,internal_user,owner,unrelated_same_org,cross_org_user,service_account}` — all 6 flip 200→403 between parent and HEAD | ✅ RED→GREEN below |
| 2 | `8bbc61e03c` | harden /key/update authorization checks | `/key/update` | `test_key_update.py::owner_target/*` — non-admins blocked from updating peers' keys | by-inspection (overlapping coverage with #1) |
| 3 | `1b2756811e` | close project hijacking and key org IDOR | `/key/update` (org_id field) | `test_key_update.py` matrix asserts no row mutation on denied responses (`row.models != [MARKER]`) | by-inspection |
| 4 | `c7c3df2b02` siblings: `f6cd0a827a` | /key/update returns 404 (not 401) for nonexistent body key | `/key/update` | NOT covered by current matrix — the matrix only exercises existing target keys. Future: add 404-on-missing scenarios. | gap (filed below) |
| 5 | `133471f882` | double-counting bug in org/team key limit checks on update | `/key/update` (counting) | NOT directly covered; matrix asserts status only, not counts. Future: budget/limit assertions. | gap (filed below) |
| 6 | `574633fcf1` | exclude budget_limits from deleted verification token | `/key/delete` | `test_key_delete.py` matrix verifies post-delete authentication fails — would catch a shape-of-delete change but not a budget_limits-specific bug | by-inspection (partial) |
| 7 | `db8ef44323` | enforce upperbound_key_generate_params on /key/regenerate | `/key/regenerate` | `test_key_regenerate.py` matrix asserts status; a regen that exceeds upperbound limits would surface IF the test bodies passed disallowed params. Currently they don't. | gap (filed below) |
| 8 | `2220f3076a` | tighten caller-permission checks on key route fields | multiple | spans /key/generate + /key/update; partial overlap with our `team_id`/`user_id` boundary scenarios | by-inspection |
| 9 | `5190bd07eb` | extend caller-permission to service-account | /key/generate, /key/service-account/generate | service_account actor IS in our matrix; some sub-scenarios overlap | by-inspection |
| 10 | `12005c4a02` | /key/aliases auth | `/key/aliases` | not Tier-1 (out of PR1 scope; PR3 territory) | out of scope |
| 11 | `818c097ca9` | Self-exclusion hash mismatch | `/key/update` | partial overlap | by-inspection |
| 12 | `daf7c0c3a8` | virtual keys team filter | `/key/list` | `test_key_list.py` filter param scenarios deferred; default-visibility scenarios already in matrix | gap (deferred) |
## Verified replay: `c7c3df2b02`
**Fix subject**: `fix(proxy): extend /key/update admin check to non-budget fields`
**Parent SHA**: `662d05531d`
**Catching slice**: `tests/proxy_behavior/management/test_key_update.py`
### Parent (pre-fix) — RED
```
FAILED tests/proxy_behavior/management/test_key_update.py::test_key_update_authz_matrix[self/team_admin]
FAILED tests/proxy_behavior/management/test_key_update.py::test_key_update_authz_matrix[self/internal_user]
FAILED tests/proxy_behavior/management/test_key_update.py::test_key_update_authz_matrix[self/owner]
FAILED tests/proxy_behavior/management/test_key_update.py::test_key_update_authz_matrix[self/unrelated_same_org]
FAILED tests/proxy_behavior/management/test_key_update.py::test_key_update_authz_matrix[self/cross_org_user]
FAILED tests/proxy_behavior/management/test_key_update.py::test_key_update_authz_matrix[self/service_account]
6 failed, 15 passed
```
Each of the 6 self-target scenarios returned `200` under the parent code (the
admin check only gated budget/spend changes, so a non-admin could rewrite
`models`) but is pinned at `403` post-fix.
### HEAD (post-fix) — GREEN
```
21 passed
```
The matrix correctly flipped from 6/21 RED to 21/21 GREEN solely on the
handler swap — confirming the suite catches the c7c3df2b02 behavior change.
## Identified coverage gaps (deferred to PR2/PR3)
These cells in the replay table call out genuine matrix gaps — the current
PR1 surface does not pin the specific behavior the fix introduced. Each is
worth a follow-up scenario:
- **404-on-missing-key** (rows 4, paralleling `f6cd0a827a` and
`19efe556cb`) — add an explicit "actor calls /key/update with a body
`key` that does not exist" scenario per endpoint, asserting 404.
- **Budget/limit counting bugs** (row 5, `133471f882`) — add scenarios that
read the team/org spend rows after a denied /key/update and assert no
counter movement.
- **Upperbound enforcement on /key/regenerate** (row 7, `db8ef44323`) —
extend the regenerate matrix with at least one scenario that requests
params exceeding the team's `upperbound_key_generate_params`.
- **`/key/list` filter-param view** (row 12, `daf7c0c3a8`) — PR1 only
pins default-visibility. Filter combinations (`team_id=`,
`include_team_keys=true`, etc.) belong in a follow-up.
Filed as TODOs rather than blocking PR1: the matrix shape is correct,
these are scope extensions.

View file

@ -1,10 +1,9 @@
from typing import Any, Dict, Optional
import pytest
from litellm.proxy.utils import hash_token
from .actors import TEAM_ALPHA, TEAM_BETA, Actor
from .conftest import create_scratch_key
pytestmark = pytest.mark.asyncio(loop_scope="session")
@ -36,26 +35,6 @@ _SCENARIOS = [
]
async def _create_scratch_key(
proxy_client,
seeder_cleartext: str,
scratch_prefix: str,
*,
user_id: str,
team_id: Optional[str] = None,
) -> str:
body: Dict[str, Any] = {"key_alias": scratch_prefix, "user_id": user_id}
if team_id is not None:
body["team_id"] = team_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"]
@pytest.mark.parametrize(
"actor,target_shape,expected_status",
[(a, t, s) for (_id, a, t, s) in _SCENARIOS],
@ -74,11 +53,11 @@ async def test_key_delete_authz_matrix(
seeder = world.keys[Actor.PROXY_ADMIN].cleartext
if target_shape == "self":
target_cleartext = await _create_scratch_key(
target_cleartext = await create_scratch_key(
proxy_client, seeder, scratch.prefix, user_id=caller.user_id
)
elif target_shape == "owner":
target_cleartext = await _create_scratch_key(
target_cleartext = await create_scratch_key(
proxy_client,
seeder,
scratch.prefix,
@ -86,7 +65,7 @@ async def test_key_delete_authz_matrix(
team_id=TEAM_ALPHA,
)
elif target_shape == "cross_org":
target_cleartext = await _create_scratch_key(
target_cleartext = await create_scratch_key(
proxy_client,
seeder,
scratch.prefix,

View file

@ -21,6 +21,27 @@ _VISIBILITY = {
}
async def _all_visible_hashes(proxy_client, caller_cleartext) -> set:
"""Walk every /key/list page — size is capped at 100 by the endpoint, so a
single request can truncate PROXY_ADMIN's view on a non-fresh DB."""
hashes: set = set()
page = 1
while True:
resp = await proxy_client.get(
f"/key/list?page={page}&size=100",
headers={"Authorization": f"Bearer {caller_cleartext}"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
for entry in body.get("keys", []):
tok = entry.get("token") if isinstance(entry, dict) else entry
if tok:
hashes.add(tok)
if page >= (body.get("total_pages") or 1):
return hashes
page += 1
@pytest.mark.parametrize(
"actor,expected_visible",
list(_VISIBILITY.items()),
@ -32,16 +53,7 @@ async def test_key_list_visibility(
caller = world.keys[actor]
hashed_to_actor = {world.keys[a].hashed: a for a in Actor}
resp = await proxy_client.get(
"/key/list?size=100",
headers={"Authorization": f"Bearer {caller.cleartext}"},
)
assert resp.status_code == 200, f"{actor.value}: {resp.text}"
returned_hashes = {
(entry.get("token") if isinstance(entry, dict) else entry)
for entry in resp.json().get("keys", [])
}
returned_hashes = await _all_visible_hashes(proxy_client, caller.cleartext)
visible_seeded = {
hashed_to_actor[h] for h in returned_hashes if h in hashed_to_actor
}

View file

@ -1,8 +1,7 @@
from typing import Any, Dict, Optional
import pytest
from .actors import TEAM_ALPHA, TEAM_BETA, Actor
from .conftest import create_scratch_key
pytestmark = pytest.mark.asyncio(loop_scope="session")
@ -35,26 +34,6 @@ _SCENARIOS = [
]
async def _create_scratch_key(
proxy_client,
seeder_cleartext: str,
scratch_prefix: str,
*,
user_id: str,
team_id: Optional[str] = None,
) -> str:
body: Dict[str, Any] = {"key_alias": scratch_prefix, "user_id": user_id}
if team_id is not None:
body["team_id"] = team_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 _info(proxy_client, cleartext: str):
return await proxy_client.get(
"/key/info", headers={"Authorization": f"Bearer {cleartext}"}
@ -78,11 +57,11 @@ async def test_key_regenerate_authz_matrix(
seeder = world.keys[Actor.PROXY_ADMIN].cleartext
if target_shape == "self":
target_cleartext = await _create_scratch_key(
target_cleartext = await create_scratch_key(
proxy_client, seeder, scratch.prefix, user_id=caller.user_id
)
elif target_shape == "owner":
target_cleartext = await _create_scratch_key(
target_cleartext = await create_scratch_key(
proxy_client,
seeder,
scratch.prefix,
@ -90,7 +69,7 @@ async def test_key_regenerate_authz_matrix(
team_id=TEAM_ALPHA,
)
elif target_shape == "cross_org":
target_cleartext = await _create_scratch_key(
target_cleartext = await create_scratch_key(
proxy_client,
seeder,
scratch.prefix,
@ -122,7 +101,7 @@ async def test_key_regenerate_authz_matrix(
async def test_key_path_regenerate_smoke(proxy_client, scratch, world):
"""Pins that POST /key/{key:path}/regenerate shares the same handler."""
caller = world.keys[Actor.PROXY_ADMIN]
target_cleartext = await _create_scratch_key(
target_cleartext = await create_scratch_key(
proxy_client, caller.cleartext, scratch.prefix, user_id=caller.user_id
)

View file

@ -1,10 +1,9 @@
from typing import Any, Dict, Optional
import pytest
from litellm.proxy.utils import hash_token
from .actors import TEAM_ALPHA, TEAM_BETA, Actor
from .conftest import create_scratch_key
pytestmark = pytest.mark.asyncio(loop_scope="session")
@ -40,29 +39,6 @@ _SCENARIOS = [
MARKER_MODEL = "behavior-pin-update-marker-model"
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,
) -> str:
body: Dict[str, Any] = {"key_alias": 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"]
@pytest.mark.parametrize(
"actor,target_shape,expected_status",
[(a, t, s) for (_id, a, t, s) in _SCENARIOS],
@ -81,11 +57,11 @@ async def test_key_update_authz_matrix(
seeder = world.keys[Actor.PROXY_ADMIN].cleartext
if target_shape == "self":
target_cleartext = await _create_scratch_key(
target_cleartext = await create_scratch_key(
proxy_client, seeder, scratch.prefix, user_id=caller.user_id
)
elif target_shape == "owner":
target_cleartext = await _create_scratch_key(
target_cleartext = await create_scratch_key(
proxy_client,
seeder,
scratch.prefix,
@ -93,7 +69,7 @@ async def test_key_update_authz_matrix(
team_id=TEAM_ALPHA,
)
elif target_shape == "cross_org":
target_cleartext = await _create_scratch_key(
target_cleartext = await create_scratch_key(
proxy_client,
seeder,
scratch.prefix,