"""Live e2e: the gateway's authorization and error-shape contract. A virtual key may only call models in its allow-list and route groups in its allowed_routes; both denials are a 403 raised before any provider is touched. A syntactically valid request naming a non-existent model is a 400 with a JSON body, never forwarded and never a 5xx. Migrated from litellm-regression-tests/tests/test_access_control.py: the source asserted 401 for the disallowed-model case against an older proxy, but the current contract (auth_checks.py) is a 403 key_model_access_denied, and the unknown-route check is replaced by a stronger route-permission check (an llm-only key rejected from a management route). """ from __future__ import annotations import pytest from access_control_client import ( AccessControlClient, MODEL_ACCESS_DENIED_MARKER, ROUTE_NOT_ALLOWED_MARKER, error_envelope, ) from e2e_config import unique_marker from e2e_http import Success, UnauthorizedError, UnknownApiError, unwrap from lifecycle import ResourceManager from models import ChatBody, ChatMessage, ChatResponse, LiteLLMParamsBody from proxy_client import ProxyClient pytestmark = pytest.mark.e2e ALLOWED_MODEL = "gemini-2.5-flash" DISALLOWED_MODEL = "gpt-5.5" VIRTUAL_KEY_BACKEND = "anthropic/claude-haiku-4-5-20251001" class TestAccessControl: def test_allowed_model_is_permitted( self, client: AccessControlClient, resources: ResourceManager ) -> None: """The allow-list's positive half. Without this, every other case in this class passes just as happily against a gateway that denies the allowed model too, because they only ever assert that something was refused. """ key = resources.key(models=[ALLOWED_MODEL]) result = client.chat_status( key, ALLOWED_MODEL, f"capital of France? {unique_marker()}" ) assert result.status_code == 200, ( f"key allow-listed for {ALLOWED_MODEL!r} must be able to call it, got " f"{result.status_code}: {result.body[:300]}" ) assert ChatResponse.model_validate_json(result.body).choices, ( f"200 must carry a real completion, not an error envelope: {result.body[:300]}" ) def test_disallowed_model_is_denied_403( self, client: AccessControlClient, resources: ResourceManager ) -> None: key = resources.key(models=[ALLOWED_MODEL]) result = client.chat_status( key, DISALLOWED_MODEL, f"capital of France? {unique_marker()}" ) assert result.status_code == 403, ( f"key limited to {ALLOWED_MODEL!r} calling {DISALLOWED_MODEL!r} must be " f"denied 403, got {result.status_code}: {result.body[:300]}" ) assert MODEL_ACCESS_DENIED_MARKER in result.body, ( f"403 body must be a model-access denial, got: {result.body[:300]}" ) def test_llm_only_key_forbidden_from_management_route_403( self, client: AccessControlClient, resources: ResourceManager ) -> None: key = client.llm_only_key() resources.defer(lambda: client.delete_key(key)) result = client.create_model_status(key, f"e2e-forbidden-{unique_marker()}") assert result.status_code == 403, ( f"llm-only key calling a management route must be denied 403, got " f"{result.status_code}: {result.body[:300]}" ) assert ROUTE_NOT_ALLOWED_MARKER in result.body, ( f"403 body must be a route-permission denial, got: {result.body[:300]}" ) def test_unknown_model_returns_400( self, client: AccessControlClient, resources: ResourceManager ) -> None: key = resources.key() result = client.chat_status( key, f"nonexistent-model-{unique_marker()}", "hi this is a test" ) assert result.status_code == 400, ( f"unknown model must be rejected 400 before forwarding, got " f"{result.status_code}: {result.body[:300]}" ) envelope = error_envelope(result.body) assert envelope is not None, ( f"400 body must be an OpenAI-shaped error envelope, got: {result.body[:300]}" ) assert envelope.error.message, ( f"400 error must carry a message a client can surface: {result.body[:300]}" ) class TestVirtualKeyAuth: """Virtual-key auth the way OpenAI-compatible clients send it: a real key must reach chat, a forged bearer must be rejected before the provider.""" @pytest.mark.covers( "mgmt.virtual_key.valid_allows", "mgmt.virtual_key.invalid_denied", exercised_on=[], ) def test_valid_key_allows_and_invalid_key_denied( self, proxy: ProxyClient, resources: ResourceManager ) -> None: model = f"e2e-auth-chat-{unique_marker()}" model_id = proxy.create_model( model, LiteLLMParamsBody(model=VIRTUAL_KEY_BACKEND, api_key="os.environ/ANTHROPIC_API_KEY"), ) resources.defer(lambda: proxy.delete_model(model_id)) key = resources.key() ok = unwrap( proxy.chat( key, ChatBody( model=model, messages=[ ChatMessage( role="user", content=f"Reply with one word. {unique_marker()}", ) ], max_tokens=16, ), ) ) assert ok.choices, f"valid key must complete chat: {ok}" bad = proxy.chat( "sk-e2e-forged-not-a-real-key", ChatBody( model=model, messages=[ChatMessage(role="user", content="should not run")], max_tokens=8, ), ) match bad: case UnauthorizedError(): return case UnknownApiError(status_code=status) if status in (401, 403): return case Success(): pytest.fail("forged bearer must not reach a successful completion") case _: pytest.fail(f"forged bearer must be auth-denied, got {bad}")