Merge pull request #25559 from shreyescodes/fix/cors-and-db-safety-bugs

fix: harden CORS credentials, create_views exception handling, and spend log cleanup loop
This commit is contained in:
yuneng-jiang 2026-04-13 21:17:12 -07:00 committed by GitHub
commit b0a40fde6d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 478 additions and 48 deletions

View File

@ -804,6 +804,8 @@ router_settings:
| LITELLM_ASSETS_PATH | Path to directory for UI assets and logos. Used when running with read-only filesystem (e.g., Kubernetes). Default is `/var/lib/litellm/assets` in Docker.
| LITELLM_BLOG_POSTS_URL | Custom URL for fetching LiteLLM blog posts JSON. Default is the GitHub main branch URL
| LITELLM_CLI_JWT_EXPIRATION_HOURS | Expiration time in hours for CLI-generated JWT tokens. Default is 24 hours
| LITELLM_CORS_ALLOW_CREDENTIALS | Set to `true` to explicitly allow credentials in CORS responses. When not set, credentials are disabled automatically if `LITELLM_CORS_ORIGINS` is `*` (wildcard) to prevent the browser security misconfiguration of reflecting any origin with credentials
| LITELLM_CORS_ORIGINS | Comma-separated list of allowed CORS origins (e.g. `https://app.example.com,https://admin.example.com`). Defaults to `*` (all origins) when not set
| LITELLM_DD_AGENT_HOST | Hostname or IP of DataDog agent for LiteLLM-specific logging. When set, logs are sent to agent instead of direct API
| LITELLM_DEPLOYMENT_ENVIRONMENT | Environment name for the deployment (e.g., "production", "staging"). Used as a fallback when OTEL_ENVIRONMENT_NAME is not set. Sets the `environment` tag in telemetry data
| LITELLM_DETAILED_TIMING | When true, adds detailed per-phase timing headers to responses (`x-litellm-timing-{pre-processing,llm-api,post-processing,message-copy}-ms`). Default is false. See [latency overhead docs](../troubleshoot/latency_overhead.md)

View File

@ -4,6 +4,12 @@ from litellm import verbose_logger
_db = Any
# Markers that indicate a view/relation does not yet exist in the database.
# Keeping these in one place avoids repeating the check across all view blocks
# and prevents overly broad matches (e.g. bare 'undefined' would also match
# 'undefined function' or 'column undefined_col referenced in query').
_VIEW_NOT_FOUND_MARKERS = ("does not exist", "no such table", "undefined table")
async def create_missing_views(db: _db): # noqa: PLR0915
"""
@ -18,14 +24,17 @@ async def create_missing_views(db: _db): # noqa: PLR0915
If the view doesn't exist, one will be created.
"""
try:
# Try to select one row from the view
await db.query_raw("""SELECT 1 FROM "LiteLLM_VerificationTokenView" LIMIT 1""")
print("LiteLLM_VerificationTokenView Exists!") # noqa
except Exception:
verbose_logger.debug("LiteLLM_VerificationTokenView Exists!")
except Exception as e:
error_msg = str(e).lower()
if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS):
raise
# If an error occurs, the view does not exist, so create it
await db.execute_raw(
"""
await db.execute_raw("""
CREATE VIEW "LiteLLM_VerificationTokenView" AS
SELECT
v.*,
@ -37,15 +46,17 @@ async def create_missing_views(db: _db): # noqa: PLR0915
FROM "LiteLLM_VerificationToken" v
LEFT JOIN "LiteLLM_TeamTable" t ON v.team_id = t.team_id
LEFT JOIN "LiteLLM_ProjectTable" p ON v.project_id = p.project_id;
"""
)
""")
print("LiteLLM_VerificationTokenView Created!") # noqa
verbose_logger.debug("LiteLLM_VerificationTokenView Created!")
try:
await db.query_raw("""SELECT 1 FROM "MonthlyGlobalSpend" LIMIT 1""")
print("MonthlyGlobalSpend Exists!") # noqa
except Exception:
verbose_logger.debug("MonthlyGlobalSpend Exists!")
except Exception as e:
error_msg = str(e).lower()
if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS):
raise
sql_query = """
CREATE OR REPLACE VIEW "MonthlyGlobalSpend" AS
SELECT
@ -60,12 +71,15 @@ async def create_missing_views(db: _db): # noqa: PLR0915
"""
await db.execute_raw(query=sql_query)
print("MonthlyGlobalSpend Created!") # noqa
verbose_logger.debug("MonthlyGlobalSpend Created!")
try:
await db.query_raw("""SELECT 1 FROM "Last30dKeysBySpend" LIMIT 1""")
print("Last30dKeysBySpend Exists!") # noqa
except Exception:
verbose_logger.debug("Last30dKeysBySpend Exists!")
except Exception as e:
error_msg = str(e).lower()
if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS):
raise
sql_query = """
CREATE OR REPLACE VIEW "Last30dKeysBySpend" AS
SELECT
@ -88,12 +102,15 @@ async def create_missing_views(db: _db): # noqa: PLR0915
"""
await db.execute_raw(query=sql_query)
print("Last30dKeysBySpend Created!") # noqa
verbose_logger.debug("Last30dKeysBySpend Created!")
try:
await db.query_raw("""SELECT 1 FROM "Last30dModelsBySpend" LIMIT 1""")
print("Last30dModelsBySpend Exists!") # noqa
except Exception:
verbose_logger.debug("Last30dModelsBySpend Exists!")
except Exception as e:
error_msg = str(e).lower()
if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS):
raise
sql_query = """
CREATE OR REPLACE VIEW "Last30dModelsBySpend" AS
SELECT
@ -111,11 +128,14 @@ async def create_missing_views(db: _db): # noqa: PLR0915
"""
await db.execute_raw(query=sql_query)
print("Last30dModelsBySpend Created!") # noqa
verbose_logger.debug("Last30dModelsBySpend Created!")
try:
await db.query_raw("""SELECT 1 FROM "MonthlyGlobalSpendPerKey" LIMIT 1""")
print("MonthlyGlobalSpendPerKey Exists!") # noqa
except Exception:
verbose_logger.debug("MonthlyGlobalSpendPerKey Exists!")
except Exception as e:
error_msg = str(e).lower()
if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS):
raise
sql_query = """
CREATE OR REPLACE VIEW "MonthlyGlobalSpendPerKey" AS
SELECT
@ -132,13 +152,16 @@ async def create_missing_views(db: _db): # noqa: PLR0915
"""
await db.execute_raw(query=sql_query)
print("MonthlyGlobalSpendPerKey Created!") # noqa
verbose_logger.debug("MonthlyGlobalSpendPerKey Created!")
try:
await db.query_raw(
"""SELECT 1 FROM "MonthlyGlobalSpendPerUserPerKey" LIMIT 1"""
)
print("MonthlyGlobalSpendPerUserPerKey Exists!") # noqa
except Exception:
verbose_logger.debug("MonthlyGlobalSpendPerUserPerKey Exists!")
except Exception as e:
error_msg = str(e).lower()
if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS):
raise
sql_query = """
CREATE OR REPLACE VIEW "MonthlyGlobalSpendPerUserPerKey" AS
SELECT
@ -157,12 +180,15 @@ async def create_missing_views(db: _db): # noqa: PLR0915
"""
await db.execute_raw(query=sql_query)
print("MonthlyGlobalSpendPerUserPerKey Created!") # noqa
verbose_logger.debug("MonthlyGlobalSpendPerUserPerKey Created!")
try:
await db.query_raw("""SELECT 1 FROM "DailyTagSpend" LIMIT 1""")
print("DailyTagSpend Exists!") # noqa
except Exception:
verbose_logger.debug("DailyTagSpend Exists!")
except Exception as e:
error_msg = str(e).lower()
if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS):
raise
sql_query = """
CREATE OR REPLACE VIEW "DailyTagSpend" AS
SELECT
@ -175,12 +201,15 @@ async def create_missing_views(db: _db): # noqa: PLR0915
"""
await db.execute_raw(query=sql_query)
print("DailyTagSpend Created!") # noqa
verbose_logger.debug("DailyTagSpend Created!")
try:
await db.query_raw("""SELECT 1 FROM "Last30dTopEndUsersSpend" LIMIT 1""")
print("Last30dTopEndUsersSpend Exists!") # noqa
except Exception:
verbose_logger.debug("Last30dTopEndUsersSpend Exists!")
except Exception as e:
error_msg = str(e).lower()
if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS):
raise
sql_query = """
CREATE VIEW "Last30dTopEndUsersSpend" AS
SELECT end_user, COUNT(*) AS total_events, SUM(spend) AS total_spend
@ -193,7 +222,7 @@ async def create_missing_views(db: _db): # noqa: PLR0915
"""
await db.execute_raw(query=sql_query)
print("Last30dTopEndUsersSpend Created!") # noqa
verbose_logger.debug("Last30dTopEndUsersSpend Created!")
return

View File

@ -82,7 +82,7 @@ class SpendLogCleanup:
break
# Step 1: Find logs and delete them in one go without fetching to application
# Delete in batches, limited by self.batch_size
deleted_count = await prisma_client.db.execute_raw(
deleted_result = await prisma_client.db.execute_raw(
"""
DELETE FROM "LiteLLM_SpendLogs"
WHERE "request_id" IN (
@ -94,6 +94,17 @@ class SpendLogCleanup:
cutoff_date,
self.batch_size,
)
deleted_count = 0
if isinstance(deleted_result, int):
deleted_count = deleted_result
else:
verbose_proxy_logger.error(
f"Unexpected execute_raw return type for spend log cleanup: {type(deleted_result)}; "
"aborting cleanup to avoid infinite loop"
)
break
verbose_proxy_logger.info(f"Deleted {deleted_count} logs in this batch")
if deleted_count == 0:

View File

@ -54,7 +54,7 @@ from litellm.constants import (
LITELLM_SETTINGS_SAFE_DB_OVERRIDES,
LITELLM_UI_ALLOW_HEADERS,
LITELLM_UI_SESSION_DURATION,
DAILY_TAG_SPEND_BATCH_MULTIPLIER
DAILY_TAG_SPEND_BATCH_MULTIPLIER,
)
from litellm.litellm_core_utils.litellm_logging import (
_init_custom_logger_compatible_class,
@ -1140,7 +1140,54 @@ async def openai_exception_handler(request: Request, exc: ProxyException):
router = APIRouter()
origins = ["*"]
def _get_cors_config(
cors_origins_env: Optional[str] = None,
cors_credentials_env: Optional[str] = None,
):
"""
Compute CORS allowed origins and credentials flag from environment variables.
Extracted into a function so it can be unit-tested without reloading the module.
Args:
cors_origins_env: Value of LITELLM_CORS_ORIGINS (defaults to os.getenv).
cors_credentials_env: Value of LITELLM_CORS_ALLOW_CREDENTIALS (defaults to os.getenv).
Returns:
Tuple[List[str], bool]: (origins, allow_credentials)
"""
_origins_raw = (
cors_origins_env
if cors_origins_env is not None
else os.getenv("LITELLM_CORS_ORIGINS")
)
if _origins_raw is None or _origins_raw.strip() == "":
computed_origins = ["*"]
else:
computed_origins = [o.strip() for o in _origins_raw.split(",") if o.strip()]
# Disable credentials by default when wildcard origins are used — combining
# allow_origins=["*"] with allow_credentials=True causes Starlette to reflect
# the incoming Origin header, allowing any site to make credentialed requests.
# Set LITELLM_CORS_ALLOW_CREDENTIALS=true to explicitly restore the old behaviour
# (e.g. for non-browser clients that relied on the Access-Control-Allow-Credentials
# header being present regardless of origin).
_credentials_raw = (
cors_credentials_env
if cors_credentials_env is not None
else os.getenv("LITELLM_CORS_ALLOW_CREDENTIALS")
)
if _credentials_raw is not None:
computed_credentials = _credentials_raw.strip().lower() == "true"
else:
computed_credentials = "*" not in computed_origins
return computed_origins, computed_credentials
origins, allow_cors_credentials = _get_cors_config()
# get current directory
@ -1467,7 +1514,7 @@ current_dir = os.path.dirname(os.path.abspath(__file__))
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_credentials=allow_cors_credentials,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=LITELLM_UI_ALLOW_HEADERS,
@ -2316,9 +2363,13 @@ def _write_health_state_to_router_cache(
exception_status = getattr(original_exception, "status_code", 500)
if llm_router.health_check_ignore_transient_errors and exception_status in (
429,
408,
if (
llm_router.health_check_ignore_transient_errors
and exception_status
in (
429,
408,
)
):
continue
@ -6287,7 +6338,9 @@ class ProxyStartupEvent:
### UPDATE DAILY TAG SPEND (separate scheduler job with longer interval) ###
## Reduces QPS as there are more tags for a single request
tag_spend_update_interval = int(batch_writing_interval * DAILY_TAG_SPEND_BATCH_MULTIPLIER)
tag_spend_update_interval = int(
batch_writing_interval * DAILY_TAG_SPEND_BATCH_MULTIPLIER
)
from litellm.proxy.utils import update_daily_tag_spend
scheduler.add_job(
@ -7132,9 +7185,9 @@ async def chat_completion( # noqa: PLR0915
hasattr(user_api_key_dict, "organization_alias")
and user_api_key_dict.organization_alias is not None
):
data["metadata"]["user_api_key_org_alias"] = (
user_api_key_dict.organization_alias
)
data["metadata"][
"user_api_key_org_alias"
] = user_api_key_dict.organization_alias
if (
hasattr(user_api_key_dict, "agent_id")
and user_api_key_dict.agent_id is not None
@ -7313,9 +7366,9 @@ async def completion( # noqa: PLR0915
hasattr(user_api_key_dict, "organization_alias")
and user_api_key_dict.organization_alias is not None
):
data["metadata"]["user_api_key_org_alias"] = (
user_api_key_dict.organization_alias
)
data["metadata"][
"user_api_key_org_alias"
] = user_api_key_dict.organization_alias
if (
hasattr(user_api_key_dict, "agent_id")
and user_api_key_dict.agent_id is not None
@ -7562,9 +7615,9 @@ async def embeddings( # noqa: PLR0915
hasattr(user_api_key_dict, "organization_alias")
and user_api_key_dict.organization_alias is not None
):
data["metadata"]["user_api_key_org_alias"] = (
user_api_key_dict.organization_alias
)
data["metadata"][
"user_api_key_org_alias"
] = user_api_key_dict.organization_alias
if (
hasattr(user_api_key_dict, "agent_id")
and user_api_key_dict.agent_id is not None

View File

@ -0,0 +1,155 @@
"""
Tests for create_missing_views exception handling fix.
Verifies that real DB errors (auth failures, connection errors, etc.)
are re-raised instead of being silently swallowed, while genuine
"view not found" errors still trigger view creation.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, call
@pytest.mark.asyncio
async def test_create_views_reraises_connection_error():
"""should re-raise exceptions that are NOT 'does not exist' errors (e.g. connection errors)."""
from litellm.proxy.db.create_views import create_missing_views
mock_db = MagicMock()
mock_db.query_raw = AsyncMock(
side_effect=Exception("connection refused: unable to connect to database")
)
mock_db.execute_raw = AsyncMock()
with pytest.raises(Exception, match="connection refused"):
await create_missing_views(mock_db)
mock_db.execute_raw.assert_not_called()
@pytest.mark.asyncio
async def test_create_views_reraises_permission_error():
"""should re-raise permission denied errors, not treat them as missing views."""
from litellm.proxy.db.create_views import create_missing_views
mock_db = MagicMock()
mock_db.query_raw = AsyncMock(
side_effect=Exception(
"permission denied for table LiteLLM_VerificationTokenView"
)
)
mock_db.execute_raw = AsyncMock()
with pytest.raises(Exception, match="permission denied"):
await create_missing_views(mock_db)
mock_db.execute_raw.assert_not_called()
@pytest.mark.asyncio
async def test_create_views_creates_view_on_does_not_exist():
"""should call execute_raw to create view when error contains 'does not exist'."""
from litellm.proxy.db.create_views import create_missing_views
mock_db = MagicMock()
mock_db.query_raw = AsyncMock(
side_effect=[
Exception('relation "LiteLLM_VerificationTokenView" does not exist'),
None, # MonthlyGlobalSpend exists
None, # Last30dKeysBySpend exists
None, # Last30dModelsBySpend exists
None, # MonthlyGlobalSpendPerKey exists
None, # MonthlyGlobalSpendPerUserPerKey exists
None, # DailyTagSpend exists
None, # Last30dTopEndUsersSpend exists
]
)
mock_db.execute_raw = AsyncMock(return_value=None)
await create_missing_views(mock_db)
mock_db.execute_raw.assert_called_once()
created_sql = mock_db.execute_raw.call_args[0][0]
assert 'CREATE VIEW "LiteLLM_VerificationTokenView"' in created_sql
@pytest.mark.asyncio
async def test_create_views_creates_view_on_undefined_error():
"""should treat 'undefined' errors as 'view not found' and attempt creation."""
from litellm.proxy.db.create_views import create_missing_views
mock_db = MagicMock()
mock_db.query_raw = AsyncMock(
side_effect=[
Exception("undefined table LiteLLM_VerificationTokenView"),
None,
None,
None,
None,
None,
None,
None,
]
)
mock_db.execute_raw = AsyncMock(return_value=None)
await create_missing_views(mock_db)
mock_db.execute_raw.assert_called_once()
@pytest.mark.asyncio
async def test_create_views_skips_creation_when_view_exists():
"""should not call execute_raw when all views already exist."""
from litellm.proxy.db.create_views import create_missing_views
mock_db = MagicMock()
mock_db.query_raw = AsyncMock(return_value=[{"?column?": 1}])
mock_db.execute_raw = AsyncMock()
await create_missing_views(mock_db)
mock_db.execute_raw.assert_not_called()
@pytest.mark.asyncio
async def test_create_views_reraises_undefined_function_error():
"""should re-raise 'undefined function' errors — bare 'undefined' is too broad
and would previously misclassify DB function errors as missing-view signals."""
from litellm.proxy.db.create_views import create_missing_views
mock_db = MagicMock()
mock_db.query_raw = AsyncMock(
side_effect=Exception("ERROR: undefined function pg_get_viewdef()")
)
mock_db.execute_raw = AsyncMock()
with pytest.raises(Exception, match="undefined function"):
await create_missing_views(mock_db)
mock_db.execute_raw.assert_not_called()
@pytest.mark.asyncio
async def test_create_views_creates_view_on_undefined_table_error():
"""should treat 'undefined table' as a missing-view signal and attempt creation."""
from litellm.proxy.db.create_views import create_missing_views
mock_db = MagicMock()
mock_db.query_raw = AsyncMock(
side_effect=[
Exception('undefined table "LiteLLM_VerificationTokenView"'),
None,
None,
None,
None,
None,
None,
None,
]
)
mock_db.execute_raw = AsyncMock(return_value=None)
await create_missing_views(mock_db)
mock_db.execute_raw.assert_called_once()

View File

@ -0,0 +1,141 @@
"""
Tests for CORS configuration security fix.
All tests import _get_cors_config directly from proxy_server so they exercise
real production code rather than a local mirror.
"""
import pytest
def test_cors_wildcard_disables_credentials():
"""should disable credentials when LITELLM_CORS_ORIGINS is not set (defaults to wildcard)."""
from litellm.proxy.proxy_server import _get_cors_config
origins, allow_credentials = _get_cors_config(cors_origins_env="")
assert origins == ["*"]
assert allow_credentials is False
def test_cors_empty_string_disables_credentials():
"""should disable credentials when LITELLM_CORS_ORIGINS is empty or whitespace."""
from litellm.proxy.proxy_server import _get_cors_config
for empty in ("", " ", "\t"):
origins, allow_credentials = _get_cors_config(cors_origins_env=empty)
assert origins == ["*"], f"Expected wildcard for input {repr(empty)}"
assert (
allow_credentials is False
), f"Expected no credentials for input {repr(empty)}"
def test_cors_single_specific_origin_enables_credentials():
"""should enable credentials when a single explicit origin is configured."""
from litellm.proxy.proxy_server import _get_cors_config
origins, allow_credentials = _get_cors_config(
cors_origins_env="https://admin.example.com"
)
assert origins == ["https://admin.example.com"]
assert allow_credentials is True
def test_cors_multiple_specific_origins_enables_credentials():
"""should enable credentials and correctly parse comma-separated origins."""
from litellm.proxy.proxy_server import _get_cors_config
origins, allow_credentials = _get_cors_config(
cors_origins_env="https://app.example.com, https://admin.example.com, https://api.example.com"
)
assert origins == [
"https://app.example.com",
"https://admin.example.com",
"https://api.example.com",
]
assert allow_credentials is True
def test_cors_wildcard_string_in_env_disables_credentials():
"""should disable credentials when LITELLM_CORS_ORIGINS is explicitly set to '*'."""
from litellm.proxy.proxy_server import _get_cors_config
origins, allow_credentials = _get_cors_config(cors_origins_env="*")
assert "*" in origins
assert allow_credentials is False
def test_cors_origins_strips_whitespace():
"""should strip surrounding whitespace from each origin entry."""
from litellm.proxy.proxy_server import _get_cors_config
origins, _ = _get_cors_config(
cors_origins_env=" https://a.com , https://b.com "
)
assert origins == ["https://a.com", "https://b.com"]
def test_cors_origins_skips_blank_entries():
"""should skip blank entries caused by trailing/double commas."""
from litellm.proxy.proxy_server import _get_cors_config
origins, allow_credentials = _get_cors_config(
cors_origins_env="https://a.com,,https://b.com,"
)
assert origins == ["https://a.com", "https://b.com"]
assert allow_credentials is True
def test_cors_explicit_credentials_true_overrides_wildcard():
"""should enable credentials when LITELLM_CORS_ALLOW_CREDENTIALS=true even
if wildcard origins are in use (opt-in for existing deployments)."""
from litellm.proxy.proxy_server import _get_cors_config
origins, allow_credentials = _get_cors_config(
cors_origins_env="",
cors_credentials_env="true",
)
assert "*" in origins
assert allow_credentials is True
def test_cors_explicit_credentials_false_overrides_specific_origins():
"""should disable credentials when LITELLM_CORS_ALLOW_CREDENTIALS=false even
if specific origins are configured."""
from litellm.proxy.proxy_server import _get_cors_config
origins, allow_credentials = _get_cors_config(
cors_origins_env="https://admin.example.com",
cors_credentials_env="false",
)
assert origins == ["https://admin.example.com"]
assert allow_credentials is False
def test_cors_explicit_credentials_case_insensitive():
"""should accept TRUE/FALSE case-insensitively for LITELLM_CORS_ALLOW_CREDENTIALS."""
from litellm.proxy.proxy_server import _get_cors_config
_, allow_true = _get_cors_config(cors_origins_env="", cors_credentials_env="TRUE")
_, allow_false = _get_cors_config(
cors_origins_env="https://x.com", cors_credentials_env="FALSE"
)
assert allow_true is True
assert allow_false is False
def test_proxy_server_cors_invariant():
"""should verify that proxy_server module-level origins and allow_cors_credentials
are consistent catches any future drift in the module-level call to _get_cors_config.
"""
import os
import litellm.proxy.proxy_server as proxy_server
if os.getenv("LITELLM_CORS_ALLOW_CREDENTIALS") is None:
assert proxy_server.allow_cors_credentials == (
"*" not in proxy_server.origins
), (
f"Invariant broken: allow_cors_credentials={proxy_server.allow_cors_credentials} "
f"but origins={proxy_server.origins}. "
"When origins contains '*', allow_credentials must be False."
)

View File

@ -287,9 +287,48 @@ def test_string_retention_still_works():
general_settings={"maximum_spend_logs_retention_period": setting}
)
assert cleaner._should_delete_spend_logs() is True, f"Failed for {setting}"
assert cleaner.retention_seconds == expected_seconds, (
f"Expected {expected_seconds} for {setting}, got {cleaner.retention_seconds}"
)
assert (
cleaner.retention_seconds == expected_seconds
), f"Expected {expected_seconds} for {setting}, got {cleaner.retention_seconds}"
@pytest.mark.asyncio
async def test_delete_old_logs_aborts_on_non_int_execute_raw_return():
"""should abort deletion loop immediately when execute_raw returns a non-int
(e.g. None or dict), preventing an infinite loop."""
mock_prisma_client = MagicMock()
mock_db = MagicMock()
mock_db.execute_raw = AsyncMock(return_value=None)
mock_prisma_client.db = mock_db
cleaner = SpendLogCleanup(
general_settings={"maximum_spend_logs_retention_period": "7d"}
)
cutoff_date = datetime.now(timezone.utc) - timedelta(days=7)
total_deleted = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date)
assert mock_db.execute_raw.call_count == 1
assert total_deleted == 0
@pytest.mark.asyncio
async def test_delete_old_logs_continues_on_valid_int_return():
"""should continue deletion loop across batches when execute_raw returns valid int counts."""
mock_prisma_client = MagicMock()
mock_db = MagicMock()
mock_db.execute_raw = AsyncMock(side_effect=[500, 300, 0])
mock_prisma_client.db = mock_db
cleaner = SpendLogCleanup(
general_settings={"maximum_spend_logs_retention_period": "7d"}
)
cutoff_date = datetime.now(timezone.utc) - timedelta(days=7)
total_deleted = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date)
assert mock_db.execute_raw.call_count == 3
assert total_deleted == 800
def test_cleanup_batch_size_env_var(monkeypatch):