mirror of
https://github.com/tiennm99/litellm.git
synced 2026-06-18 21:35:06 +00:00
649 lines
22 KiB
Python
649 lines
22 KiB
Python
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, Mock, mock_open, patch
|
|
|
|
sys.path.insert(
|
|
0, os.path.abspath("../../..")
|
|
) # Adds the parent directory to the system path
|
|
|
|
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
from litellm.proxy.client.cli.commands.auth import (
|
|
clear_token,
|
|
get_stored_api_key,
|
|
get_token_file_path,
|
|
load_token,
|
|
login,
|
|
logout,
|
|
save_token,
|
|
whoami,
|
|
)
|
|
|
|
|
|
class TestTokenUtilities:
|
|
"""Test token file utility functions"""
|
|
|
|
def test_get_token_file_path(self):
|
|
"""Test getting token file path"""
|
|
with (
|
|
patch("pathlib.Path.home") as mock_home,
|
|
patch("pathlib.Path.mkdir") as mock_mkdir,
|
|
):
|
|
mock_home.return_value = Path("/home/user")
|
|
|
|
result = get_token_file_path()
|
|
|
|
assert result == "/home/user/.litellm/token.json"
|
|
mock_mkdir.assert_called_once_with(exist_ok=True)
|
|
|
|
def test_get_token_file_path_creates_directory(self):
|
|
"""Test that get_token_file_path creates the config directory"""
|
|
with (
|
|
patch("pathlib.Path.home") as mock_home,
|
|
patch("pathlib.Path.mkdir") as mock_mkdir,
|
|
):
|
|
mock_home.return_value = Path("/home/user")
|
|
|
|
get_token_file_path()
|
|
|
|
mock_mkdir.assert_called_once_with(exist_ok=True)
|
|
|
|
def test_save_token(self):
|
|
"""Test saving token data to file"""
|
|
token_data = {
|
|
"key": "test-key",
|
|
"user_id": "test-user",
|
|
"timestamp": 1234567890,
|
|
}
|
|
|
|
with (
|
|
patch("builtins.open", mock_open()) as mock_file,
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.get_token_file_path"
|
|
) as mock_path,
|
|
patch("os.chmod") as mock_chmod,
|
|
):
|
|
|
|
mock_path.return_value = "/test/path/token.json"
|
|
|
|
save_token(token_data)
|
|
|
|
mock_file.assert_called_once_with("/test/path/token.json", "w")
|
|
mock_file().write.assert_called()
|
|
mock_chmod.assert_called_once_with("/test/path/token.json", 0o600)
|
|
|
|
# Verify JSON content was written correctly
|
|
written_content = "".join(
|
|
call[0][0] for call in mock_file().write.call_args_list
|
|
)
|
|
parsed_content = json.loads(written_content)
|
|
assert parsed_content == token_data
|
|
|
|
def test_load_token_success(self):
|
|
"""Test loading token data from file successfully"""
|
|
token_data = {
|
|
"key": "test-key",
|
|
"user_id": "test-user",
|
|
"timestamp": 1234567890,
|
|
}
|
|
|
|
with (
|
|
patch("builtins.open", mock_open(read_data=json.dumps(token_data))),
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.get_token_file_path"
|
|
) as mock_path,
|
|
patch("os.path.exists", return_value=True),
|
|
):
|
|
|
|
mock_path.return_value = "/test/path/token.json"
|
|
|
|
result = load_token()
|
|
|
|
assert result == token_data
|
|
|
|
def test_load_token_file_not_exists(self):
|
|
"""Test loading token when file doesn't exist"""
|
|
with (
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.get_token_file_path"
|
|
) as mock_path,
|
|
patch("os.path.exists", return_value=False),
|
|
):
|
|
|
|
mock_path.return_value = "/test/path/token.json"
|
|
|
|
result = load_token()
|
|
|
|
assert result is None
|
|
|
|
def test_load_token_json_decode_error(self):
|
|
"""Test loading token with invalid JSON"""
|
|
with (
|
|
patch("builtins.open", mock_open(read_data="invalid json")),
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.get_token_file_path"
|
|
) as mock_path,
|
|
patch("os.path.exists", return_value=True),
|
|
):
|
|
|
|
mock_path.return_value = "/test/path/token.json"
|
|
|
|
result = load_token()
|
|
|
|
assert result is None
|
|
|
|
def test_load_token_io_error(self):
|
|
"""Test loading token with IO error"""
|
|
with (
|
|
patch("builtins.open", side_effect=IOError("Permission denied")),
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.get_token_file_path"
|
|
) as mock_path,
|
|
patch("os.path.exists", return_value=True),
|
|
):
|
|
|
|
mock_path.return_value = "/test/path/token.json"
|
|
|
|
result = load_token()
|
|
|
|
assert result is None
|
|
|
|
def test_clear_token_file_exists(self):
|
|
"""Test clearing token when file exists"""
|
|
with (
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.get_token_file_path"
|
|
) as mock_path,
|
|
patch("os.path.exists", return_value=True),
|
|
patch("os.remove") as mock_remove,
|
|
):
|
|
|
|
mock_path.return_value = "/test/path/token.json"
|
|
|
|
clear_token()
|
|
|
|
mock_remove.assert_called_once_with("/test/path/token.json")
|
|
|
|
def test_clear_token_file_not_exists(self):
|
|
"""Test clearing token when file doesn't exist"""
|
|
with (
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.get_token_file_path"
|
|
) as mock_path,
|
|
patch("os.path.exists", return_value=False),
|
|
patch("os.remove") as mock_remove,
|
|
):
|
|
|
|
mock_path.return_value = "/test/path/token.json"
|
|
|
|
clear_token()
|
|
|
|
mock_remove.assert_not_called()
|
|
|
|
def test_get_stored_api_key_success(self):
|
|
"""Test getting stored API key successfully"""
|
|
token_data = {"key": "test-api-key-123", "user_id": "test-user"}
|
|
|
|
with patch(
|
|
"litellm.litellm_core_utils.cli_token_utils.load_cli_token",
|
|
return_value=token_data,
|
|
):
|
|
result = get_stored_api_key()
|
|
assert result == "test-api-key-123"
|
|
|
|
def test_get_stored_api_key_no_token(self):
|
|
"""Test getting stored API key when no token exists"""
|
|
with patch(
|
|
"litellm.litellm_core_utils.cli_token_utils.load_cli_token",
|
|
return_value=None,
|
|
):
|
|
result = get_stored_api_key()
|
|
assert result is None
|
|
|
|
def test_get_stored_api_key_no_key_field(self):
|
|
"""Test getting stored API key when token has no key field"""
|
|
token_data = {"user_id": "test-user"}
|
|
|
|
with patch(
|
|
"litellm.litellm_core_utils.cli_token_utils.load_cli_token",
|
|
return_value=token_data,
|
|
):
|
|
result = get_stored_api_key()
|
|
assert result is None
|
|
|
|
|
|
class TestLoginCommand:
|
|
"""Test login CLI command"""
|
|
|
|
def setup_method(self):
|
|
"""Setup for each test"""
|
|
self.runner = CliRunner()
|
|
|
|
def test_login_success(self):
|
|
"""Test successful login flow with single team (JWT generated immediately)"""
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
# Mock the requests for successful authentication with single team
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"status": "ready",
|
|
"key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.jwt",
|
|
"user_id": "test-user-123",
|
|
"team_id": "team-1",
|
|
"teams": ["team-1"],
|
|
}
|
|
|
|
with (
|
|
patch("webbrowser.open") as mock_browser,
|
|
patch("requests.get", return_value=mock_response) as mock_get,
|
|
patch("litellm.proxy.client.cli.commands.auth.save_token") as mock_save,
|
|
patch(
|
|
"litellm.proxy.client.cli.interface.show_commands"
|
|
) as mock_show_commands,
|
|
patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"),
|
|
):
|
|
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Login successful!" in result.output
|
|
assert "Automatically assigned to team: team-1" in result.output
|
|
|
|
# Verify browser was opened with correct URL
|
|
mock_browser.assert_called_once()
|
|
call_args = mock_browser.call_args[0][0]
|
|
assert "https://test.example.com/sso/key/generate" in call_args
|
|
assert "sk-test-uuid-123" in call_args
|
|
|
|
# Verify JWT was saved
|
|
mock_save.assert_called_once()
|
|
saved_data = mock_save.call_args[0][0]
|
|
assert saved_data["key"] == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.jwt"
|
|
assert saved_data["user_id"] == "test-user-123"
|
|
|
|
# Verify commands were shown
|
|
mock_show_commands.assert_called_once()
|
|
|
|
def test_login_timeout(self):
|
|
"""Test login timeout scenario"""
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
# Mock response that never returns ready status
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {"status": "pending"}
|
|
|
|
with (
|
|
patch("webbrowser.open"),
|
|
patch("requests.get", return_value=mock_response),
|
|
patch("time.sleep") as mock_sleep,
|
|
patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"),
|
|
):
|
|
|
|
# Mock time.sleep to avoid actual delays in tests
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "❌ Authentication timed out" in result.output
|
|
|
|
def test_login_http_error(self):
|
|
"""Test login with HTTP error"""
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
# Mock response with HTTP error
|
|
mock_response = Mock()
|
|
mock_response.status_code = 500
|
|
|
|
with (
|
|
patch("webbrowser.open"),
|
|
patch("requests.get", return_value=mock_response),
|
|
patch("time.sleep"),
|
|
patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"),
|
|
):
|
|
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "❌ Authentication timed out" in result.output
|
|
|
|
def test_login_request_exception(self):
|
|
"""Test login with request exception"""
|
|
import requests
|
|
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
with (
|
|
patch("webbrowser.open"),
|
|
patch(
|
|
"requests.get",
|
|
side_effect=requests.RequestException("Connection failed"),
|
|
),
|
|
patch("time.sleep"),
|
|
patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"),
|
|
):
|
|
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "❌ Authentication timed out" in result.output
|
|
|
|
def test_login_keyboard_interrupt(self):
|
|
"""Test login cancelled by user"""
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
with (
|
|
patch("webbrowser.open"),
|
|
patch("requests.get", side_effect=KeyboardInterrupt),
|
|
patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"),
|
|
):
|
|
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "❌ Authentication cancelled by user" in result.output
|
|
|
|
def test_login_no_api_key_in_response(self):
|
|
"""Test login when response doesn't contain API key"""
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
# Mock response without API key
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"status": "ready"
|
|
# Missing 'key' field
|
|
}
|
|
|
|
with (
|
|
patch("webbrowser.open"),
|
|
patch("requests.get", return_value=mock_response),
|
|
patch("time.sleep"),
|
|
patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"),
|
|
):
|
|
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "❌ Authentication timed out" in result.output
|
|
|
|
def test_login_general_exception(self):
|
|
"""Test login with general exception (not requests exception)"""
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
with (
|
|
patch("webbrowser.open"),
|
|
patch("requests.get", side_effect=ValueError("Invalid value")),
|
|
patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"),
|
|
):
|
|
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "❌ Authentication failed: Invalid value" in result.output
|
|
|
|
|
|
class TestLogoutCommand:
|
|
"""Test logout CLI command"""
|
|
|
|
def setup_method(self):
|
|
"""Setup for each test"""
|
|
self.runner = CliRunner()
|
|
|
|
def test_logout_success(self):
|
|
"""Test successful logout"""
|
|
with patch("litellm.proxy.client.cli.commands.auth.clear_token") as mock_clear:
|
|
result = self.runner.invoke(logout)
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Logged out successfully" in result.output
|
|
mock_clear.assert_called_once()
|
|
|
|
|
|
class TestWhoamiCommand:
|
|
"""Test whoami CLI command"""
|
|
|
|
def setup_method(self):
|
|
"""Setup for each test"""
|
|
self.runner = CliRunner()
|
|
|
|
def test_whoami_authenticated(self):
|
|
"""Test whoami when user is authenticated"""
|
|
token_data = {
|
|
"user_email": "test@example.com",
|
|
"user_id": "test-user-123",
|
|
"user_role": "admin",
|
|
"timestamp": time.time() - 3600, # 1 hour ago
|
|
}
|
|
|
|
with patch(
|
|
"litellm.proxy.client.cli.commands.auth.load_token", return_value=token_data
|
|
):
|
|
result = self.runner.invoke(whoami)
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Authenticated" in result.output
|
|
assert "test@example.com" in result.output
|
|
assert "test-user-123" in result.output
|
|
assert "admin" in result.output
|
|
assert "Token age: 1.0 hours" in result.output
|
|
|
|
def test_whoami_not_authenticated(self):
|
|
"""Test whoami when user is not authenticated"""
|
|
with patch(
|
|
"litellm.proxy.client.cli.commands.auth.load_token", return_value=None
|
|
):
|
|
result = self.runner.invoke(whoami)
|
|
|
|
assert result.exit_code == 0
|
|
assert "❌ Not authenticated" in result.output
|
|
assert "Run 'litellm-proxy login'" in result.output
|
|
|
|
def test_whoami_old_token(self):
|
|
"""Test whoami with old token showing warning"""
|
|
token_data = {
|
|
"user_email": "test@example.com",
|
|
"user_id": "test-user-123",
|
|
"user_role": "admin",
|
|
"timestamp": time.time() - (25 * 3600), # 25 hours ago
|
|
}
|
|
|
|
with patch(
|
|
"litellm.proxy.client.cli.commands.auth.load_token", return_value=token_data
|
|
):
|
|
result = self.runner.invoke(whoami)
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Authenticated" in result.output
|
|
assert "⚠️ Warning: Token is more than 24 hours old" in result.output
|
|
|
|
def test_whoami_missing_fields(self):
|
|
"""Test whoami with token missing some fields"""
|
|
token_data = {
|
|
"timestamp": time.time()
|
|
- 3600
|
|
# Missing user_email, user_id, user_role
|
|
}
|
|
|
|
with patch(
|
|
"litellm.proxy.client.cli.commands.auth.load_token", return_value=token_data
|
|
):
|
|
result = self.runner.invoke(whoami)
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Authenticated" in result.output
|
|
assert (
|
|
"Unknown" in result.output
|
|
) # Should show "Unknown" for missing fields
|
|
|
|
def test_whoami_no_timestamp(self):
|
|
"""Test whoami with token missing timestamp"""
|
|
token_data = {
|
|
"user_email": "test@example.com",
|
|
"user_id": "test-user-123",
|
|
"user_role": "admin",
|
|
# Missing timestamp
|
|
}
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.load_token",
|
|
return_value=token_data,
|
|
),
|
|
patch("time.time", return_value=1000),
|
|
):
|
|
|
|
result = self.runner.invoke(whoami)
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Authenticated" in result.output
|
|
# Should calculate age based on timestamp=0
|
|
assert "Token age:" in result.output
|
|
|
|
|
|
class TestCLIKeyRegenerationFlow:
|
|
"""Test the end-to-end CLI key regeneration flow from CLI perspective"""
|
|
|
|
def setup_method(self):
|
|
"""Setup for each test"""
|
|
self.runner = CliRunner()
|
|
|
|
def test_login_with_team_selection_flow(self):
|
|
"""Test complete login flow when user has multiple teams - should prompt for selection"""
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
# Mock first response - requires team selection
|
|
mock_first_response = Mock()
|
|
mock_first_response.status_code = 200
|
|
mock_first_response.json.return_value = {
|
|
"status": "ready",
|
|
"requires_team_selection": True,
|
|
"user_id": "test-user-456",
|
|
"teams": ["team-alpha", "team-beta", "team-gamma"],
|
|
# New richer response with team details including aliases
|
|
"team_details": [
|
|
{"team_id": "team-alpha", "team_alias": "Alpha Team"},
|
|
{"team_id": "team-beta", "team_alias": "Beta Team"},
|
|
{"team_id": "team-gamma", "team_alias": "Gamma Team"},
|
|
],
|
|
}
|
|
|
|
# Mock second response after team selection - JWT with selected team
|
|
mock_second_response = Mock()
|
|
mock_second_response.status_code = 200
|
|
mock_second_response.json.return_value = {
|
|
"status": "ready",
|
|
"key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.team-beta.jwt",
|
|
"user_id": "test-user-456",
|
|
"team_id": "team-beta",
|
|
"teams": ["team-alpha", "team-beta", "team-gamma"],
|
|
}
|
|
|
|
# Simulate user selecting team #2 (team-beta)
|
|
with (
|
|
patch("webbrowser.open") as mock_browser,
|
|
patch(
|
|
"requests.get", side_effect=[mock_first_response, mock_second_response]
|
|
) as mock_get,
|
|
patch("litellm.proxy.client.cli.commands.auth.save_token") as mock_save,
|
|
patch(
|
|
"litellm.proxy.client.cli.interface.show_commands"
|
|
) as mock_show_commands,
|
|
patch("litellm._uuid.uuid.uuid4", return_value="session-uuid-456"),
|
|
patch("click.prompt", return_value="2"),
|
|
): # User selects index 2
|
|
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Login successful!" in result.output
|
|
assert "team-beta" in result.output
|
|
# Ensure we surface the human-readable team alias to the user
|
|
assert "Beta Team" in result.output
|
|
|
|
# Verify browser was opened
|
|
mock_browser.assert_called_once()
|
|
call_args = mock_browser.call_args[0][0]
|
|
assert "https://test.example.com/sso/key/generate" in call_args
|
|
|
|
# Verify two polling requests were made
|
|
assert mock_get.call_count == 2
|
|
|
|
# First poll should be without team_id
|
|
first_poll_url = mock_get.call_args_list[0][0][0]
|
|
assert "sk-session-uuid-456" in first_poll_url
|
|
assert "team_id=" not in first_poll_url
|
|
|
|
# Second poll should include team_id=team-beta
|
|
second_poll_url = mock_get.call_args_list[1][0][0]
|
|
assert "team_id=team-beta" in second_poll_url
|
|
|
|
# Verify JWT was saved
|
|
mock_save.assert_called_once()
|
|
saved_data = mock_save.call_args[0][0]
|
|
assert (
|
|
saved_data["key"]
|
|
== "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.team-beta.jwt"
|
|
)
|
|
assert saved_data["user_id"] == "test-user-456"
|
|
|
|
mock_show_commands.assert_called_once()
|
|
|
|
def test_login_without_teams_flow(self):
|
|
"""Test complete login flow when user has no teams - JWT generated without team"""
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
# Mock response with no teams
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"status": "ready",
|
|
"key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.no-team.jwt",
|
|
"user_id": "test-user-solo",
|
|
"team_id": None,
|
|
"teams": [],
|
|
}
|
|
|
|
with (
|
|
patch("webbrowser.open") as mock_browser,
|
|
patch("requests.get", return_value=mock_response),
|
|
patch("litellm.proxy.client.cli.commands.auth.save_token") as mock_save,
|
|
patch("litellm.proxy.client.cli.interface.show_commands"),
|
|
patch("litellm._uuid.uuid.uuid4", return_value="session-uuid-solo"),
|
|
):
|
|
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "✅ Login successful!" in result.output
|
|
|
|
# Verify browser was opened
|
|
mock_browser.assert_called_once()
|
|
call_args = mock_browser.call_args[0][0]
|
|
assert "https://test.example.com/sso/key/generate" in call_args
|
|
assert "source=litellm-cli" in call_args
|
|
assert "key=sk-session-uuid-solo" in call_args
|
|
|
|
# Verify JWT was saved
|
|
mock_save.assert_called_once()
|
|
saved_data = mock_save.call_args[0][0]
|
|
assert (
|
|
saved_data["key"] == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.no-team.jwt"
|
|
)
|
|
assert saved_data["user_id"] == "test-user-solo"
|