mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-08 21:03:08 +00:00
a6527e5010
* add handlers for auth commands * add login, logout, whoami * refactor auth * add CLI Authentication Flow * add SSO sign in constants * add itellm-session-token * fixes for managing state with cli * use locally stored context for cli session * add litellm banner + interactive shell * update main.py * update auth to show commands * fix ui sso render * add TestCLISSOCallbackFunction * update banner.py * remove file * fix cli sso success * TestTokenUtilities * fix code qa * fix execute_command * fix cli_sso_callback * fix import * Authentication using CLI
1048 lines
36 KiB
Python
1048 lines
36 KiB
Python
import asyncio
|
|
import json
|
|
import os
|
|
import sys
|
|
import uuid
|
|
from typing import Optional, cast
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import Request
|
|
from fastapi.testclient import TestClient
|
|
|
|
sys.path.insert(
|
|
0, os.path.abspath("../../../")
|
|
) # Adds the parent directory to the system path
|
|
|
|
import litellm
|
|
from litellm.proxy._types import NewTeamRequest
|
|
from litellm.proxy.auth.handle_jwt import JWTHandler
|
|
from litellm.proxy.management_endpoints.types import CustomOpenID
|
|
from litellm.proxy.management_endpoints.ui_sso import (
|
|
GoogleSSOHandler,
|
|
MicrosoftSSOHandler,
|
|
SSOAuthenticationHandler,
|
|
)
|
|
from litellm.types.proxy.management_endpoints.ui_sso import (
|
|
DefaultTeamSSOParams,
|
|
MicrosoftGraphAPIUserGroupDirectoryObject,
|
|
MicrosoftGraphAPIUserGroupResponse,
|
|
MicrosoftServicePrincipalTeam,
|
|
)
|
|
|
|
|
|
def test_microsoft_sso_handler_openid_from_response_user_principal_name():
|
|
# Arrange
|
|
# Create a mock response similar to what Microsoft SSO would return
|
|
mock_response = {
|
|
"userPrincipalName": "test@example.com",
|
|
"displayName": "Test User",
|
|
"id": "user123",
|
|
"givenName": "Test",
|
|
"surname": "User",
|
|
"some_other_field": "value",
|
|
}
|
|
expected_team_ids = ["team1", "team2"]
|
|
# Act
|
|
# Call the method being tested
|
|
result = MicrosoftSSOHandler.openid_from_response(
|
|
response=mock_response, team_ids=expected_team_ids
|
|
)
|
|
|
|
# Assert
|
|
|
|
# Check that the result is a CustomOpenID object with the expected values
|
|
assert isinstance(result, CustomOpenID)
|
|
assert result.email == "test@example.com"
|
|
assert result.display_name == "Test User"
|
|
assert result.provider == "microsoft"
|
|
assert result.id == "user123"
|
|
assert result.first_name == "Test"
|
|
assert result.last_name == "User"
|
|
assert result.team_ids == expected_team_ids
|
|
|
|
|
|
def test_microsoft_sso_handler_openid_from_response():
|
|
# Arrange
|
|
# Create a mock response similar to what Microsoft SSO would return
|
|
mock_response = {
|
|
"mail": "test@example.com",
|
|
"displayName": "Test User",
|
|
"id": "user123",
|
|
"givenName": "Test",
|
|
"surname": "User",
|
|
"some_other_field": "value",
|
|
}
|
|
expected_team_ids = ["team1", "team2"]
|
|
# Act
|
|
# Call the method being tested
|
|
result = MicrosoftSSOHandler.openid_from_response(
|
|
response=mock_response, team_ids=expected_team_ids
|
|
)
|
|
|
|
# Assert
|
|
|
|
# Check that the result is a CustomOpenID object with the expected values
|
|
assert isinstance(result, CustomOpenID)
|
|
assert result.email == "test@example.com"
|
|
assert result.display_name == "Test User"
|
|
assert result.provider == "microsoft"
|
|
assert result.id == "user123"
|
|
assert result.first_name == "Test"
|
|
assert result.last_name == "User"
|
|
assert result.team_ids == expected_team_ids
|
|
|
|
|
|
def test_microsoft_sso_handler_with_empty_response():
|
|
# Arrange
|
|
# Test with None response
|
|
|
|
# Act
|
|
result = MicrosoftSSOHandler.openid_from_response(response=None, team_ids=[])
|
|
|
|
# Assert
|
|
assert isinstance(result, CustomOpenID)
|
|
assert result.email is None
|
|
assert result.display_name is None
|
|
assert result.provider == "microsoft"
|
|
assert result.id is None
|
|
assert result.first_name is None
|
|
assert result.last_name is None
|
|
assert result.team_ids == []
|
|
|
|
|
|
def test_get_microsoft_callback_response():
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_response = {
|
|
"mail": "microsoft_user@example.com",
|
|
"displayName": "Microsoft User",
|
|
"id": "msft123",
|
|
"givenName": "Microsoft",
|
|
"surname": "User",
|
|
}
|
|
|
|
future = asyncio.Future()
|
|
future.set_result(mock_response)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{"MICROSOFT_CLIENT_SECRET": "mock_secret", "MICROSOFT_TENANT": "mock_tenant"},
|
|
):
|
|
with patch(
|
|
"fastapi_sso.sso.microsoft.MicrosoftSSO.verify_and_process",
|
|
return_value=future,
|
|
):
|
|
# Act
|
|
result = asyncio.run(
|
|
MicrosoftSSOHandler.get_microsoft_callback_response(
|
|
request=mock_request,
|
|
microsoft_client_id="mock_client_id",
|
|
redirect_url="http://mock_redirect_url",
|
|
)
|
|
)
|
|
|
|
# Assert
|
|
assert isinstance(result, CustomOpenID)
|
|
assert result.email == "microsoft_user@example.com"
|
|
assert result.display_name == "Microsoft User"
|
|
assert result.provider == "microsoft"
|
|
assert result.id == "msft123"
|
|
assert result.first_name == "Microsoft"
|
|
assert result.last_name == "User"
|
|
|
|
|
|
def test_get_microsoft_callback_response_raw_sso_response():
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_response = {
|
|
"mail": "microsoft_user@example.com",
|
|
"displayName": "Microsoft User",
|
|
"id": "msft123",
|
|
"givenName": "Microsoft",
|
|
"surname": "User",
|
|
}
|
|
|
|
future = asyncio.Future()
|
|
future.set_result(mock_response)
|
|
with patch.dict(
|
|
os.environ,
|
|
{"MICROSOFT_CLIENT_SECRET": "mock_secret", "MICROSOFT_TENANT": "mock_tenant"},
|
|
):
|
|
with patch(
|
|
"fastapi_sso.sso.microsoft.MicrosoftSSO.verify_and_process",
|
|
return_value=future,
|
|
):
|
|
# Act
|
|
result = asyncio.run(
|
|
MicrosoftSSOHandler.get_microsoft_callback_response(
|
|
request=mock_request,
|
|
microsoft_client_id="mock_client_id",
|
|
redirect_url="http://mock_redirect_url",
|
|
return_raw_sso_response=True,
|
|
)
|
|
)
|
|
|
|
# Assert
|
|
print("result from verify_and_process", result)
|
|
assert isinstance(result, dict)
|
|
assert result["mail"] == "microsoft_user@example.com"
|
|
assert result["displayName"] == "Microsoft User"
|
|
assert result["id"] == "msft123"
|
|
assert result["givenName"] == "Microsoft"
|
|
assert result["surname"] == "User"
|
|
|
|
|
|
def test_get_google_callback_response():
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_response = {
|
|
"email": "google_user@example.com",
|
|
"name": "Google User",
|
|
"sub": "google123",
|
|
"given_name": "Google",
|
|
"family_name": "User",
|
|
}
|
|
|
|
future = asyncio.Future()
|
|
future.set_result(mock_response)
|
|
|
|
with patch.dict(os.environ, {"GOOGLE_CLIENT_SECRET": "mock_secret"}):
|
|
with patch(
|
|
"fastapi_sso.sso.google.GoogleSSO.verify_and_process", return_value=future
|
|
):
|
|
# Act
|
|
result = asyncio.run(
|
|
GoogleSSOHandler.get_google_callback_response(
|
|
request=mock_request,
|
|
google_client_id="mock_client_id",
|
|
redirect_url="http://mock_redirect_url",
|
|
)
|
|
)
|
|
|
|
# Assert
|
|
assert isinstance(result, dict)
|
|
assert result.get("email") == "google_user@example.com"
|
|
assert result.get("name") == "Google User"
|
|
assert result.get("sub") == "google123"
|
|
assert result.get("given_name") == "Google"
|
|
assert result.get("family_name") == "User"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_groups_from_graph_api():
|
|
# Arrange
|
|
mock_response = {
|
|
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#directoryObjects",
|
|
"value": [
|
|
{
|
|
"@odata.type": "#microsoft.graph.group",
|
|
"id": "group1",
|
|
"displayName": "Group 1",
|
|
},
|
|
{
|
|
"@odata.type": "#microsoft.graph.group",
|
|
"id": "group2",
|
|
"displayName": "Group 2",
|
|
},
|
|
],
|
|
}
|
|
|
|
async def mock_get(*args, **kwargs):
|
|
mock = MagicMock()
|
|
mock.json.return_value = mock_response
|
|
return mock
|
|
|
|
with patch(
|
|
"litellm.proxy.management_endpoints.ui_sso.get_async_httpx_client"
|
|
) as mock_client:
|
|
mock_client.return_value = MagicMock()
|
|
mock_client.return_value.get = mock_get
|
|
|
|
# Act
|
|
result = await MicrosoftSSOHandler.get_user_groups_from_graph_api(
|
|
access_token="mock_token"
|
|
)
|
|
|
|
# Assert
|
|
assert isinstance(result, list)
|
|
assert len(result) == 2
|
|
assert "group1" in result
|
|
assert "group2" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_groups_empty_response():
|
|
# Arrange
|
|
mock_response = {
|
|
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#directoryObjects",
|
|
"value": [],
|
|
}
|
|
|
|
async def mock_get(*args, **kwargs):
|
|
mock = MagicMock()
|
|
mock.json.return_value = mock_response
|
|
return mock
|
|
|
|
with patch(
|
|
"litellm.proxy.management_endpoints.ui_sso.get_async_httpx_client"
|
|
) as mock_client:
|
|
mock_client.return_value = MagicMock()
|
|
mock_client.return_value.get = mock_get
|
|
|
|
# Act
|
|
result = await MicrosoftSSOHandler.get_user_groups_from_graph_api(
|
|
access_token="mock_token"
|
|
)
|
|
|
|
# Assert
|
|
assert isinstance(result, list)
|
|
assert len(result) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_groups_error_handling():
|
|
# Arrange
|
|
async def mock_get(*args, **kwargs):
|
|
raise Exception("API Error")
|
|
|
|
with patch(
|
|
"litellm.proxy.management_endpoints.ui_sso.get_async_httpx_client"
|
|
) as mock_client:
|
|
mock_client.return_value = MagicMock()
|
|
mock_client.return_value.get = mock_get
|
|
|
|
# Act
|
|
result = await MicrosoftSSOHandler.get_user_groups_from_graph_api(
|
|
access_token="mock_token"
|
|
)
|
|
|
|
# Assert
|
|
assert isinstance(result, list)
|
|
assert len(result) == 0
|
|
|
|
|
|
def test_get_group_ids_from_graph_api_response():
|
|
# Arrange
|
|
mock_response = MicrosoftGraphAPIUserGroupResponse(
|
|
odata_context="https://graph.microsoft.com/v1.0/$metadata#directoryObjects",
|
|
odata_nextLink=None,
|
|
value=[
|
|
MicrosoftGraphAPIUserGroupDirectoryObject(
|
|
odata_type="#microsoft.graph.group",
|
|
id="group1",
|
|
displayName="Group 1",
|
|
description=None,
|
|
deletedDateTime=None,
|
|
roleTemplateId=None,
|
|
),
|
|
MicrosoftGraphAPIUserGroupDirectoryObject(
|
|
odata_type="#microsoft.graph.group",
|
|
id="group2",
|
|
displayName="Group 2",
|
|
description=None,
|
|
deletedDateTime=None,
|
|
roleTemplateId=None,
|
|
),
|
|
MicrosoftGraphAPIUserGroupDirectoryObject(
|
|
odata_type="#microsoft.graph.group",
|
|
id=None, # Test handling of None id
|
|
displayName="Invalid Group",
|
|
description=None,
|
|
deletedDateTime=None,
|
|
roleTemplateId=None,
|
|
),
|
|
],
|
|
)
|
|
|
|
# Act
|
|
result = MicrosoftSSOHandler._get_group_ids_from_graph_api_response(mock_response)
|
|
|
|
# Assert
|
|
assert isinstance(result, list)
|
|
assert len(result) == 2
|
|
assert "group1" in result
|
|
assert "group2" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"team_params",
|
|
[
|
|
# Test case 1: Using DefaultTeamSSOParams
|
|
DefaultTeamSSOParams(
|
|
max_budget=10, budget_duration="1d", models=["special-gpt-5"]
|
|
),
|
|
# Test case 2: Using Dict
|
|
{"max_budget": 10, "budget_duration": "1d", "models": ["special-gpt-5"]},
|
|
],
|
|
)
|
|
async def test_default_team_params(team_params):
|
|
"""
|
|
When litellm.default_team_params is set, it should be used to create a new team
|
|
"""
|
|
# Arrange
|
|
litellm.default_team_params = team_params
|
|
|
|
def mock_jsonify_team_object(db_data):
|
|
return db_data
|
|
|
|
# Mock Prisma client
|
|
mock_prisma = MagicMock()
|
|
mock_prisma.db.litellm_teamtable.find_first = AsyncMock(return_value=None)
|
|
mock_prisma.db.litellm_teamtable.create = AsyncMock()
|
|
mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0)
|
|
mock_prisma.get_data = AsyncMock(return_value=None)
|
|
mock_prisma.jsonify_team_object = MagicMock(side_effect=mock_jsonify_team_object)
|
|
|
|
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma):
|
|
# Act
|
|
team_id = str(uuid.uuid4())
|
|
await MicrosoftSSOHandler.create_litellm_teams_from_service_principal_team_ids(
|
|
service_principal_teams=[
|
|
MicrosoftServicePrincipalTeam(
|
|
principalId=team_id,
|
|
principalDisplayName="Test Team",
|
|
)
|
|
]
|
|
)
|
|
|
|
# Assert
|
|
# Verify team was created with correct parameters
|
|
mock_prisma.db.litellm_teamtable.create.assert_called_once()
|
|
print(
|
|
"mock_prisma.db.litellm_teamtable.create.call_args",
|
|
mock_prisma.db.litellm_teamtable.create.call_args,
|
|
)
|
|
create_call_args = mock_prisma.db.litellm_teamtable.create.call_args.kwargs[
|
|
"data"
|
|
]
|
|
assert create_call_args["team_id"] == team_id
|
|
assert create_call_args["team_alias"] == "Test Team"
|
|
assert create_call_args["max_budget"] == 10
|
|
assert create_call_args["budget_duration"] == "1d"
|
|
assert create_call_args["models"] == ["special-gpt-5"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_team_without_default_params():
|
|
"""
|
|
Test team creation when litellm.default_team_params is None
|
|
Should create team with just the basic required fields
|
|
"""
|
|
# Arrange
|
|
litellm.default_team_params = None
|
|
|
|
def mock_jsonify_team_object(db_data):
|
|
return db_data
|
|
|
|
# Mock Prisma client
|
|
mock_prisma = MagicMock()
|
|
mock_prisma.db.litellm_teamtable.find_first = AsyncMock(return_value=None)
|
|
mock_prisma.db.litellm_teamtable.create = AsyncMock()
|
|
mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0)
|
|
mock_prisma.get_data = AsyncMock(return_value=None)
|
|
mock_prisma.jsonify_team_object = MagicMock(side_effect=mock_jsonify_team_object)
|
|
|
|
with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma):
|
|
# Act
|
|
team_id = str(uuid.uuid4())
|
|
await MicrosoftSSOHandler.create_litellm_teams_from_service_principal_team_ids(
|
|
service_principal_teams=[
|
|
MicrosoftServicePrincipalTeam(
|
|
principalId=team_id,
|
|
principalDisplayName="Test Team",
|
|
)
|
|
]
|
|
)
|
|
|
|
# Assert
|
|
mock_prisma.db.litellm_teamtable.create.assert_called_once()
|
|
create_call_args = mock_prisma.db.litellm_teamtable.create.call_args.kwargs[
|
|
"data"
|
|
]
|
|
assert create_call_args["team_id"] == team_id
|
|
assert create_call_args["team_alias"] == "Test Team"
|
|
# Should not have any of the optional fields
|
|
assert "max_budget" not in create_call_args
|
|
assert "budget_duration" not in create_call_args
|
|
assert create_call_args["models"] == []
|
|
|
|
|
|
def test_apply_user_info_values_to_sso_user_defined_values():
|
|
from litellm.proxy._types import LiteLLM_UserTable, SSOUserDefinedValues
|
|
from litellm.proxy.management_endpoints.ui_sso import (
|
|
apply_user_info_values_to_sso_user_defined_values,
|
|
)
|
|
|
|
user_info = LiteLLM_UserTable(
|
|
user_id="123",
|
|
user_email="test@example.com",
|
|
user_role="admin",
|
|
)
|
|
|
|
user_defined_values: SSOUserDefinedValues = {
|
|
"models": [],
|
|
"user_id": "456",
|
|
"user_email": "test@example.com",
|
|
"user_role": "admin",
|
|
"max_budget": None,
|
|
"budget_duration": None,
|
|
}
|
|
|
|
sso_user_defined_values = apply_user_info_values_to_sso_user_defined_values(
|
|
user_info=user_info,
|
|
user_defined_values=user_defined_values,
|
|
)
|
|
|
|
assert sso_user_defined_values is not None
|
|
assert sso_user_defined_values["user_id"] == "123"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_info_from_db():
|
|
"""
|
|
received args in get_user_info_from_db: {'result': CustomOpenID(id='krrishd', email='krrishdholakia@gmail.com', first_name=None, last_name=None, display_name='a3f1c107-04dc-4c93-ae60-7f32eb4b05ce', picture=None, provider=None, team_ids=[]), 'prisma_client': <litellm.proxy.utils.PrismaClient object at 0x14a74e3c0>, 'user_api_key_cache': <litellm.caching.dual_cache.DualCache object at 0x148d37110>, 'proxy_logging_obj': <litellm.proxy.utils.ProxyLogging object at 0x148dd9090>, 'user_email': 'krrishdholakia@gmail.com', 'user_defined_values': {'models': [], 'user_id': 'krrishd', 'user_email': 'krrishdholakia@gmail.com', 'max_budget': None, 'user_role': None, 'budget_duration': None}}
|
|
"""
|
|
from litellm.proxy.management_endpoints.ui_sso import get_user_info_from_db
|
|
|
|
prisma_client = MagicMock()
|
|
user_api_key_cache = MagicMock()
|
|
proxy_logging_obj = MagicMock()
|
|
user_email = "krrishdholakia@gmail.com"
|
|
user_defined_values = {
|
|
"models": [],
|
|
"user_id": "krrishd",
|
|
"user_email": "krrishdholakia@gmail.com",
|
|
"max_budget": None,
|
|
"user_role": None,
|
|
"budget_duration": None,
|
|
}
|
|
args = {
|
|
"result": CustomOpenID(
|
|
id="krrishd",
|
|
email="krrishdholakia@gmail.com",
|
|
first_name=None,
|
|
last_name=None,
|
|
display_name="a3f1c107-04dc-4c93-ae60-7f32eb4b05ce",
|
|
picture=None,
|
|
provider=None,
|
|
team_ids=[],
|
|
),
|
|
"prisma_client": prisma_client,
|
|
"user_api_key_cache": user_api_key_cache,
|
|
"proxy_logging_obj": proxy_logging_obj,
|
|
"user_email": user_email,
|
|
"user_defined_values": user_defined_values,
|
|
}
|
|
with patch(
|
|
"litellm.proxy.management_endpoints.ui_sso.get_user_object"
|
|
) as mock_get_user_object:
|
|
user_info = await get_user_info_from_db(**args)
|
|
mock_get_user_object.assert_called_once()
|
|
mock_get_user_object.call_args.kwargs["user_id"] = "krrishd"
|
|
|
|
|
|
async def test_get_user_info_from_db_alternate_user_id():
|
|
from litellm.proxy.management_endpoints.ui_sso import get_user_info_from_db
|
|
|
|
prisma_client = MagicMock()
|
|
user_api_key_cache = MagicMock()
|
|
proxy_logging_obj = MagicMock()
|
|
user_email = "krrishdholakia@gmail.com"
|
|
user_defined_values = {
|
|
"models": [],
|
|
"user_id": "krrishd",
|
|
"user_email": "krrishdholakia@gmail.com",
|
|
"max_budget": None,
|
|
"user_role": None,
|
|
"budget_duration": None,
|
|
}
|
|
args = {
|
|
"result": CustomOpenID(
|
|
id="krrishd",
|
|
email="krrishdholakia@gmail.com",
|
|
first_name=None,
|
|
last_name=None,
|
|
display_name="a3f1c107-04dc-4c93-ae60-7f32eb4b05ce",
|
|
picture=None,
|
|
provider=None,
|
|
team_ids=[],
|
|
),
|
|
"prisma_client": prisma_client,
|
|
"user_api_key_cache": user_api_key_cache,
|
|
"proxy_logging_obj": proxy_logging_obj,
|
|
"user_email": user_email,
|
|
"user_defined_values": user_defined_values,
|
|
"alternate_user_id": "krrishd-email1234",
|
|
}
|
|
with patch(
|
|
"litellm.proxy.management_endpoints.ui_sso.get_user_object"
|
|
) as mock_get_user_object:
|
|
user_info = await get_user_info_from_db(**args)
|
|
mock_get_user_object.assert_called_once()
|
|
mock_get_user_object.call_args.kwargs["user_id"] = "krrishd-email1234"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_and_update_if_proxy_admin_id():
|
|
"""
|
|
Test that a user with matching PROXY_ADMIN_ID gets their role updated to admin
|
|
"""
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
from litellm.proxy.management_endpoints.ui_sso import (
|
|
check_and_update_if_proxy_admin_id,
|
|
)
|
|
|
|
# Mock Prisma client
|
|
mock_prisma = MagicMock()
|
|
mock_prisma.db.litellm_usertable.update = AsyncMock()
|
|
|
|
# Set up test data
|
|
test_user_id = "test_admin_123"
|
|
test_user_role = "user"
|
|
|
|
with patch.dict(os.environ, {"PROXY_ADMIN_ID": test_user_id}):
|
|
# Act
|
|
updated_role = await check_and_update_if_proxy_admin_id(
|
|
user_role=test_user_role, user_id=test_user_id, prisma_client=mock_prisma
|
|
)
|
|
|
|
# Assert
|
|
assert updated_role == LitellmUserRoles.PROXY_ADMIN.value
|
|
mock_prisma.db.litellm_usertable.update.assert_called_once_with(
|
|
where={"user_id": test_user_id},
|
|
data={"user_role": LitellmUserRoles.PROXY_ADMIN.value},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_and_update_if_proxy_admin_id_already_admin():
|
|
"""
|
|
Test that a user who is already an admin doesn't get their role updated
|
|
"""
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
from litellm.proxy.management_endpoints.ui_sso import (
|
|
check_and_update_if_proxy_admin_id,
|
|
)
|
|
|
|
# Mock Prisma client
|
|
mock_prisma = MagicMock()
|
|
mock_prisma.db.litellm_usertable.update = AsyncMock()
|
|
|
|
# Set up test data
|
|
test_user_id = "test_admin_123"
|
|
test_user_role = LitellmUserRoles.PROXY_ADMIN.value
|
|
|
|
with patch.dict(os.environ, {"PROXY_ADMIN_ID": test_user_id}):
|
|
# Act
|
|
updated_role = await check_and_update_if_proxy_admin_id(
|
|
user_role=test_user_role, user_id=test_user_id, prisma_client=mock_prisma
|
|
)
|
|
|
|
# Assert
|
|
assert updated_role == LitellmUserRoles.PROXY_ADMIN.value
|
|
mock_prisma.db.litellm_usertable.update.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_generic_sso_response_with_additional_headers():
|
|
"""
|
|
Test that GENERIC_SSO_HEADERS environment variable is correctly processed
|
|
and passed to generic_sso.verify_and_process
|
|
"""
|
|
from litellm.proxy.management_endpoints.ui_sso import get_generic_sso_response
|
|
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_jwt_handler = MagicMock(spec=JWTHandler)
|
|
mock_jwt_handler.get_team_ids_from_jwt.return_value = []
|
|
|
|
generic_client_id = "test_client_id"
|
|
redirect_url = "http://test.com/callback"
|
|
|
|
# Mock response from verify_and_process
|
|
mock_sso_response = {
|
|
"sub": "test_user_123",
|
|
"email": "test@example.com",
|
|
"preferred_username": "testuser",
|
|
}
|
|
|
|
# Set up environment variables including GENERIC_SSO_HEADERS
|
|
test_env_vars = {
|
|
"GENERIC_CLIENT_SECRET": "test_secret",
|
|
"GENERIC_AUTHORIZATION_ENDPOINT": "https://auth.example.com/auth",
|
|
"GENERIC_TOKEN_ENDPOINT": "https://auth.example.com/token",
|
|
"GENERIC_USERINFO_ENDPOINT": "https://auth.example.com/userinfo",
|
|
"GENERIC_SSO_HEADERS": "Authorization=Bearer token123, Content-Type=application/json, X-Custom-Header=custom-value",
|
|
}
|
|
|
|
# Expected headers dictionary
|
|
expected_headers = {
|
|
"Authorization": "Bearer token123",
|
|
"Content-Type": "application/json",
|
|
"X-Custom-Header": "custom-value",
|
|
}
|
|
|
|
# Mock the SSO provider and its methods
|
|
mock_sso_instance = MagicMock()
|
|
mock_sso_instance.verify_and_process = AsyncMock(return_value=mock_sso_response)
|
|
|
|
mock_sso_class = MagicMock(return_value=mock_sso_instance)
|
|
|
|
with patch.dict(os.environ, test_env_vars):
|
|
with patch("fastapi_sso.sso.base.DiscoveryDocument") as mock_discovery:
|
|
with patch(
|
|
"fastapi_sso.sso.generic.create_provider", return_value=mock_sso_class
|
|
) as mock_create_provider:
|
|
# Act
|
|
result, received_response = await get_generic_sso_response(
|
|
request=mock_request,
|
|
jwt_handler=mock_jwt_handler,
|
|
generic_client_id=generic_client_id,
|
|
redirect_url=redirect_url,
|
|
sso_jwt_handler=None,
|
|
)
|
|
|
|
# Assert
|
|
# Verify verify_and_process was called with the correct headers
|
|
mock_sso_instance.verify_and_process.assert_called_once_with(
|
|
mock_request,
|
|
params={"include_client_id": False},
|
|
headers=expected_headers,
|
|
)
|
|
|
|
# Verify the result is returned correctly
|
|
assert result == mock_sso_response
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_generic_sso_response_with_empty_headers():
|
|
"""
|
|
Test that when GENERIC_SSO_HEADERS is not set, an empty headers dict is passed
|
|
"""
|
|
from litellm.proxy.management_endpoints.ui_sso import get_generic_sso_response
|
|
|
|
# Arrange
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_jwt_handler = MagicMock(spec=JWTHandler)
|
|
mock_jwt_handler.get_team_ids_from_jwt.return_value = []
|
|
|
|
generic_client_id = "test_client_id"
|
|
redirect_url = "http://test.com/callback"
|
|
|
|
mock_sso_response = {
|
|
"sub": "test_user_123",
|
|
"email": "test@example.com",
|
|
"preferred_username": "testuser",
|
|
}
|
|
|
|
# Set up environment variables without GENERIC_SSO_HEADERS
|
|
test_env_vars = {
|
|
"GENERIC_CLIENT_SECRET": "test_secret",
|
|
"GENERIC_AUTHORIZATION_ENDPOINT": "https://auth.example.com/auth",
|
|
"GENERIC_TOKEN_ENDPOINT": "https://auth.example.com/token",
|
|
"GENERIC_USERINFO_ENDPOINT": "https://auth.example.com/userinfo",
|
|
}
|
|
|
|
# Mock the SSO provider and its methods
|
|
mock_sso_instance = MagicMock()
|
|
mock_sso_instance.verify_and_process = AsyncMock(return_value=mock_sso_response)
|
|
|
|
mock_sso_class = MagicMock(return_value=mock_sso_instance)
|
|
|
|
with patch.dict(os.environ, test_env_vars):
|
|
with patch("fastapi_sso.sso.base.DiscoveryDocument") as mock_discovery:
|
|
with patch(
|
|
"fastapi_sso.sso.generic.create_provider", return_value=mock_sso_class
|
|
) as mock_create_provider:
|
|
# Act
|
|
result, received_response = await get_generic_sso_response(
|
|
request=mock_request,
|
|
jwt_handler=mock_jwt_handler,
|
|
generic_client_id=generic_client_id,
|
|
redirect_url=redirect_url,
|
|
sso_jwt_handler=None,
|
|
)
|
|
|
|
# Assert
|
|
# Verify verify_and_process was called with empty headers dict
|
|
mock_sso_instance.verify_and_process.assert_called_once_with(
|
|
mock_request, params={"include_client_id": False}, headers={}
|
|
)
|
|
|
|
assert result == mock_sso_response
|
|
|
|
|
|
class TestCLISSOCallbackFunction:
|
|
"""Test the cli_sso_callback function specifically"""
|
|
|
|
def test_cli_sso_callback_validation_invalid_key(self):
|
|
"""Test CLI SSO callback input validation for invalid key format"""
|
|
# Test the validation logic without hitting the database
|
|
invalid_keys = [
|
|
None,
|
|
"",
|
|
"invalid-key",
|
|
"not-sk-key",
|
|
"sk", # too short
|
|
]
|
|
|
|
for invalid_key in invalid_keys:
|
|
# This should fail validation before any database operations
|
|
# We can test this by checking if the key starts with 'sk-'
|
|
if not invalid_key or not invalid_key.startswith('sk-'):
|
|
# This would trigger the validation error
|
|
assert True # Validation works as expected
|
|
|
|
|
|
class TestCLIPollingFunction:
|
|
"""Test the cli_poll_key function specifically"""
|
|
|
|
def test_cli_poll_key_validation_invalid_format(self):
|
|
"""Test CLI polling key format validation"""
|
|
# Test key format validation logic
|
|
invalid_keys = [
|
|
"invalid-key",
|
|
"not-sk-key",
|
|
"",
|
|
"sk", # too short
|
|
]
|
|
|
|
for invalid_key in invalid_keys:
|
|
# Validation logic: key must start with 'sk-'
|
|
if not invalid_key.startswith('sk-'):
|
|
# This would trigger the validation error in the actual function
|
|
assert True # Validation works as expected
|
|
|
|
|
|
class TestAuthCallbackRouting:
|
|
"""Test the auth_callback function routing logic"""
|
|
|
|
def test_cli_state_detection_and_routing(self):
|
|
"""Test that CLI states are properly detected and would route to CLI callback"""
|
|
from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX
|
|
|
|
# Test CLI state detection logic
|
|
cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:sk-test123"
|
|
|
|
# This mimics the logic in auth_callback
|
|
if cli_state and cli_state.startswith(f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:"):
|
|
# Extract the key ID from the state
|
|
key_id = cli_state.split(":", 1)[1]
|
|
assert key_id == "sk-test123"
|
|
else:
|
|
assert False, "CLI state should have been detected"
|
|
|
|
def test_non_cli_state_routing(self):
|
|
"""Test that non-CLI states don't trigger CLI routing"""
|
|
from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX
|
|
|
|
non_cli_states = [
|
|
"regular_oauth_state",
|
|
"some_random_string",
|
|
None,
|
|
"",
|
|
"not_session_token:something"
|
|
]
|
|
|
|
for state in non_cli_states:
|
|
# This mimics the routing logic in auth_callback
|
|
should_route_to_cli = state and state.startswith(f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:")
|
|
assert not should_route_to_cli, f"State '{state}' should not route to CLI"
|
|
|
|
|
|
class TestGoogleLoginCLIIntegration:
|
|
"""Test the google_login function with CLI parameters"""
|
|
|
|
def test_google_login_cli_state_generation(self):
|
|
"""Test that google_login generates CLI state when CLI parameters are provided"""
|
|
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
|
|
|
|
# Test the CLI state generation logic used in google_login
|
|
source = "litellm-cli"
|
|
key = "sk-test123"
|
|
|
|
cli_state = SSOAuthenticationHandler._get_cli_state(source=source, key=key)
|
|
|
|
assert cli_state is not None
|
|
assert cli_state.startswith("litellm-session-token:")
|
|
assert "sk-test123" in cli_state
|
|
|
|
def test_google_login_no_cli_state_when_missing_params(self):
|
|
"""Test that google_login doesn't generate CLI state when CLI parameters are missing"""
|
|
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
|
|
|
|
# Test various parameter combinations that shouldn't generate CLI state
|
|
test_cases = [
|
|
(None, None),
|
|
("litellm-cli", None),
|
|
(None, "sk-test123"),
|
|
("wrong-source", "sk-test123"),
|
|
]
|
|
|
|
for source, key in test_cases:
|
|
cli_state = SSOAuthenticationHandler._get_cli_state(source=source, key=key)
|
|
assert cli_state is None, f"CLI state should not be generated for source='{source}', key='{key}'"
|
|
|
|
|
|
class TestSSOHandlerIntegration:
|
|
"""Test SSOAuthenticationHandler methods"""
|
|
|
|
def test_should_use_sso_handler(self):
|
|
"""Test the SSO handler detection logic"""
|
|
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
|
|
|
|
# Test that SSO handler is used when client IDs are provided
|
|
assert SSOAuthenticationHandler.should_use_sso_handler(google_client_id="test") is True
|
|
assert SSOAuthenticationHandler.should_use_sso_handler(microsoft_client_id="test") is True
|
|
assert SSOAuthenticationHandler.should_use_sso_handler(generic_client_id="test") is True
|
|
|
|
# Test that SSO handler is not used when no client IDs are provided
|
|
assert SSOAuthenticationHandler.should_use_sso_handler() is False
|
|
assert SSOAuthenticationHandler.should_use_sso_handler(None, None, None) is False
|
|
|
|
def test_get_redirect_url_for_sso(self):
|
|
"""Test the redirect URL generation for SSO"""
|
|
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
|
|
|
|
# Mock request object
|
|
mock_request = MagicMock()
|
|
mock_request.base_url = "https://test.litellm.ai/"
|
|
|
|
# Test redirect URL generation
|
|
redirect_url = SSOAuthenticationHandler.get_redirect_url_for_sso(
|
|
request=mock_request,
|
|
sso_callback_route="sso/callback"
|
|
)
|
|
|
|
assert redirect_url.startswith("https://test.litellm.ai")
|
|
assert "sso/callback" in redirect_url
|
|
|
|
|
|
class TestUISSO_FunctionsExistence:
|
|
"""Test that all the new functions exist and are importable"""
|
|
|
|
def test_cli_sso_callback_exists(self):
|
|
"""Test that cli_sso_callback function exists"""
|
|
from litellm.proxy.management_endpoints.ui_sso import cli_sso_callback
|
|
assert callable(cli_sso_callback)
|
|
|
|
def test_cli_poll_key_exists(self):
|
|
"""Test that cli_poll_key function exists"""
|
|
from litellm.proxy.management_endpoints.ui_sso import cli_poll_key
|
|
assert callable(cli_poll_key)
|
|
|
|
def test_auth_callback_exists(self):
|
|
"""Test that auth_callback function exists"""
|
|
from litellm.proxy.management_endpoints.ui_sso import auth_callback
|
|
assert callable(auth_callback)
|
|
|
|
def test_google_login_exists(self):
|
|
"""Test that google_login function exists"""
|
|
from litellm.proxy.management_endpoints.ui_sso import google_login
|
|
assert callable(google_login)
|
|
|
|
def test_sso_authentication_handler_exists(self):
|
|
"""Test that SSOAuthenticationHandler class exists with new methods"""
|
|
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
|
|
|
|
# Check that the class exists
|
|
assert SSOAuthenticationHandler is not None
|
|
|
|
# Check that the new _get_cli_state method exists
|
|
assert hasattr(SSOAuthenticationHandler, '_get_cli_state')
|
|
assert callable(SSOAuthenticationHandler._get_cli_state)
|
|
|
|
|
|
class TestSSOStateHandling:
|
|
"""Test the SSO state handling for CLI authentication"""
|
|
|
|
def test_get_cli_state_valid(self):
|
|
"""Test generating CLI state with valid parameters"""
|
|
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
|
|
|
|
state = SSOAuthenticationHandler._get_cli_state(source="litellm-cli", key="sk-test123")
|
|
|
|
assert state is not None
|
|
assert state.startswith("litellm-session-token:")
|
|
assert "sk-test123" in state
|
|
|
|
def test_get_cli_state_invalid_source(self):
|
|
"""Test generating CLI state with invalid source"""
|
|
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
|
|
|
|
state = SSOAuthenticationHandler._get_cli_state(source="invalid_source", key="sk-test123")
|
|
|
|
assert state is None
|
|
|
|
def test_get_cli_state_no_key(self):
|
|
"""Test generating CLI state without key"""
|
|
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
|
|
|
|
state = SSOAuthenticationHandler._get_cli_state(source="litellm-cli", key=None)
|
|
|
|
assert state is None
|
|
|
|
def test_get_cli_state_no_source(self):
|
|
"""Test generating CLI state without source"""
|
|
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
|
|
|
|
state = SSOAuthenticationHandler._get_cli_state(source=None, key="sk-test123")
|
|
|
|
assert state is None
|
|
|
|
|
|
class TestStateRouting:
|
|
"""Test state parameter routing logic"""
|
|
|
|
def test_cli_state_detection(self):
|
|
"""Test detection of CLI state parameters"""
|
|
from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX
|
|
|
|
# Test CLI state format
|
|
cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:sk-test123"
|
|
assert cli_state.startswith(f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:")
|
|
|
|
# Test extraction of key from state
|
|
key_id = cli_state.split(":", 1)[1]
|
|
assert key_id == "sk-test123"
|
|
|
|
def test_non_cli_state_detection(self):
|
|
"""Test detection of non-CLI state parameters"""
|
|
from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX
|
|
|
|
# Test various non-CLI states
|
|
test_states = [
|
|
"regular_oauth_state",
|
|
"some_random_string",
|
|
None,
|
|
"",
|
|
"not_session_token:something"
|
|
]
|
|
|
|
for state in test_states:
|
|
if state:
|
|
assert not state.startswith(f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:")
|
|
else:
|
|
assert state != f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:"
|
|
|
|
|
|
class TestHTMLIntegration:
|
|
"""Test HTML rendering integration with CLI flow"""
|
|
|
|
def test_html_render_utils_import(self):
|
|
"""Test that HTML render utils can be imported correctly"""
|
|
from litellm.proxy.common_utils.html_forms.cli_sso_success import (
|
|
render_cli_sso_success_page,
|
|
)
|
|
|
|
# Test that function exists and is callable
|
|
assert callable(render_cli_sso_success_page)
|
|
|
|
# Test that it returns expected type
|
|
html = render_cli_sso_success_page()
|
|
|
|
assert isinstance(html, str)
|
|
assert len(html) > 0
|