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"