litellm/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py
Yucheng Zhu 168a20b065
Some checks failed
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled
fix: bind GET /credentials to its handler, not the verdict helper
The type-discipline refactor inserted _destination_verdict between the
@router.get decorator and get_credentials, so FastAPI registered the helper
as the listing endpoint. It declares a CredentialItem parameter, which FastAPI
reads as a required request body, and every GET /credentials returned 422.

Add the GET routing regression test alongside the existing PATCH one; the
other tests call get_credentials directly and stay green while the route is
dead.
2026-08-12 10:33:53 -07:00

277 lines
11 KiB
Python

"""Tests for the credential management endpoints."""
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.abspath("../../../.."))
from litellm.proxy._types import UserAPIKeyAuth
import litellm
import litellm.proxy.credential_endpoints.endpoints as endpoints
from litellm.proxy._types import LitellmUserRoles
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.proxy_server import app
from litellm.types.utils import CredentialItem
client = TestClient(app)
def _as_admin():
return UserAPIKeyAuth(api_key="test-key", user_role="proxy_admin")
def _patch_credential(name: str, body: dict):
missing = object()
previous_override = app.dependency_overrides.get(user_api_key_auth, missing)
app.dependency_overrides[user_api_key_auth] = _as_admin
try:
return client.patch(
f"/credentials/{name}",
json=body,
headers={"Authorization": "Bearer test-key"},
)
finally:
if previous_override is missing:
app.dependency_overrides.pop(user_api_key_auth, None)
else:
app.dependency_overrides[user_api_key_auth] = previous_override
def test_update_credential_answers_404_when_the_credential_does_not_exist():
"""Regression: the handler used to ``return handle_exception_on_proxy(e)``, which makes
the exception the response body and lets FastAPI answer 200, so a write the handler
rejected read as a success to every caller that checks the status. The dashboard's API
client branches on the status, so it reported a failed edit as applied."""
with patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), patch(
"litellm.proxy.credential_endpoints.endpoints.CredentialsRepository"
) as repository:
repository.return_value.find_by_name = AsyncMock(return_value=None)
response = _patch_credential(
"definitely-not-there",
{"credential_name": "definitely-not-there", "credential_values": {"api_key": "sk-x"}, "credential_info": {}},
)
assert response.status_code == 404, f"rejected write answered {response.status_code}: {response.text}"
assert "error" in response.json()
def test_update_credential_answers_500_when_the_database_is_not_connected():
"""The other rejection this handler raises must carry its own status too."""
with patch("litellm.proxy.proxy_server.prisma_client", None):
response = _patch_credential(
"any-name",
{"credential_name": "any-name", "credential_values": {"api_key": "sk-x"}, "credential_info": {}},
)
assert response.status_code == 500, f"rejected write answered {response.status_code}: {response.text}"
def test_update_credential_still_answers_200_on_a_successful_write():
"""The fix must not turn a legitimate update into an error; the dashboard and the
Playwright credentials spec both assert the success path."""
stored = CredentialItem(
credential_name="existing",
credential_values={"api_key": "sk-old"},
credential_info={"custom_llm_provider": "openai"},
)
with patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), patch(
"litellm.proxy.proxy_server.master_key", "sk-test-master"
), patch("litellm.proxy.credential_endpoints.endpoints.CredentialsRepository") as repository:
repository.return_value.find_by_name = AsyncMock(return_value=stored)
repository.return_value.update_by_name = AsyncMock(return_value=None)
response = _patch_credential(
"existing",
{"credential_name": "existing", "credential_values": {"api_key": "sk-new"}, "credential_info": {}},
)
assert response.status_code == 200, response.text
assert response.json()["success"] is True
def _admin():
return UserAPIKeyAuth(api_key="k", user_role=LitellmUserRoles.PROXY_ADMIN)
# --- credential_info replace semantics ---
@pytest.mark.asyncio
async def test_create_credential_validates_access_only_for_logging(monkeypatch):
"""validate_credential_access runs for a logging destination but never for a
provider credential. A provider cred carrying an unrelated `access` key must not be
rejected by the destination access-shape validator (that would 400 a valid
provider credential the validator was never meant to see)."""
import litellm.proxy.proxy_server as proxy_server
from litellm.types.utils import CreateCredentialItem
monkeypatch.setattr(proxy_server, "prisma_client", None, raising=False)
class _Validated(Exception):
pass
def _spy(_info):
raise _Validated()
monkeypatch.setattr(endpoints, "validate_credential_access", _spy)
async def _create(credential):
return await endpoints.create_credential(
request=MagicMock(),
fastapi_response=MagicMock(),
credential=credential,
user_api_key_dict=_admin(),
)
logging_cred = CreateCredentialItem(
credential_name="dest",
credential_values={"otel_endpoint": "http://collector:4318"},
credential_info={"credential_type": "logging", "description": "generic", "access": {"global": True}},
)
with pytest.raises(_Validated):
await _create(logging_cred)
provider_cred = CreateCredentialItem(
credential_name="openai",
credential_values={"api_key": "sk"},
credential_info={"custom_llm_provider": "openai", "access": {"bogus": True}},
)
# Validator is skipped; the handler proceeds and fails on the (None) prisma client,
# a 500 -- never the _Validated sentinel.
with pytest.raises(Exception) as excinfo:
await _create(provider_cred)
assert not isinstance(excinfo.value, _Validated)
# --- PATCH routing regression ------------------------------------------------
def test_patch_credentials_route_targets_update_credential():
"""Regression: the @router.patch decorator on /credentials/{name:path} must
decorate update_credential, not one of the extracted helpers. A misplaced
decorator landed once during the 7ecc1d49 split and the unit tests didn't
catch it because they import the handler function directly; this asserts
the FastAPI routing table actually points at update_credential.
"""
from fastapi.routing import APIRoute
patch_route = next(
route
for route in endpoints.router.routes
if isinstance(route, APIRoute)
and route.path == "/credentials/{credential_name:path}"
and "PATCH" in route.methods
)
assert patch_route.endpoint is endpoints.update_credential
def test_get_credentials_route_targets_get_credentials():
"""Regression: the @router.get decorator on /credentials must decorate
get_credentials, not one of the extracted helpers. A helper interposed
between the decorator and the handler binds the route to a function taking
a CredentialItem body, so the listing 422s for every caller. The other
tests import get_credentials directly and stay green while that happens.
"""
from fastapi.routing import APIRoute
get_route = next(
route
for route in endpoints.router.routes
if isinstance(route, APIRoute) and route.path == "/credentials" and "GET" in route.methods
)
assert get_route.endpoint is endpoints.get_credentials
assert get_route.body_field is None
@pytest.mark.asyncio
async def test_get_credentials_masks_secret_values(monkeypatch):
"""GET /credentials masks secret-bearing values; in particular a destination's
otel_headers (which carries the collector auth token) is never returned raw."""
raw_headers = "Authorization=Bearer collector-secret,x-api-key=api-secret"
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="openai",
credential_values={"api_key": "sk-secret"},
credential_info={"custom_llm_provider": "openai"},
),
CredentialItem(
credential_name="generic-otel",
credential_values={"otel_headers": raw_headers},
credential_info={"credential_type": "logging", "description": "generic"},
),
],
)
response = await endpoints.get_credentials(
request=MagicMock(),
fastapi_response=MagicMock(),
user_api_key_dict=_admin(),
)
names = sorted(c["credential_name"] for c in response["credentials"])
assert names == ["generic-otel", "openai"]
generic = next(c for c in response["credentials"] if c["credential_name"] == "generic-otel")
# otel_headers carries the collector auth token, so the masker treats it as a
# secret key: readable prefix only, never the full value.
assert generic["credential_values"]["otel_headers"] != raw_headers
assert "collector-secret" not in str(response)
assert "sk-secret" not in str(response)
@pytest.mark.asyncio
async def test_get_credentials_reports_whether_each_destination_actually_builds(monkeypatch):
"""The dashboard needs the resolver's own verdict, not a second implementation of it.
Its Scope column read ``credential_info.access`` alone, so a destination the resolver
excludes (no backend name, or values its adapter rejects) still rendered a scope badge
and read as live. Reproducing the adapter rules in the frontend would drift from them;
this field is computed by ``destination_for_credential``, the same function the
request-time resolver and the team/org disclosure use.
"""
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="builds",
credential_values={"otel_endpoint": "http://collector.internal:4318/v1/traces"},
credential_info={"credential_type": "logging", "description": "generic", "access": {"global": True}},
),
CredentialItem(
credential_name="no-backend",
credential_values={"otel_endpoint": "http://collector.internal:4318/v1/traces"},
credential_info={"credential_type": "logging", "access": {"global": True}},
),
CredentialItem(
credential_name="adapter-rejects",
credential_values={"langfuse_public_key": "pk-only"},
credential_info={
"credential_type": "logging",
"description": "langfuse_otel",
"access": {"global": True},
},
),
CredentialItem(
credential_name="openai",
credential_values={"api_key": "sk-secret"},
credential_info={"custom_llm_provider": "openai"},
),
],
)
response = await endpoints.get_credentials(
request=MagicMock(),
fastapi_response=MagicMock(),
user_api_key_dict=_admin(),
)
verdicts = {c["credential_name"]: c.get("resolves_to_destination") for c in response["credentials"]}
assert verdicts["builds"] is True
assert verdicts["no-backend"] is False
assert verdicts["adapter-rejects"] is False
# A provider credential is not a destination and gets no verdict at all, rather than a
# False that would render it as a broken destination.
assert verdicts["openai"] is None