Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8af57a0
test(proxy_behavior): scaffold session-scoped async ASGI client + liv…
yuneng-berri May 20, 2026
d2d9539
test(proxy_behavior): connect prisma via real lifespan; key/generate …
yuneng-berri May 20, 2026
4c3879a
test(proxy_behavior): seed 8-actor read-world for the authz matrix
yuneng-berri May 20, 2026
12d5d7b
test(proxy_behavior): per-test scratch namespace + targeted delete_ma…
yuneng-berri May 20, 2026
6f588c7
test(proxy_behavior): codify G3 (strict-import grep) as a pytest item
yuneng-berri May 20, 2026
3ac9506
style(proxy_behavior): apply black to G3 grep test
yuneng-berri May 20, 2026
f7b881a
test(proxy_behavior): pin /key/generate authz matrix (18 scenarios)
yuneng-berri May 20, 2026
f5bd477
test(proxy_behavior): pin /key/info authz matrix (24 scenarios)
yuneng-berri May 20, 2026
1afe438
test(proxy_behavior): pin /key/list default-visibility matrix (8 scen…
yuneng-berri May 20, 2026
30bcdb4
test(proxy_behavior): pin /key/update authz matrix + mutation re-read…
yuneng-berri May 20, 2026
671e0bc
test(proxy_behavior): pin /key/regenerate authz matrix + rotation con…
yuneng-berri May 20, 2026
1013d72
test(proxy_behavior): pin /key/delete authz matrix + post-delete cont…
yuneng-berri May 20, 2026
f5b29dd
ci(proxy-mgmt-behavior): add PR-triggered workflow for the behavior s…
yuneng-berri May 20, 2026
cd34004
docs(proxy_behavior): G4 regression-replay table for Key Tier-1
yuneng-berri May 20, 2026
8753969
chore(mutmut): include the behavior suite in tests_dir + G5 triage stub
yuneng-berri May 20, 2026
f25173b
docs(proxy_behavior): suite README with local-repro + conventions + g…
yuneng-berri May 20, 2026
1e21cb6
ci(proxy-mgmt-behavior): drop xdist (workers=0) to fix seed race
yuneng-berri May 20, 2026
15c7a59
ci(proxy-mgmt-behavior): seed scratch keys via proxy_admin actor, not…
yuneng-berri May 20, 2026
f576d94
ci(proxy-mgmt-behavior): diag — run world-seed test first + bump max-…
yuneng-berri May 20, 2026
0c73928
ci(proxy-mgmt-behavior): await LiteLLM_VerificationTokenView creation…
yuneng-berri May 20, 2026
1c66f99
ci(proxy-mgmt-behavior): widen diagnostic to dump token / user / view…
yuneng-berri May 20, 2026
8816977
ci(proxy-mgmt-behavior): unambiguous diagnostic view query
yuneng-berri May 20, 2026
6d207ff
ci(proxy-mgmt-behavior): add auth-resolver chain diagnostic
yuneng-berri May 20, 2026
f37b763
ci(proxy-mgmt-behavior): set LITELLM_MASTER_KEY env so lifespan doesn…
yuneng-berri May 20, 2026
5f03819
ci(proxy-mgmt-behavior): force premium_user=True so /key/regenerate i…
yuneng-berri May 20, 2026
4c66635
test(proxy_behavior): trim debug diagnostics, restore default max-fai…
yuneng-berri May 20, 2026
2fd511c
chore(proxy_behavior): drop empty mutmut triage stub, fold protocol i…
yuneng-berri May 20, 2026
7a38fd3
chore(proxy_behavior): drop README + trim comments
yuneng-berri May 20, 2026
cc00ad9
Merge remote-tracking branch 'origin/litellm_internal_staging' into l…
yuneng-berri May 21, 2026
016b7c6
test(proxy_behavior): address Greptile review — env force, pagination…
yuneng-berri May 21, 2026
63295a5
Merge remote-tracking branch 'origin/litellm_internal_staging' into l…
yuneng-berri May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/test-unit-proxy-mgmt-behavior.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: "Unit Tests: Proxy Management-Endpoint Behavior Pinning"

on:
pull_request:
branches:
- main
- litellm_internal_staging
- litellm_oss_branch
- "litellm_**"

permissions:
contents: read
id-token: write
pull-requests: write

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
proxy-mgmt-behavior:
uses: ./.github/workflows/_test-unit-services-base.yml
with:
test-path: tests/proxy_behavior
# workers=0 (no xdist): the world seed is a single shared Postgres
# state — two xdist workers both call seed_world() and race on the
# ``behavior-pin-budget`` row, producing UniqueViolation + cascading
# missing-membership FK failures. The whole suite is ~7s sequentially,
# so the cost of disabling parallelism here is negligible.
workers: 0
reruns: 0
enable-postgres: true
artifact-name: proxy-mgmt-behavior
timeout-minutes: 15
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,12 @@ paths_to_mutate = [
]
tests_dir = [
"tests/test_litellm/proxy/management_endpoints/",
# PR1 (key Tier-1) behavior-pinning suite. Manual mutmut runs
# (.github/workflows/mutation-test.yml) include this directory so the
# behavior matrix contributes to mutation-score signal alongside the
# legacy mock suite. See tests/proxy_behavior/management/README.md
# for the G5 triage protocol.
"tests/proxy_behavior/management/",
]
also_copy = [
"litellm/",
Expand Down
Empty file.
Empty file.
257 changes: 257 additions & 0 deletions tests/proxy_behavior/management/actors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
"""8-actor read-world seed for the authz matrix tests."""

import enum
import uuid
from dataclasses import dataclass
from typing import Any, Dict

from prisma import Json

from litellm.proxy._types import LitellmUserRoles
from litellm.proxy.utils import PrismaClient, hash_token


class Actor(str, enum.Enum):
PROXY_ADMIN = "proxy_admin"
ORG_ADMIN = "org_admin"
TEAM_ADMIN = "team_admin"
INTERNAL_USER = "internal_user"
OWNER = "owner"
UNRELATED_SAME_ORG = "unrelated_same_org"
CROSS_ORG_USER = "cross_org_user"
SERVICE_ACCOUNT = "service_account"


PREFIX = "behavior-pin-"
ORG_A = PREFIX + "org-a"
ORG_B = PREFIX + "org-b"
TEAM_ALPHA = PREFIX + "team-alpha"
TEAM_BETA = PREFIX + "team-beta"
BUDGET_ID = PREFIX + "budget"


@dataclass(frozen=True)
class SeededKey:
user_id: str
cleartext: str
hashed: str


@dataclass(frozen=True)
class World:
org_a_id: str
org_b_id: str
team_alpha_id: str
team_beta_id: str
keys: Dict[Actor, SeededKey]


def _new_clear_key() -> str:
return "sk-" + uuid.uuid4().hex


def _actor_profile() -> Dict[Actor, Dict[str, Any]]:
return {
Actor.PROXY_ADMIN: {
"user_role": LitellmUserRoles.PROXY_ADMIN.value,
"team_id": None,
"organization_id": None,
},
Actor.ORG_ADMIN: {
"user_role": LitellmUserRoles.ORG_ADMIN.value,
"team_id": None,
"organization_id": ORG_A,
},
Actor.TEAM_ADMIN: {
"user_role": LitellmUserRoles.INTERNAL_USER.value,
"team_id": TEAM_ALPHA,
"organization_id": ORG_A,
},
Actor.INTERNAL_USER: {
"user_role": LitellmUserRoles.INTERNAL_USER.value,
"team_id": TEAM_ALPHA,
"organization_id": ORG_A,
},
Actor.OWNER: {
"user_role": LitellmUserRoles.INTERNAL_USER.value,
"team_id": TEAM_ALPHA,
"organization_id": ORG_A,
},
Actor.UNRELATED_SAME_ORG: {
"user_role": LitellmUserRoles.INTERNAL_USER.value,
"team_id": TEAM_ALPHA,
"organization_id": ORG_A,
},
Actor.CROSS_ORG_USER: {
"user_role": LitellmUserRoles.INTERNAL_USER.value,
"team_id": TEAM_BETA,
"organization_id": ORG_B,
},
Actor.SERVICE_ACCOUNT: {
"user_role": LitellmUserRoles.INTERNAL_USER.value,
"team_id": TEAM_ALPHA,
"organization_id": ORG_A,
},
}


async def _wipe_world(prisma: PrismaClient) -> None:
await prisma.db.litellm_verificationtoken.delete_many(
where={"user_id": {"startswith": PREFIX}}
)
await prisma.db.litellm_organizationmembership.delete_many(
where={"user_id": {"startswith": PREFIX}}
)
await prisma.db.litellm_teammembership.delete_many(
where={"user_id": {"startswith": PREFIX}}
)
await prisma.db.litellm_usertable.delete_many(
where={"user_id": {"startswith": PREFIX}}
)
await prisma.db.litellm_teamtable.delete_many(
where={"team_id": {"startswith": PREFIX}}
)
await prisma.db.litellm_organizationtable.delete_many(
where={"organization_id": {"startswith": PREFIX}}
)
await prisma.db.litellm_budgettable.delete_many(where={"budget_id": BUDGET_ID})


async def seed_world(prisma: PrismaClient) -> World:
await _wipe_world(prisma)

await prisma.db.litellm_budgettable.create(
data={
"budget_id": BUDGET_ID,
"created_by": "behavior-pin-seeder",
"updated_by": "behavior-pin-seeder",
}
)

for org_id, alias in [(ORG_A, "alpha"), (ORG_B, "beta")]:
await prisma.db.litellm_organizationtable.create(
data={
"organization_id": org_id,
"organization_alias": alias,
"budget_id": BUDGET_ID,
"created_by": "behavior-pin-seeder",
"updated_by": "behavior-pin-seeder",
}
)

profiles = _actor_profile()
user_ids: Dict[Actor, str] = {actor: PREFIX + actor.value for actor in Actor}

for actor, profile in profiles.items():
teams_list = [profile["team_id"]] if profile["team_id"] else []
await prisma.db.litellm_usertable.create(
data={
"user_id": user_ids[actor],
"user_role": profile["user_role"],
"team_id": profile["team_id"],
"organization_id": profile["organization_id"],
"teams": teams_list,
}
)

# _get_user_in_team in key_management_endpoints.py walks members_with_roles
# (a JSON list of {user_id, role}), not the String[] members column —
# populate both to match what /team/new produces.
await prisma.db.litellm_teamtable.create(
data={
"team_id": TEAM_ALPHA,
"team_alias": "alpha-1",
"organization_id": ORG_A,
"admins": [user_ids[Actor.TEAM_ADMIN]],
"members": [
user_ids[Actor.TEAM_ADMIN],
user_ids[Actor.INTERNAL_USER],
user_ids[Actor.OWNER],
user_ids[Actor.UNRELATED_SAME_ORG],
user_ids[Actor.SERVICE_ACCOUNT],
],
"members_with_roles": Json(
[
{"user_id": user_ids[Actor.TEAM_ADMIN], "role": "admin"},
{"user_id": user_ids[Actor.INTERNAL_USER], "role": "user"},
{"user_id": user_ids[Actor.OWNER], "role": "user"},
{"user_id": user_ids[Actor.UNRELATED_SAME_ORG], "role": "user"},
{"user_id": user_ids[Actor.SERVICE_ACCOUNT], "role": "user"},
]
),
}
)
await prisma.db.litellm_teamtable.create(
data={
"team_id": TEAM_BETA,
"team_alias": "beta-1",
"organization_id": ORG_B,
"admins": [],
"members": [user_ids[Actor.CROSS_ORG_USER]],
"members_with_roles": Json(
[
{"user_id": user_ids[Actor.CROSS_ORG_USER], "role": "user"},
]
),
}
)

for actor, org_id, role in [
(Actor.ORG_ADMIN, ORG_A, "org_admin"),
(Actor.TEAM_ADMIN, ORG_A, "internal_user"),
(Actor.INTERNAL_USER, ORG_A, "internal_user"),
(Actor.OWNER, ORG_A, "internal_user"),
(Actor.UNRELATED_SAME_ORG, ORG_A, "internal_user"),
(Actor.SERVICE_ACCOUNT, ORG_A, "internal_user"),
(Actor.CROSS_ORG_USER, ORG_B, "internal_user"),
]:
await prisma.db.litellm_organizationmembership.create(
data={
"user_id": user_ids[actor],
"organization_id": org_id,
"user_role": role,
}
)

for actor, team_id in [
(Actor.TEAM_ADMIN, TEAM_ALPHA),
(Actor.INTERNAL_USER, TEAM_ALPHA),
(Actor.OWNER, TEAM_ALPHA),
(Actor.UNRELATED_SAME_ORG, TEAM_ALPHA),
(Actor.SERVICE_ACCOUNT, TEAM_ALPHA),
(Actor.CROSS_ORG_USER, TEAM_BETA),
]:
await prisma.db.litellm_teammembership.create(
data={"user_id": user_ids[actor], "team_id": team_id}
)

keys: Dict[Actor, SeededKey] = {}
for actor, profile in profiles.items():
cleartext = _new_clear_key()
hashed = hash_token(cleartext)
token_data: Dict[str, Any] = {
"token": hashed,
"key_name": PREFIX + actor.value + "-key",
"user_id": user_ids[actor],
# LiteLLM_VerificationTokenView's models field rejects NULL even
# though the column is nullable in Postgres.
"models": [],
}
if profile["team_id"]:
token_data["team_id"] = profile["team_id"]
if profile["organization_id"]:
token_data["organization_id"] = profile["organization_id"]
if actor == Actor.SERVICE_ACCOUNT:
token_data["metadata"] = Json({"service_account_id": user_ids[actor]})
await prisma.db.litellm_verificationtoken.create(data=token_data)
keys[actor] = SeededKey(
user_id=user_ids[actor], cleartext=cleartext, hashed=hashed
)

return World(
org_a_id=ORG_A,
org_b_id=ORG_B,
team_alpha_id=TEAM_ALPHA,
team_beta_id=TEAM_BETA,
keys=keys,
)
Loading
Loading