mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
1201 lines
47 KiB
Python
1201 lines
47 KiB
Python
"""
|
|
Tests for login_utils module.
|
|
|
|
This module tests the refactored login logic that was moved from proxy_server.py
|
|
to login_utils.py for better reusability.
|
|
"""
|
|
|
|
import hashlib
|
|
import os
|
|
from contextlib import ExitStack
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from litellm.constants import LITELLM_PROXY_ADMIN_NAME
|
|
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
|
from litellm.proxy._types import (
|
|
LiteLLM_UserTable,
|
|
LitellmUserRoles,
|
|
ProxyErrorTypes,
|
|
ProxyException,
|
|
hash_token,
|
|
)
|
|
from litellm.proxy.auth.login_utils import (
|
|
LoginResult,
|
|
authenticate_user,
|
|
get_ui_credentials,
|
|
is_env_credential_login_enabled,
|
|
screen_login_password_for_breach,
|
|
)
|
|
|
|
# Successful DB-user logins schedule the background HIBP screen; disable it so
|
|
# no test ever does live network I/O to haveibeenpwned.com from CI.
|
|
_POLICY_NO_BREACH_CHECK = {"password_policy_check_breached_passwords": False}
|
|
|
|
|
|
def test_get_ui_credentials_prefers_explicit_password():
|
|
"""The configured UI password should be returned when available."""
|
|
with patch.dict(
|
|
os.environ,
|
|
{"UI_USERNAME": "test-admin", "UI_PASSWORD": "secure-pass"},
|
|
clear=True,
|
|
):
|
|
username, password = get_ui_credentials(master_key="sk-123")
|
|
|
|
assert username == "test-admin"
|
|
assert password == "secure-pass"
|
|
|
|
|
|
def test_get_ui_credentials_can_use_master_key():
|
|
"""Master key should be used as password when UI_PASSWORD is missing."""
|
|
with patch.dict(os.environ, {"UI_USERNAME": "fallback-admin"}, clear=True):
|
|
username, password = get_ui_credentials(master_key="fallback-key")
|
|
|
|
assert username == "fallback-admin"
|
|
assert password == "fallback-key"
|
|
|
|
|
|
def test_get_ui_credentials_requires_password():
|
|
"""Missing UI password and master key results in error."""
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
with pytest.raises(ProxyException) as exc_info:
|
|
get_ui_credentials(master_key=None)
|
|
|
|
assert exc_info.value.type == ProxyErrorTypes.auth_error
|
|
assert exc_info.value.code == "500"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_user_admin_login_with_ui_credentials():
|
|
"""Test admin login using UI_USERNAME and UI_PASSWORD"""
|
|
master_key = "sk-1234"
|
|
ui_username = "admin"
|
|
ui_password = "sk-1234"
|
|
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"UI_USERNAME": ui_username,
|
|
"UI_PASSWORD": ui_password,
|
|
"DATABASE_URL": "postgresql://test:test@localhost/test",
|
|
},
|
|
):
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.generate_key_helper_fn",
|
|
new_callable=AsyncMock,
|
|
) as mock_generate_key:
|
|
mock_generate_key.return_value = {
|
|
"token": "test-token-123",
|
|
"user_id": LITELLM_PROXY_ADMIN_NAME,
|
|
}
|
|
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.user_update",
|
|
new_callable=AsyncMock,
|
|
return_value=None,
|
|
) as mock_user_update:
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.get_secret_bool",
|
|
return_value=False,
|
|
):
|
|
result = await authenticate_user(
|
|
username=ui_username,
|
|
password=ui_password,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
)
|
|
|
|
assert isinstance(result, LoginResult)
|
|
assert result.user_id == LITELLM_PROXY_ADMIN_NAME
|
|
assert result.key == "test-token-123"
|
|
assert result.user_email is None
|
|
assert result.user_role == LitellmUserRoles.PROXY_ADMIN
|
|
assert result.login_method == "username_password"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_user_admin_login_with_master_key_as_password(monkeypatch):
|
|
"""Test admin login when UI_PASSWORD is not set, should use master_key"""
|
|
master_key = "sk-1234"
|
|
ui_username = "admin"
|
|
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
|
|
|
env_vars = {
|
|
"UI_USERNAME": ui_username,
|
|
"DATABASE_URL": "postgresql://test:test@localhost/test",
|
|
}
|
|
# Remove UI_PASSWORD to test fallback to master_key
|
|
if "UI_PASSWORD" in os.environ:
|
|
# Keep other env vars but don't set UI_PASSWORD
|
|
pass
|
|
else:
|
|
# Ensure UI_PASSWORD is not in the patched env
|
|
pass
|
|
|
|
with patch.dict(os.environ, env_vars, clear=False):
|
|
# Explicitly remove UI_PASSWORD if it exists
|
|
monkeypatch.delenv("UI_PASSWORD", raising=False)
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.generate_key_helper_fn",
|
|
new_callable=AsyncMock,
|
|
) as mock_generate_key:
|
|
mock_generate_key.return_value = {
|
|
"token": "test-token-123",
|
|
"user_id": LITELLM_PROXY_ADMIN_NAME,
|
|
}
|
|
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.user_update",
|
|
new_callable=AsyncMock,
|
|
return_value=None,
|
|
) as mock_user_update:
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.get_secret_bool",
|
|
return_value=False,
|
|
):
|
|
result = await authenticate_user(
|
|
username=ui_username,
|
|
password=master_key,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
)
|
|
|
|
assert isinstance(result, LoginResult)
|
|
assert result.user_id == LITELLM_PROXY_ADMIN_NAME
|
|
assert result.user_role == LitellmUserRoles.PROXY_ADMIN
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_user_invalid_credentials():
|
|
"""Test authentication failure with invalid credentials"""
|
|
master_key = "sk-1234"
|
|
ui_username = "admin"
|
|
wrong_password = "wrong-password"
|
|
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
|
|
|
with patch.dict(os.environ, {"UI_USERNAME": ui_username, "UI_PASSWORD": "correct-password"}):
|
|
with pytest.raises(ProxyException) as exc_info:
|
|
await authenticate_user(
|
|
username=ui_username,
|
|
password=wrong_password,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
)
|
|
|
|
assert exc_info.value.type == ProxyErrorTypes.auth_error
|
|
assert exc_info.value.code == "401"
|
|
assert "Invalid credentials" in exc_info.value.message
|
|
assert "UI_USERNAME" in exc_info.value.message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_user_missing_master_key():
|
|
"""Test authentication failure when master_key is None"""
|
|
mock_prisma_client = MagicMock()
|
|
|
|
with pytest.raises(ProxyException) as exc_info:
|
|
await authenticate_user(
|
|
username="admin",
|
|
password="password",
|
|
master_key=None,
|
|
prisma_client=mock_prisma_client,
|
|
)
|
|
|
|
assert exc_info.value.type == ProxyErrorTypes.auth_error
|
|
assert exc_info.value.code == "500"
|
|
assert "Master Key not set" in exc_info.value.message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_user_wrong_password():
|
|
"""Test authentication failure with wrong password for database user"""
|
|
master_key = "sk-1234"
|
|
user_email = "test@example.com"
|
|
correct_password = "correct-password"
|
|
wrong_password = "wrong-password"
|
|
hashed_password = hash_token(token=correct_password)
|
|
|
|
mock_user = LiteLLM_UserTable(
|
|
user_id="test-user-123",
|
|
user_email=user_email,
|
|
password=hashed_password,
|
|
user_role=LitellmUserRoles.INTERNAL_USER,
|
|
)
|
|
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=mock_user)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"DATABASE_URL": "postgresql://test:test@localhost/test",
|
|
"UI_USERNAME": "admin",
|
|
"UI_PASSWORD": "admin-password",
|
|
},
|
|
):
|
|
with pytest.raises(ProxyException) as exc_info:
|
|
await authenticate_user(
|
|
username=user_email,
|
|
password=wrong_password,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
)
|
|
|
|
assert exc_info.value.type == ProxyErrorTypes.auth_error
|
|
assert exc_info.value.code == "401"
|
|
assert "Invalid credentials" in exc_info.value.message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_user_email_case_insensitive_login():
|
|
"""Test that email lookup is case-insensitive during login"""
|
|
master_key = "sk-1234"
|
|
stored_email = "testemail@test.com"
|
|
login_email_mixed_case = "testEmail@test.com"
|
|
correct_password = "correct-password"
|
|
hashed_password = hash_token(token=correct_password)
|
|
|
|
# `LiteLLM_UserTable` does not define a `password` field, but `authenticate_user()`
|
|
# expects `user_row.password` to exist (invite-link login). Use a simple object.
|
|
mock_user = MagicMock()
|
|
mock_user.user_id = "test-user-123"
|
|
mock_user.user_email = stored_email
|
|
mock_user.password = hashed_password
|
|
mock_user.user_role = LitellmUserRoles.INTERNAL_USER
|
|
|
|
def mock_find_first(**kwargs):
|
|
where = kwargs.get("where", {})
|
|
user_email = where.get("user_email", {})
|
|
if user_email.get("mode") != "insensitive":
|
|
return None
|
|
if str(user_email.get("equals", "")).lower() == stored_email.lower():
|
|
return mock_user
|
|
return None
|
|
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(side_effect=mock_find_first)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"DATABASE_URL": "postgresql://test:test@localhost/test",
|
|
"UI_USERNAME": "admin",
|
|
"UI_PASSWORD": "admin-password",
|
|
},
|
|
):
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.generate_key_helper_fn",
|
|
new_callable=AsyncMock,
|
|
) as mock_generate_key:
|
|
mock_generate_key.side_effect = [
|
|
{"token": "token-1"},
|
|
{"token": "token-2"},
|
|
]
|
|
|
|
result_mixed = await authenticate_user(
|
|
username=login_email_mixed_case,
|
|
password=correct_password,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
general_settings=_POLICY_NO_BREACH_CHECK,
|
|
)
|
|
result_lower = await authenticate_user(
|
|
username=stored_email,
|
|
password=correct_password,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
general_settings=_POLICY_NO_BREACH_CHECK,
|
|
)
|
|
|
|
assert result_mixed.user_id == result_lower.user_id == "test-user-123"
|
|
assert result_mixed.user_email == result_lower.user_email == stored_email
|
|
|
|
calls = mock_prisma_client.db.litellm_usertable.find_first.await_args_list
|
|
assert len(calls) == 2
|
|
for call, expected_username in zip(calls, [login_email_mixed_case, stored_email]):
|
|
where = call.kwargs["where"]
|
|
assert where["user_email"]["equals"] == expected_username
|
|
assert where["user_email"]["mode"] == "insensitive"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_user_database_required_for_admin(monkeypatch):
|
|
"""Test that database is required for admin login"""
|
|
master_key = "sk-1234"
|
|
ui_username = "admin"
|
|
ui_password = "sk-1234"
|
|
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
|
|
|
with patch.dict(os.environ, {"UI_USERNAME": ui_username, "UI_PASSWORD": ui_password}):
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.user_update",
|
|
new_callable=AsyncMock,
|
|
return_value=None,
|
|
):
|
|
# Remove DATABASE_URL to simulate no database
|
|
original_db_url = os.environ.get("DATABASE_URL")
|
|
if "DATABASE_URL" in os.environ:
|
|
del os.environ["DATABASE_URL"]
|
|
|
|
try:
|
|
with pytest.raises(ProxyException) as exc_info:
|
|
await authenticate_user(
|
|
username=ui_username,
|
|
password=ui_password,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
)
|
|
|
|
assert exc_info.value.type == ProxyErrorTypes.auth_error
|
|
assert exc_info.value.code == "500"
|
|
assert "No Database connected" in exc_info.value.message
|
|
finally:
|
|
if original_db_url:
|
|
monkeypatch.setenv("DATABASE_URL", original_db_url)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_user_admin_login_with_non_ascii_characters():
|
|
"""Test admin login with non-ASCII characters in password (issue #19559)"""
|
|
master_key = "sk-1234"
|
|
ui_username = "admin£test"
|
|
ui_password = "sk-1234£pass"
|
|
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"UI_USERNAME": ui_username,
|
|
"UI_PASSWORD": ui_password,
|
|
"DATABASE_URL": "postgresql://test:test@localhost/test",
|
|
},
|
|
):
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.generate_key_helper_fn",
|
|
new_callable=AsyncMock,
|
|
) as mock_generate_key:
|
|
mock_generate_key.return_value = {
|
|
"token": "test-token-123",
|
|
"user_id": LITELLM_PROXY_ADMIN_NAME,
|
|
}
|
|
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.user_update",
|
|
new_callable=AsyncMock,
|
|
return_value=None,
|
|
) as mock_user_update:
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.get_secret_bool",
|
|
return_value=False,
|
|
):
|
|
result = await authenticate_user(
|
|
username=ui_username,
|
|
password=ui_password,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
)
|
|
|
|
assert isinstance(result, LoginResult)
|
|
assert result.user_id == LITELLM_PROXY_ADMIN_NAME
|
|
assert result.key == "test-token-123"
|
|
assert result.user_role == LitellmUserRoles.PROXY_ADMIN
|
|
|
|
|
|
def test_authenticate_user_non_ascii_direct_comparison():
|
|
"""Test that non-ASCII characters can be compared directly (unit test for fix)"""
|
|
import secrets
|
|
|
|
# This test verifies the fix handles non-ASCII by encoding to bytes
|
|
username = "admin£test"
|
|
password = "pass£word"
|
|
|
|
# This would fail without encoding:
|
|
# secrets.compare_digest(username, username) # TypeError!
|
|
|
|
# But works with the fix:
|
|
result = secrets.compare_digest(username.encode("utf-8"), username.encode("utf-8"))
|
|
assert result is True
|
|
|
|
# And correctly returns False for different passwords
|
|
result = secrets.compare_digest(password.encode("utf-8"), "different£pass".encode("utf-8"))
|
|
assert result is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_user_multiple_logins_generate_unique_tokens():
|
|
"""Test that multiple logins for the same user each generate unique tokens.
|
|
|
|
This test verifies that users can have multiple concurrent UI sessions.
|
|
Previous UI session tokens should NOT be expired/blocked when a new session is created.
|
|
"""
|
|
master_key = "sk-1234"
|
|
ui_username = "admin"
|
|
ui_password = "sk-1234"
|
|
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"UI_USERNAME": ui_username,
|
|
"UI_PASSWORD": ui_password,
|
|
"DATABASE_URL": "postgresql://test:test@localhost/test",
|
|
},
|
|
):
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.generate_key_helper_fn",
|
|
new_callable=AsyncMock,
|
|
) as mock_generate_key:
|
|
# Each login should generate a unique token
|
|
mock_generate_key.side_effect = [
|
|
{"token": "session-token-1", "user_id": LITELLM_PROXY_ADMIN_NAME},
|
|
{"token": "session-token-2", "user_id": LITELLM_PROXY_ADMIN_NAME},
|
|
{"token": "session-token-3", "user_id": LITELLM_PROXY_ADMIN_NAME},
|
|
]
|
|
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.user_update",
|
|
new_callable=AsyncMock,
|
|
return_value=None,
|
|
):
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.get_secret_bool",
|
|
return_value=False,
|
|
):
|
|
# Simulate multiple logins from the same user
|
|
result1 = await authenticate_user(
|
|
username=ui_username,
|
|
password=ui_password,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
)
|
|
result2 = await authenticate_user(
|
|
username=ui_username,
|
|
password=ui_password,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
)
|
|
result3 = await authenticate_user(
|
|
username=ui_username,
|
|
password=ui_password,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
)
|
|
|
|
# Each login should return a unique token
|
|
assert result1.key == "session-token-1"
|
|
assert result2.key == "session-token-2"
|
|
assert result3.key == "session-token-3"
|
|
|
|
# All tokens should be different (concurrent sessions allowed)
|
|
assert len({result1.key, result2.key, result3.key}) == 3
|
|
|
|
# generate_key_helper_fn should be called 3 times (once per login)
|
|
assert mock_generate_key.call_count == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_user_database_login_with_non_ascii_password():
|
|
"""Test database user login with non-ASCII characters in password (issue #19559)"""
|
|
master_key = "sk-1234"
|
|
user_email = "test@example.com"
|
|
password_with_special_char = "correct£password"
|
|
hashed_password = hash_token(token=password_with_special_char)
|
|
|
|
mock_user = MagicMock()
|
|
mock_user.user_id = "test-user-123"
|
|
mock_user.user_email = user_email
|
|
mock_user.password = hashed_password
|
|
mock_user.user_role = LitellmUserRoles.INTERNAL_USER
|
|
|
|
def mock_find_first(**kwargs):
|
|
where = kwargs.get("where", {})
|
|
user_email_filter = where.get("user_email", {})
|
|
if str(user_email_filter.get("equals", "")).lower() == user_email.lower():
|
|
return mock_user
|
|
return None
|
|
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(side_effect=mock_find_first)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"DATABASE_URL": "postgresql://test:test@localhost/test",
|
|
"UI_USERNAME": "admin",
|
|
"UI_PASSWORD": "admin-password",
|
|
},
|
|
):
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.generate_key_helper_fn",
|
|
new_callable=AsyncMock,
|
|
) as mock_generate_key:
|
|
mock_generate_key.return_value = {"token": "token-123"}
|
|
|
|
result = await authenticate_user(
|
|
username=user_email,
|
|
password=password_with_special_char,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
general_settings=_POLICY_NO_BREACH_CHECK,
|
|
)
|
|
|
|
assert isinstance(result, LoginResult)
|
|
assert result.user_id == "test-user-123"
|
|
assert result.user_email == user_email
|
|
|
|
|
|
class TestEncodeUiSessionJwt:
|
|
"""The UI session cookie must carry a bounded exp so it does not stay
|
|
signature-valid until the master key rotates, and so the session-cookie readers
|
|
that require a bounded lifetime (the MCP interactive sign-in) accept it."""
|
|
|
|
def _decode(self, token: str) -> dict:
|
|
import jwt
|
|
|
|
return jwt.decode(token, "sk-master-for-tests", algorithms=["HS256"])
|
|
|
|
def test_encoded_cookie_carries_bounded_exp(self):
|
|
import time
|
|
|
|
from litellm.proxy.auth.login_utils import encode_ui_session_jwt
|
|
|
|
token_object = {"user_id": "u1", "key": "sk-abc", "login_method": "username_password"}
|
|
with patch("litellm.proxy.auth.login_utils.LITELLM_UI_SESSION_DURATION", "24h"):
|
|
token = encode_ui_session_jwt(token_object, "sk-master-for-tests")
|
|
claims = self._decode(token)
|
|
assert claims["user_id"] == "u1"
|
|
assert claims["login_method"] == "username_password"
|
|
remaining = claims["exp"] - int(time.time())
|
|
assert 23 * 3600 < remaining <= 24 * 3600
|
|
|
|
def test_duration_is_honored_from_env(self):
|
|
import time
|
|
|
|
from litellm.proxy.auth.login_utils import encode_ui_session_jwt
|
|
|
|
with patch("litellm.proxy.auth.login_utils.LITELLM_UI_SESSION_DURATION", "1h"):
|
|
token = encode_ui_session_jwt({"user_id": "u1"}, "sk-master-for-tests")
|
|
remaining = self._decode(token)["exp"] - int(time.time())
|
|
assert 0 < remaining <= 3600
|
|
|
|
def test_cookie_is_accepted_by_the_exp_requiring_session_reader(self):
|
|
"""The regression this change exists for: before it, the UI cookie carried no
|
|
exp and _user_id_from_session_cookie (require=["exp"]) rejected every real login,
|
|
so the MCP interactive sign-in could never capture identity. A cookie minted by
|
|
this helper must now be accepted."""
|
|
from unittest.mock import MagicMock
|
|
|
|
from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import (
|
|
_user_id_from_session_cookie,
|
|
)
|
|
from litellm.proxy.auth.login_utils import encode_ui_session_jwt
|
|
|
|
token_object = {"user_id": "cornell-user", "key": "sk-abc", "login_method": "sso"}
|
|
with patch("litellm.proxy.auth.login_utils.LITELLM_UI_SESSION_DURATION", "24h"):
|
|
token = encode_ui_session_jwt(token_object, "sk-master-for-tests")
|
|
request = MagicMock()
|
|
request.cookies = {"token": token}
|
|
with patch("litellm.proxy.proxy_server.master_key", "sk-master-for-tests"):
|
|
assert _user_id_from_session_cookie(request) == "cornell-user"
|
|
|
|
|
|
def _patch_sso_configured(stack: ExitStack, *, configured: bool) -> None:
|
|
stack.enter_context(
|
|
patch( # test-quality-ok: no HTTP boundary here; same internal the pre-existing tests above already mock
|
|
"litellm.proxy.auth.login_utils.is_sso_provider_fully_configured", return_value=configured
|
|
)
|
|
)
|
|
|
|
|
|
def _patch_successful_admin_login_deps(stack: ExitStack) -> None:
|
|
"""The collaborators a real admin login exercises past the SSO gate:
|
|
generating the UI session key, syncing the admin role, and reading the
|
|
experimental-login flag. Shared so the two "still allowed" tests below
|
|
don't each repeat the same three-mock wiring."""
|
|
stack.enter_context(
|
|
patch( # test-quality-ok: internal orchestration, no HTTP boundary; matches pre-existing tests
|
|
"litellm.proxy.auth.login_utils.generate_key_helper_fn",
|
|
new_callable=AsyncMock,
|
|
return_value={"token": "test-token", "user_id": LITELLM_PROXY_ADMIN_NAME},
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch( # test-quality-ok: internal orchestration, no HTTP boundary; matches pre-existing tests
|
|
"litellm.proxy.auth.login_utils.user_update",
|
|
new_callable=AsyncMock,
|
|
return_value=None,
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch( # test-quality-ok: internal orchestration, no HTTP boundary; matches pre-existing tests
|
|
"litellm.proxy.auth.login_utils.get_secret_bool",
|
|
return_value=False,
|
|
)
|
|
)
|
|
|
|
|
|
class TestDisablePasswordLoginWhenSSOEnabled:
|
|
"""`disable_password_login_when_sso_enabled` must reject every
|
|
username/password login attempt (including the UI_USERNAME/UI_PASSWORD
|
|
admin fallback) once SSO is configured, so SSO becomes the only way to
|
|
reach the Admin UI. It must not affect logins when SSO is unconfigured,
|
|
so admins can never lock themselves out with no SSO to fall back to."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejects_correct_admin_credentials_when_sso_configured(self):
|
|
master_key = "sk-1234"
|
|
ui_username = "admin"
|
|
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
|
|
|
with patch.dict(os.environ, {"UI_USERNAME": ui_username, "UI_PASSWORD": master_key}):
|
|
with ExitStack() as stack:
|
|
_patch_sso_configured(stack, configured=True)
|
|
with pytest.raises(ProxyException) as exc_info:
|
|
await authenticate_user(
|
|
username=ui_username,
|
|
password=master_key,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
general_settings={"disable_password_login_when_sso_enabled": True},
|
|
)
|
|
|
|
assert exc_info.value.type == ProxyErrorTypes.auth_error
|
|
assert exc_info.value.code == "403"
|
|
# The credential comparison must never even run.
|
|
mock_prisma_client.db.litellm_usertable.find_first.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejects_correct_db_user_credentials_when_sso_configured(self):
|
|
master_key = "sk-1234"
|
|
user_email = "test@example.com"
|
|
password = "correct-password"
|
|
|
|
mock_user = LiteLLM_UserTable(
|
|
user_id="test-user-123",
|
|
user_email=user_email,
|
|
password=hash_token(token=password),
|
|
user_role=LitellmUserRoles.INTERNAL_USER,
|
|
)
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=mock_user)
|
|
|
|
with patch.dict(os.environ, {"UI_USERNAME": "admin", "UI_PASSWORD": "unrelated"}):
|
|
with ExitStack() as stack:
|
|
_patch_sso_configured(stack, configured=True)
|
|
with pytest.raises(ProxyException) as exc_info:
|
|
await authenticate_user(
|
|
username=user_email,
|
|
password=password,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
general_settings={"disable_password_login_when_sso_enabled": True},
|
|
)
|
|
|
|
assert exc_info.value.code == "403"
|
|
mock_prisma_client.db.litellm_usertable.find_first.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allows_password_login_when_setting_enabled_but_sso_not_configured(self):
|
|
"""The setting alone must not lock out an admin who has not actually
|
|
configured SSO — there would be no fallback left."""
|
|
master_key = "sk-1234"
|
|
ui_username = "admin"
|
|
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"UI_USERNAME": ui_username,
|
|
"UI_PASSWORD": master_key,
|
|
"DATABASE_URL": "postgresql://test:test@localhost/test",
|
|
},
|
|
clear=True,
|
|
):
|
|
with ExitStack() as stack:
|
|
_patch_sso_configured(stack, configured=False)
|
|
_patch_successful_admin_login_deps(stack)
|
|
result = await authenticate_user(
|
|
username=ui_username,
|
|
password=master_key,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
general_settings={"disable_password_login_when_sso_enabled": True},
|
|
)
|
|
|
|
assert isinstance(result, LoginResult)
|
|
assert result.user_id == LITELLM_PROXY_ADMIN_NAME
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allows_password_login_when_sso_env_is_incomplete(self):
|
|
"""Regression: a lone MICROSOFT_CLIENT_ID with no client secret or
|
|
tenant makes has_user_setup_sso() True, but a real SSO sign-in would
|
|
fail. The gate must read the real env (no is_sso_provider_fully_configured
|
|
mock here) and still let password login through, or an admin who set
|
|
one env var by mistake is locked out with no way in."""
|
|
master_key = "sk-1234"
|
|
ui_username = "admin"
|
|
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"UI_USERNAME": ui_username,
|
|
"UI_PASSWORD": master_key,
|
|
"DATABASE_URL": "postgresql://test:test@localhost/test",
|
|
"MICROSOFT_CLIENT_ID": "ms-client-id-only",
|
|
},
|
|
clear=True,
|
|
):
|
|
with ExitStack() as stack:
|
|
_patch_successful_admin_login_deps(stack)
|
|
result = await authenticate_user(
|
|
username=ui_username,
|
|
password=master_key,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
general_settings={"disable_password_login_when_sso_enabled": True},
|
|
)
|
|
|
|
assert isinstance(result, LoginResult)
|
|
assert result.user_id == LITELLM_PROXY_ADMIN_NAME
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allows_password_login_when_sso_configured_but_setting_not_enabled(self):
|
|
"""SSO being configured must not, by itself, disable the password
|
|
fallback: the setting is opt-in."""
|
|
master_key = "sk-1234"
|
|
ui_username = "admin"
|
|
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"UI_USERNAME": ui_username,
|
|
"UI_PASSWORD": master_key,
|
|
"DATABASE_URL": "postgresql://test:test@localhost/test",
|
|
},
|
|
clear=True,
|
|
):
|
|
with ExitStack() as stack:
|
|
_patch_sso_configured(stack, configured=True)
|
|
_patch_successful_admin_login_deps(stack)
|
|
result = await authenticate_user(
|
|
username=ui_username,
|
|
password=master_key,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
general_settings={},
|
|
)
|
|
|
|
assert isinstance(result, LoginResult)
|
|
assert result.user_id == LITELLM_PROXY_ADMIN_NAME
|
|
|
|
|
|
class TestDisableEnvCredentialLogin:
|
|
"""`disable_env_credential_login` must reject a login with the env
|
|
credentials (UI_USERNAME/UI_PASSWORD, or the master-key fallback when
|
|
UI_PASSWORD is unset) while leaving database-user password logins
|
|
untouched, so admins with real accounts keep a way in."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejects_correct_env_credentials_when_disabled(self):
|
|
master_key = "sk-1234"
|
|
ui_username = "admin"
|
|
ui_password = "env-only-password"
|
|
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
|
|
|
with patch.dict(os.environ, {"UI_USERNAME": ui_username, "UI_PASSWORD": ui_password}):
|
|
with pytest.raises(ProxyException) as exc_info:
|
|
await authenticate_user(
|
|
username=ui_username,
|
|
password=ui_password,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
general_settings={"disable_env_credential_login": True},
|
|
)
|
|
|
|
assert exc_info.value.type == ProxyErrorTypes.auth_error
|
|
assert exc_info.value.code == "401"
|
|
assert "UI_USERNAME" not in exc_info.value.message
|
|
assert "UI_PASSWORD" not in exc_info.value.message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejects_master_key_fallback_when_disabled(self):
|
|
"""With UI_PASSWORD unset, the master key IS the env password, so the
|
|
setting must reject it too or it protects nothing by default."""
|
|
master_key = "sk-1234"
|
|
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
|
|
|
with patch.dict(os.environ, {"UI_USERNAME": "admin"}, clear=True):
|
|
with pytest.raises(ProxyException) as exc_info:
|
|
await authenticate_user(
|
|
username="admin",
|
|
password=master_key,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
general_settings={"disable_env_credential_login": True},
|
|
)
|
|
|
|
assert exc_info.value.code == "401"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_db_user_login_still_works_when_disabled(self):
|
|
master_key = "sk-1234"
|
|
user_email = "admin@example.com"
|
|
password = "Str0ng!Passw0rd"
|
|
|
|
mock_user = LiteLLM_UserTable(
|
|
user_id="db-admin-1",
|
|
user_email=user_email,
|
|
password=hash_token(token=password),
|
|
user_role=LitellmUserRoles.PROXY_ADMIN,
|
|
)
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=mock_user)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"UI_USERNAME": "admin",
|
|
"UI_PASSWORD": "env-password",
|
|
"DATABASE_URL": "postgresql://test:test@localhost/test",
|
|
},
|
|
clear=True,
|
|
):
|
|
with ExitStack() as stack:
|
|
stack.enter_context(
|
|
patch( # test-quality-ok: internal orchestration, no HTTP boundary; matches pre-existing tests
|
|
"litellm.proxy.auth.login_utils.generate_key_helper_fn",
|
|
new_callable=AsyncMock,
|
|
return_value={"token": "db-user-token"},
|
|
)
|
|
)
|
|
result = await authenticate_user(
|
|
username=user_email,
|
|
password=password,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
general_settings={"disable_env_credential_login": True},
|
|
)
|
|
|
|
assert isinstance(result, LoginResult)
|
|
assert result.user_id == "db-admin-1"
|
|
assert result.user_role == LitellmUserRoles.PROXY_ADMIN
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_env_login_still_works_when_setting_absent(self):
|
|
"""Env-credential login is the bootstrap path on a fresh install and
|
|
must stay on by default."""
|
|
master_key = "sk-1234"
|
|
ui_username = "admin"
|
|
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"UI_USERNAME": ui_username,
|
|
"UI_PASSWORD": master_key,
|
|
"DATABASE_URL": "postgresql://test:test@localhost/test",
|
|
},
|
|
clear=True,
|
|
):
|
|
with ExitStack() as stack:
|
|
_patch_successful_admin_login_deps(stack)
|
|
result = await authenticate_user(
|
|
username=ui_username,
|
|
password=master_key,
|
|
master_key=master_key,
|
|
prisma_client=mock_prisma_client,
|
|
general_settings={},
|
|
)
|
|
|
|
assert isinstance(result, LoginResult)
|
|
assert result.user_id == LITELLM_PROXY_ADMIN_NAME
|
|
|
|
|
|
class TestIsEnvCredentialLoginEnabled:
|
|
"""Drives the Admin UI warning banner: it must be True exactly when a
|
|
login with the env credentials could actually succeed."""
|
|
|
|
def test_enabled_by_default(self):
|
|
assert is_env_credential_login_enabled({}) is True
|
|
|
|
def test_disabled_by_dedicated_setting(self):
|
|
assert is_env_credential_login_enabled({"disable_env_credential_login": True}) is False
|
|
|
|
def test_explicit_false_keeps_it_enabled(self):
|
|
assert is_env_credential_login_enabled({"disable_env_credential_login": False}) is True
|
|
|
|
def test_disabled_when_sso_gate_blocks_all_password_logins(self):
|
|
"""`disable_password_login_when_sso_enabled` with SSO configured
|
|
rejects every username/password login before the env comparison runs,
|
|
so the banner must not nag about an already-unreachable path."""
|
|
with ExitStack() as stack:
|
|
_patch_sso_configured(stack, configured=True)
|
|
assert is_env_credential_login_enabled({"disable_password_login_when_sso_enabled": True}) is False
|
|
|
|
def test_enabled_when_sso_gate_is_set_but_sso_not_configured(self):
|
|
with ExitStack() as stack:
|
|
_patch_sso_configured(stack, configured=False)
|
|
assert is_env_credential_login_enabled({"disable_password_login_when_sso_enabled": True}) is True
|
|
|
|
|
|
def _db_user_row(*, password: str, password_reset_required: bool | None = None, last_breach_check_at=None):
|
|
hashed = hash_token(token=password)
|
|
row = MagicMock()
|
|
row.user_id = "reset-user-1"
|
|
row.user_email = "reset@example.com"
|
|
row.password = hashed
|
|
row.user_role = LitellmUserRoles.INTERNAL_USER
|
|
row.password_reset_required = password_reset_required
|
|
row.last_breach_check_at = last_breach_check_at
|
|
return row
|
|
|
|
|
|
def _prisma_with_user(row) -> MagicMock:
|
|
mock_prisma_client = MagicMock()
|
|
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=row)
|
|
mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=row)
|
|
return mock_prisma_client
|
|
|
|
|
|
_DB_LOGIN_ENV = {
|
|
"DATABASE_URL": "postgresql://test:test@localhost/test",
|
|
"UI_USERNAME": "admin",
|
|
"UI_PASSWORD": "admin-password",
|
|
}
|
|
|
|
|
|
class TestPasswordResetRequiredSessionMinting:
|
|
"""A user flagged `password_reset_required` must receive a UI session key
|
|
restricted to the change-password endpoint (server-side enforcement, so a
|
|
script driving the management API with the session key is blocked too);
|
|
an unflagged user must keep getting an unrestricted key."""
|
|
|
|
async def _login(self, mock_prisma_client) -> tuple[LoginResult, dict]:
|
|
with patch.dict(os.environ, _DB_LOGIN_ENV):
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.generate_key_helper_fn",
|
|
new_callable=AsyncMock,
|
|
return_value={"token": "session-token"},
|
|
) as mock_generate_key:
|
|
result = await authenticate_user(
|
|
username="reset@example.com",
|
|
password="Str0ng!Passw0rd",
|
|
master_key="sk-1234",
|
|
prisma_client=mock_prisma_client,
|
|
general_settings=_POLICY_NO_BREACH_CHECK,
|
|
)
|
|
return result, mock_generate_key.call_args.kwargs
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flagged_user_gets_key_restricted_to_change_password(self):
|
|
row = _db_user_row(password="Str0ng!Passw0rd", password_reset_required=True)
|
|
result, key_kwargs = await self._login(_prisma_with_user(row))
|
|
|
|
assert key_kwargs["allowed_routes"] == ["/user/password/change"]
|
|
assert key_kwargs["metadata"] == {"password_reset_required": True}
|
|
assert result.password_reset_required is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unflagged_user_gets_unrestricted_key(self):
|
|
row = _db_user_row(password="Str0ng!Passw0rd", password_reset_required=None)
|
|
result, key_kwargs = await self._login(_prisma_with_user(row))
|
|
|
|
assert "allowed_routes" not in key_kwargs
|
|
assert "metadata" not in key_kwargs
|
|
assert result.password_reset_required is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_login_schedules_breach_screen_with_row_state(self):
|
|
"""The login must hand the background screen the row's recheck timestamp,
|
|
or the 24h throttle can never work."""
|
|
checked_at = datetime.now(timezone.utc) - timedelta(hours=1)
|
|
row = _db_user_row(password="Str0ng!Passw0rd", last_breach_check_at=checked_at)
|
|
mock_prisma_client = _prisma_with_user(row)
|
|
|
|
with patch.dict(os.environ, _DB_LOGIN_ENV):
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.generate_key_helper_fn",
|
|
new_callable=AsyncMock,
|
|
return_value={"token": "session-token"},
|
|
):
|
|
with patch(
|
|
"litellm.proxy.auth.login_utils.screen_login_password_for_breach",
|
|
new_callable=AsyncMock,
|
|
) as mock_screen:
|
|
await authenticate_user(
|
|
username="reset@example.com",
|
|
password="Str0ng!Passw0rd",
|
|
master_key="sk-1234",
|
|
prisma_client=mock_prisma_client,
|
|
general_settings=_POLICY_NO_BREACH_CHECK,
|
|
)
|
|
|
|
screen_kwargs = mock_screen.call_args.kwargs
|
|
assert screen_kwargs["user_id"] == "reset-user-1"
|
|
assert screen_kwargs["password"] == "Str0ng!Passw0rd"
|
|
assert screen_kwargs["last_breach_check_at"] == checked_at
|
|
assert screen_kwargs["prisma_client"] is mock_prisma_client
|
|
|
|
|
|
def _sha1_upper(password: str) -> str:
|
|
return hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper()
|
|
|
|
|
|
def _client_with_transport(handler) -> AsyncHTTPHandler:
|
|
http_handler = AsyncHTTPHandler()
|
|
http_handler.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
return http_handler
|
|
|
|
|
|
def _client_returning_breach_hit(password: str) -> AsyncHTTPHandler:
|
|
body = f"{_sha1_upper(password)[5:]}:42"
|
|
return _client_with_transport(lambda request: httpx.Response(200, text=body))
|
|
|
|
|
|
def _client_returning_no_hit() -> AsyncHTTPHandler:
|
|
return _client_with_transport(lambda request: httpx.Response(200, text="0000000000000000000000000000000000A:3"))
|
|
|
|
|
|
def _client_never_called() -> AsyncHTTPHandler:
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
raise AssertionError(f"unexpected HTTP call to {request.url}")
|
|
|
|
return _client_with_transport(handler)
|
|
|
|
|
|
class TestScreenLoginPasswordForBreach:
|
|
"""The fire-and-forget login-time screen: flags a breached password for a
|
|
forced reset, stamps the recheck timestamp, rechecks at most every 24h,
|
|
and never raises into the login it runs behind."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_breached_password_sets_reset_flag_and_timestamp(self):
|
|
password = "Password123!"
|
|
mock_prisma_client = _prisma_with_user(None)
|
|
|
|
await screen_login_password_for_breach(
|
|
user_id="reset-user-1",
|
|
password=password,
|
|
last_breach_check_at=None,
|
|
general_settings={},
|
|
prisma_client=mock_prisma_client,
|
|
client=_client_returning_breach_hit(password),
|
|
)
|
|
|
|
update_kwargs = mock_prisma_client.db.litellm_usertable.update.call_args.kwargs
|
|
assert update_kwargs["where"] == {"user_id": "reset-user-1"}
|
|
assert update_kwargs["data"]["password_reset_required"] is True
|
|
assert isinstance(update_kwargs["data"]["last_breach_check_at"], datetime)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clean_password_stamps_timestamp_without_flag(self):
|
|
mock_prisma_client = _prisma_with_user(None)
|
|
|
|
await screen_login_password_for_breach(
|
|
user_id="reset-user-1",
|
|
password="Str0ng!Passw0rd",
|
|
last_breach_check_at=None,
|
|
general_settings={},
|
|
prisma_client=mock_prisma_client,
|
|
client=_client_returning_no_hit(),
|
|
)
|
|
|
|
update_kwargs = mock_prisma_client.db.litellm_usertable.update.call_args.kwargs
|
|
assert "password_reset_required" not in update_kwargs["data"]
|
|
assert isinstance(update_kwargs["data"]["last_breach_check_at"], datetime)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_hibp_when_checked_within_24_hours(self):
|
|
mock_prisma_client = _prisma_with_user(None)
|
|
|
|
await screen_login_password_for_breach(
|
|
user_id="reset-user-1",
|
|
password="Password123!",
|
|
last_breach_check_at=datetime.now(timezone.utc) - timedelta(hours=23),
|
|
general_settings={},
|
|
prisma_client=mock_prisma_client,
|
|
client=_client_never_called(),
|
|
)
|
|
|
|
mock_prisma_client.db.litellm_usertable.update.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rechecks_when_last_check_is_older_than_24_hours(self):
|
|
password = "Password123!"
|
|
mock_prisma_client = _prisma_with_user(None)
|
|
|
|
await screen_login_password_for_breach(
|
|
user_id="reset-user-1",
|
|
password=password,
|
|
last_breach_check_at=datetime.now(timezone.utc) - timedelta(hours=25),
|
|
general_settings={},
|
|
prisma_client=mock_prisma_client,
|
|
client=_client_returning_breach_hit(password),
|
|
)
|
|
|
|
assert mock_prisma_client.db.litellm_usertable.update.call_args.kwargs["data"]["password_reset_required"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_hibp_when_check_disabled(self):
|
|
mock_prisma_client = _prisma_with_user(None)
|
|
|
|
await screen_login_password_for_breach(
|
|
user_id="reset-user-1",
|
|
password="Password123!",
|
|
last_breach_check_at=None,
|
|
general_settings=_POLICY_NO_BREACH_CHECK,
|
|
prisma_client=mock_prisma_client,
|
|
client=_client_never_called(),
|
|
)
|
|
|
|
mock_prisma_client.db.litellm_usertable.update.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_db_failure_never_raises_into_the_login(self):
|
|
password = "Password123!"
|
|
mock_prisma_client = _prisma_with_user(None)
|
|
mock_prisma_client.db.litellm_usertable.update = AsyncMock(side_effect=RuntimeError("db down"))
|
|
|
|
assert (
|
|
await screen_login_password_for_breach(
|
|
user_id="reset-user-1",
|
|
password=password,
|
|
last_breach_check_at=None,
|
|
general_settings={},
|
|
prisma_client=mock_prisma_client,
|
|
client=_client_returning_breach_hit(password),
|
|
)
|
|
is None
|
|
)
|