import json
import os

import pytest
from fastapi.testclient import TestClient


from litellm.proxy._types import DefaultInternalUserParams, LitellmUserRoles
from litellm.proxy.proxy_server import app
from litellm.types.proxy.management_endpoints.ui_sso import (
    DefaultTeamSSOParams,
    SSOConfig,
)

client = TestClient(app)


@pytest.fixture
def mock_proxy_config(monkeypatch):
    """Mock the proxy_config to avoid actual file operations during tests"""
    mock_config = {
        "litellm_settings": {
            "default_internal_user_params": {
                "user_role": LitellmUserRoles.INTERNAL_USER,
                "max_budget": 100.0,
                "budget_duration": "30d",
                "models": ["gpt-3.5-turbo", "gpt-4"],
            },
            "default_team_params": {
                "models": ["gpt-3.5-turbo"],
                "max_budget": 50.0,
                "budget_duration": "14d",
                "tpm_limit": 100,
                "rpm_limit": 10,
            },
        },
        "general_settings": {"proxy_admin_email": "admin@example.com"},
        "environment_variables": {
            "GOOGLE_CLIENT_ID": "test_google_client_id",
            "GOOGLE_CLIENT_SECRET": "test_google_client_secret",
            "MICROSOFT_CLIENT_ID": "test_microsoft_client_id",
            "MICROSOFT_CLIENT_SECRET": "test_microsoft_client_secret",
            "PROXY_BASE_URL": "https://example.com",
        },
    }

    async def mock_get_config():
        return mock_config

    # Add a counter to track save_config calls
    save_config_call_count = 0
    saved_env_updates: list = []

    async def mock_save_config(new_config=None):
        nonlocal mock_config, save_config_call_count
        save_config_call_count += 1
        if new_config:
            mock_config = new_config
        return mock_config

    async def mock_save_environment_variables(updates):
        saved_env_updates.append(updates)

    from litellm.proxy.proxy_server import proxy_config

    monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
    monkeypatch.setattr(proxy_config, "save_config", mock_save_config)
    monkeypatch.setattr(proxy_config, "save_environment_variables", mock_save_environment_variables)

    # Return the config, the save_config call counter, and any env-var updates
    # the endpoint routed through the dedicated save_environment_variables path
    return {
        "config": mock_config,
        "save_call_count": lambda: save_config_call_count,
        "env_updates": lambda: saved_env_updates,
    }


@pytest.fixture
def mock_auth():
    """Mock the authentication to bypass auth checks using FastAPI dependency overrides"""
    from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
    from litellm.proxy.proxy_server import app

    async def mock_user_api_key_auth():
        return {"user_id": "test_user"}

    app.dependency_overrides[user_api_key_auth] = mock_user_api_key_auth
    yield
    app.dependency_overrides.pop(user_api_key_auth, None)


class TestProxySettingEndpoints:
    def test_get_internal_user_settings(self, mock_proxy_config, mock_auth):
        """Test getting the internal user settings"""
        response = client.get("/get/internal_user_settings")

        assert response.status_code == 200
        data = response.json()

        # Check structure of response (updated to use field_schema)
        assert "values" in data
        assert "field_schema" in data

        # Check values match our mock config
        values = data["values"]
        mock_params = mock_proxy_config["config"]["litellm_settings"][
            "default_internal_user_params"
        ]
        assert values["user_role"] == mock_params["user_role"]
        assert values["max_budget"] == mock_params["max_budget"]
        assert values["budget_duration"] == mock_params["budget_duration"]
        assert values["models"] == mock_params["models"]

        # Check field_schema contains descriptions (updated from schema to field_schema)
        assert "properties" in data["field_schema"]
        assert "user_role" in data["field_schema"]["properties"]
        assert "description" in data["field_schema"]["properties"]["user_role"]

    def test_get_internal_user_settings_fresh_db_defaults_to_viewer(
        self, mock_auth, monkeypatch
    ):
        """
        On a fresh DB with no saved settings, the GET endpoint should return
        INTERNAL_USER_VIEW_ONLY as the default role — matching the runtime
        fallback in SSO/SCIM/JWT provisioning paths.
        """
        # Simulate fresh DB: no default_internal_user_params in config
        empty_config = {
            "litellm_settings": {},
            "general_settings": {},
            "environment_variables": {},
        }

        from litellm.proxy.proxy_server import proxy_config

        async def mock_get_config():
            return empty_config

        monkeypatch.setattr(proxy_config, "get_config", mock_get_config)

        response = client.get("/get/internal_user_settings")
        assert response.status_code == 200

        values = response.json()["values"]
        assert values["user_role"] == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, (
            f"Fresh DB should default to INTERNAL_USER_VIEW_ONLY, got {values['user_role']}. "
            "The Pydantic default must match the runtime fallback."
        )

    def test_update_internal_user_settings(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Test updating the internal user settings"""
        # Mock litellm.default_internal_user_params

        import litellm

        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
        monkeypatch.setattr(litellm, "default_internal_user_params", {})

        # New settings to update
        new_settings = {
            "user_role": LitellmUserRoles.PROXY_ADMIN,
            "max_budget": 200.0,
            "budget_duration": "7d",
            "models": ["gpt-4", "claude-3"],
        }

        response = client.patch("/update/internal_user_settings", json=new_settings)

        print(response.text)
        assert response.status_code == 200
        data = response.json()

        # Check response structure
        assert data["status"] == "success"
        assert "settings" in data

        # Verify settings were updated
        settings = data["settings"]
        assert settings["user_role"] == new_settings["user_role"]
        assert settings["max_budget"] == new_settings["max_budget"]
        assert settings["budget_duration"] == new_settings["budget_duration"]
        assert settings["models"] == new_settings["models"]

        # Verify the config was updated
        updated_config = mock_proxy_config["config"]["litellm_settings"][
            "default_internal_user_params"
        ]
        assert updated_config["user_role"] == new_settings["user_role"]
        assert updated_config["max_budget"] == new_settings["max_budget"]

        # Verify save_config was called exactly once
        assert mock_proxy_config["save_call_count"]() == 1

    def test_get_default_team_settings(self, mock_proxy_config, mock_auth):
        """Test getting the default team settings"""
        response = client.get("/get/default_team_settings")

        assert response.status_code == 200
        data = response.json()

        # Check structure of response (updated to use field_schema)
        assert "values" in data
        assert "field_schema" in data

        # Check values match our mock config
        values = data["values"]
        mock_params = mock_proxy_config["config"]["litellm_settings"][
            "default_team_params"
        ]
        assert values["models"] == mock_params["models"]
        assert values["max_budget"] == mock_params["max_budget"]
        assert values["budget_duration"] == mock_params["budget_duration"]
        assert values["tpm_limit"] == mock_params["tpm_limit"]
        assert values["rpm_limit"] == mock_params["rpm_limit"]

        # Check field_schema contains descriptions (updated from schema to field_schema)
        assert "properties" in data["field_schema"]
        assert "models" in data["field_schema"]["properties"]
        assert "description" in data["field_schema"]["properties"]["models"]

    def test_update_default_team_settings(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Test updating the default team settings"""
        # Mock litellm.default_team_params
        import litellm

        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
        monkeypatch.setattr(litellm, "default_team_params", {})

        # New settings to update
        new_settings = {
            "models": ["gpt-4", "claude-3"],
            "max_budget": 150.0,
            "budget_duration": "30d",
            "tpm_limit": 200,
            "rpm_limit": 20,
        }

        response = client.patch("/update/default_team_settings", json=new_settings)

        assert response.status_code == 200
        data = response.json()

        # Check response structure
        assert data["status"] == "success"
        assert "settings" in data

        # Verify settings were updated
        settings = data["settings"]
        assert settings["models"] == new_settings["models"]
        assert settings["max_budget"] == new_settings["max_budget"]
        assert settings["budget_duration"] == new_settings["budget_duration"]
        assert settings["tpm_limit"] == new_settings["tpm_limit"]
        assert settings["rpm_limit"] == new_settings["rpm_limit"]

        # Verify the config was updated
        updated_config = mock_proxy_config["config"]["litellm_settings"][
            "default_team_params"
        ]
        assert updated_config["models"] == new_settings["models"]
        assert updated_config["max_budget"] == new_settings["max_budget"]
        assert updated_config["tpm_limit"] == new_settings["tpm_limit"]

        # Verify save_config was called exactly once
        assert mock_proxy_config["save_call_count"]() == 1

    def test_get_default_team_settings_includes_team_member_permissions_schema(
        self, mock_proxy_config, mock_auth
    ):
        """Test that team_member_permissions field appears in schema with enum items"""
        response = client.get("/get/default_team_settings")

        assert response.status_code == 200
        data = response.json()

        # Check that team_member_permissions is in the schema
        props = data["field_schema"]["properties"]
        assert "team_member_permissions" in props

        perm_schema = props["team_member_permissions"]
        assert perm_schema["type"] == "array"
        assert "items" in perm_schema
        assert "enum" in perm_schema["items"]
        # Verify some known enum values are present
        enum_values = perm_schema["items"]["enum"]
        assert "/key/generate" in enum_values
        assert "/key/info" in enum_values
        assert "/key/delete" in enum_values

    def test_update_default_team_settings_with_permissions(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Test updating default team settings with team_member_permissions"""
        import litellm

        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
        monkeypatch.setattr(litellm, "default_team_params", {})

        new_settings = {
            "models": ["gpt-4"],
            "team_member_permissions": ["/key/generate", "/key/update", "/key/delete"],
        }

        response = client.patch("/update/default_team_settings", json=new_settings)

        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "success"

        settings = data["settings"]
        assert settings["team_member_permissions"] == [
            "/key/generate",
            "/key/update",
            "/key/delete",
        ]

    def test_get_sso_settings(self, mock_proxy_config, mock_auth, monkeypatch):
        """Test getting the SSO settings from the dedicated database table"""
        from unittest.mock import AsyncMock, MagicMock

        # Mock the prisma client with database record
        # Note: Prisma returns Json fields as dicts (auto-parsed)
        mock_prisma = MagicMock()
        mock_db_record = MagicMock()
        mock_db_record.sso_settings = {
            "google_client_id": "test_google_client_id",
            "google_client_secret": "test_google_client_secret",
            "microsoft_client_id": "test_microsoft_client_id",
            "microsoft_client_secret": "test_microsoft_client_secret",
            "proxy_base_url": "https://example.com",
            "user_email": "admin@example.com",
        }
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(
            return_value=mock_db_record
        )
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        # Mock decryption to return the values as-is (simulating decryption)
        from litellm.proxy.proxy_server import proxy_config

        monkeypatch.setattr(
            proxy_config,
            "_decrypt_and_set_db_env_variables",
            lambda environment_variables: environment_variables,
        )

        response = client.get("/get/sso_settings")

        assert response.status_code == 200
        data = response.json()

        # Check structure of response
        assert "values" in data
        assert "field_schema" in data

        # Check values contain SSO configuration
        values = data["values"]
        assert "google_client_id" in values
        assert "google_client_secret" in values
        assert "microsoft_client_id" in values
        assert "microsoft_client_secret" in values
        assert "proxy_base_url" in values
        assert "user_email" in values

        # Verify non-secret values match our mock config. OAuth client
        # secrets are masked on read so the GET response never carries
        # plaintext credentials.
        assert values["google_client_id"] == "test_google_client_id"
        assert values["google_client_secret"] != "test_google_client_secret"
        assert "*" in values["google_client_secret"]
        assert values["microsoft_client_id"] == "test_microsoft_client_id"
        assert values["microsoft_client_secret"] != "test_microsoft_client_secret"
        assert "*" in values["microsoft_client_secret"]
        assert values["proxy_base_url"] == "https://example.com"
        assert values["user_email"] == "admin@example.com"

        # Check field_schema contains descriptions
        assert "properties" in data["field_schema"]
        assert "google_client_id" in data["field_schema"]["properties"]
        assert "description" in data["field_schema"]["properties"]["google_client_id"]

        # Verify role_mappings is present in response (can be None if not set)
        assert "role_mappings" in values
        assert values["role_mappings"] is None

        # Verify find_unique was called with correct parameters
        mock_prisma.db.litellm_ssoconfig.find_unique.assert_called_once()
        call_args = mock_prisma.db.litellm_ssoconfig.find_unique.call_args
        assert call_args.kwargs["where"]["id"] == "sso_config"

    def _mock_sso_db_record(self, monkeypatch, sso_settings):
        """Point /get/sso_settings at a stored SSO row (or None for no row)."""
        from unittest.mock import AsyncMock, MagicMock

        mock_prisma = MagicMock()
        if sso_settings is None:
            mock_db_record = None
        else:
            mock_db_record = MagicMock()
            mock_db_record.sso_settings = sso_settings
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=mock_db_record)
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        # The resolver decrypts stored values via decrypt_value_helper; make it an
        # identity so the plaintext fixtures round-trip.
        monkeypatch.setattr(
            "litellm.proxy.config_resolvers.sso.decrypt_value_helper",
            lambda value, key, exception_type="error", return_original_value=False: value,
        )

    def test_get_sso_settings_falls_back_to_process_env(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """
        Regression for LIT-4165.

        SSO configured purely as process env vars (helm/terraform, no UI writes)
        logs users in successfully, because ui_sso.py resolves every setting from
        os.environ. /get/sso_settings read only the sso_config table though, so
        the Admin UI showed "not configured" for a working SSO deployment and hid
        the Edit/Delete controls behind an empty-state placeholder.
        """
        self._mock_sso_db_record(monkeypatch, None)
        monkeypatch.setenv("GENERIC_CLIENT_ID", "env-client-id")
        monkeypatch.setenv("GENERIC_CLIENT_SECRET", "env-client-secret-value")
        monkeypatch.setenv("GENERIC_AUTHORIZATION_ENDPOINT", "https://idp.example.com/authorize")
        monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")
        monkeypatch.setenv("GENERIC_USERINFO_ENDPOINT", "https://idp.example.com/userinfo")
        monkeypatch.setenv("GENERIC_SCOPE", "openid email profile groups")
        monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")

        response = client.get("/get/sso_settings")

        assert response.status_code == 200
        values = response.json()["values"]

        # Every one of these was None before the fix, despite SSO working.
        assert values["generic_client_id"] == "env-client-id"
        assert values["generic_authorization_endpoint"] == "https://idp.example.com/authorize"
        assert values["generic_token_endpoint"] == "https://idp.example.com/token"
        assert values["generic_userinfo_endpoint"] == "https://idp.example.com/userinfo"
        assert values["generic_scope"] == "openid email profile groups"
        assert values["proxy_base_url"] == "https://gateway.example.com"

        # An env-sourced secret is masked exactly like a stored one.
        assert values["generic_client_secret"] not in (None, "env-client-secret-value")
        assert "*" in values["generic_client_secret"]

    def test_get_sso_settings_does_not_mutate_os_environ(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """A GET must not write os.environ. The legacy read path decrypted DB
        values straight into the environment, so opening the settings page
        repopulated env and masked any consumer that stopped reading it."""
        self._mock_sso_db_record(monkeypatch, {"generic_client_id": "db-only-id"})
        monkeypatch.delenv("GENERIC_CLIENT_ID", raising=False)

        response = client.get("/get/sso_settings")

        assert response.status_code == 200
        assert response.json()["values"]["generic_client_id"] == "db-only-id"
        # The DB value must NOT have leaked into the process environment.
        assert "GENERIC_CLIENT_ID" not in os.environ

    def test_get_sso_settings_prefers_stored_over_process_env(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """A stored value wins; only fields absent from the row fall back to env."""
        self._mock_sso_db_record(monkeypatch, {"generic_client_id": "stored-client-id"})
        monkeypatch.setenv("GENERIC_CLIENT_ID", "env-client-id")
        monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")

        response = client.get("/get/sso_settings")

        assert response.status_code == 200
        values = response.json()["values"]
        assert values["generic_client_id"] == "stored-client-id"
        assert values["generic_token_endpoint"] == "https://idp.example.com/token"

    def test_get_sso_settings_blank_stored_value_falls_back_to_process_env(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """
        Blank means absent. update_sso_settings clears the env var for a blank
        field, so a blank row entry cannot describe a live setting; os.environ is
        the effective config and is what the UI must report.
        """
        self._mock_sso_db_record(monkeypatch, {"generic_client_id": "   ", "generic_token_endpoint": ""})
        monkeypatch.setenv("GENERIC_CLIENT_ID", "env-client-id")
        monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")

        response = client.get("/get/sso_settings")

        assert response.status_code == 200
        values = response.json()["values"]
        assert values["generic_client_id"] == "env-client-id"
        assert values["generic_token_endpoint"] == "https://idp.example.com/token"

    def test_get_sso_settings_unset_everywhere_reports_source(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """A field set in neither source is unset (or its effective default),
        and provenance reports which."""
        self._mock_sso_db_record(monkeypatch, None)
        for env_var in (
            "GENERIC_CLIENT_ID",
            "GENERIC_CLIENT_SECRET",
            "GENERIC_TOKEN_ENDPOINT",
            "GENERIC_SCOPE",
            "GOOGLE_CLIENT_ID",
            "MICROSOFT_CLIENT_ID",
            "PROXY_BASE_URL",
        ):
            monkeypatch.delenv(env_var, raising=False)

        response = client.get("/get/sso_settings")

        assert response.status_code == 200
        body = response.json()
        values = body["values"]
        provenance = body["provenance"]
        assert values["generic_client_id"] is None
        assert provenance["generic_client_id"] == "unset"
        assert values["generic_client_secret"] is None
        assert values["google_client_id"] is None
        # generic_scope carries the same effective default the login path applies,
        # so the settings page shows the scope logins would actually request.
        assert values["generic_scope"] == "openid email profile"
        assert provenance["generic_scope"] == "default"

    def test_update_sso_settings(self, mock_proxy_config, mock_auth, monkeypatch):
        """Test updating the SSO settings to the dedicated database table"""
        import json
        from unittest.mock import AsyncMock, MagicMock

        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        # Mock the prisma client
        mock_prisma = MagicMock()
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
        mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
        mock_prisma.db.litellm_config = MagicMock()
        mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
        mock_prisma.db.litellm_config.update = AsyncMock()
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        # Mock encryption to return values as-is
        from litellm.proxy.proxy_server import proxy_config

        monkeypatch.setattr(
            proxy_config,
            "_encrypt_env_variables",
            lambda environment_variables: environment_variables,
        )

        # New SSO settings to update
        new_sso_settings = {
            "google_client_id": "new_google_client_id",
            "google_client_secret": "new_google_client_secret",
            "microsoft_client_id": "new_microsoft_client_id",
            "microsoft_client_secret": "new_microsoft_client_secret",
            "proxy_base_url": "https://newexample.com",
            "user_email": "newadmin@example.com",
        }

        response = client.patch("/update/sso_settings", json=new_sso_settings)

        assert response.status_code == 200
        data = response.json()

        # Check response structure
        assert data["status"] == "success"
        assert "settings" in data

        # Verify settings were updated
        settings = data["settings"]
        assert settings["google_client_id"] == new_sso_settings["google_client_id"]
        assert (
            settings["google_client_secret"] == new_sso_settings["google_client_secret"]
        )
        assert (
            settings["microsoft_client_id"] == new_sso_settings["microsoft_client_id"]
        )
        assert (
            settings["microsoft_client_secret"]
            == new_sso_settings["microsoft_client_secret"]
        )
        assert settings["proxy_base_url"] == new_sso_settings["proxy_base_url"]
        assert settings["user_email"] == new_sso_settings["user_email"]

        # Verify upsert was called with correct parameters
        assert mock_prisma.db.litellm_ssoconfig.upsert.called
        call_args = mock_prisma.db.litellm_ssoconfig.upsert.call_args

        # Verify the upsert is using the correct ID
        assert call_args.kwargs["where"]["id"] == "sso_config"

        # Verify the data structure for create and update
        create_data = call_args.kwargs["data"]["create"]
        update_data = call_args.kwargs["data"]["update"]

        assert create_data["id"] == "sso_config"
        assert "sso_settings" in create_data
        assert "sso_settings" in update_data

        # Verify the data is stored as JSON string (as per implementation)
        # The encryption mock returns data as-is, so we verify structure
        create_sso_settings = json.loads(create_data["sso_settings"])
        assert create_sso_settings["google_client_id"] == "new_google_client_id"

    def test_update_sso_settings_maps_saml_fields_to_env_vars(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """SAML settings entered in the admin UI must be applied as the SAML_* env
        vars the SAML handler reads, and the allow-unsolicited toggle must map to
        the 'true'/'false' string the handler expects."""
        import json
        import os
        from unittest.mock import AsyncMock, MagicMock

        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        mock_prisma = MagicMock()
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
        mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
        mock_prisma.db.litellm_config = MagicMock()
        mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
        mock_prisma.db.litellm_config.update = AsyncMock()
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        from litellm.proxy.proxy_server import proxy_config

        monkeypatch.setattr(
            proxy_config,
            "_encrypt_env_variables",
            lambda environment_variables: environment_variables,
        )

        for var in (
            "SAML_IDP_METADATA_URL",
            "SAML_IDP_METADATA_XML",
            "SAML_SP_ENTITY_ID",
            "SAML_ALLOW_UNSOLICITED",
        ):
            monkeypatch.delenv(var, raising=False)

        new_sso_settings = {
            "saml_idp_metadata_url": "https://idp.example.com/metadata",
            "saml_sp_entity_id": "https://proxy.example.com/sso/saml/metadata",
            "saml_allow_unsolicited": "true",
            "proxy_base_url": "https://proxy.example.com",
            "user_email": "admin@example.com",
        }

        try:
            response = client.patch("/update/sso_settings", json=new_sso_settings)

            assert response.status_code == 200

            assert os.environ.get("SAML_IDP_METADATA_URL") == "https://idp.example.com/metadata"
            assert os.environ.get("SAML_SP_ENTITY_ID") == "https://proxy.example.com/sso/saml/metadata"
            assert os.environ.get("SAML_ALLOW_UNSOLICITED") == "true"
            assert "SAML_IDP_METADATA_XML" not in os.environ

            stored = json.loads(
                mock_prisma.db.litellm_ssoconfig.upsert.call_args.kwargs["data"]["create"]["sso_settings"]
            )
            assert stored["saml_idp_metadata_url"] == "https://idp.example.com/metadata"
            assert stored["saml_allow_unsolicited"] == "true"
        finally:
            for var in (
                "SAML_IDP_METADATA_URL",
                "SAML_IDP_METADATA_XML",
                "SAML_SP_ENTITY_ID",
                "SAML_ALLOW_UNSOLICITED",
            ):
                os.environ.pop(var, None)

    def test_update_sso_settings_audits_when_env_cleanup_fails(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        import json
        from unittest.mock import AsyncMock, MagicMock

        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        mock_prisma = MagicMock()
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
        mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
        mock_prisma.db.litellm_config = MagicMock()
        mock_prisma.db.litellm_config.find_unique = AsyncMock(
            side_effect=ValueError("cleanup failed")
        )
        mock_prisma.db.litellm_config.update = AsyncMock()
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        from litellm.proxy.proxy_server import proxy_config

        monkeypatch.setattr(
            proxy_config,
            "_encrypt_env_variables",
            lambda environment_variables: environment_variables,
        )

        create_config_audit_log = AsyncMock()
        monkeypatch.setattr(
            "litellm.proxy.proxy_server.create_config_audit_log",
            create_config_audit_log,
        )

        response = client.patch(
            "/update/sso_settings",
            json={"google_client_id": "new_google_client_id"},
        )

        assert response.status_code == 500
        assert mock_prisma.db.litellm_ssoconfig.upsert.called
        create_config_audit_log.assert_awaited_once()
        audit_log_kwargs = create_config_audit_log.await_args.kwargs
        assert audit_log_kwargs["param_name"] == "sso_config"
        assert (
            audit_log_kwargs["after_value"]["google_client_id"]
            == "new_google_client_id"
        )
        assert (
            json.loads(
                mock_prisma.db.litellm_ssoconfig.upsert.call_args.kwargs["data"][
                    "create"
                ]["sso_settings"]
            )["google_client_id"]
            == "new_google_client_id"
        )

    def test_update_sso_settings_with_null_values_clears_env_vars(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Test that updating SSO settings with null values clears environment variables and updates database"""
        import json
        from unittest.mock import AsyncMock, MagicMock

        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        # Mock the prisma client
        mock_prisma = MagicMock()
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
        mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
        mock_prisma.db.litellm_config = MagicMock()

        env_var_entry = MagicMock()
        env_var_entry.param_value = json.dumps(
            {
                "GOOGLE_CLIENT_ID": "old_google_id",
                "MICROSOFT_CLIENT_SECRET": "old_secret",
                "PROXY_BASE_URL": "old_proxy_url",
            }
        )
        mock_prisma.db.litellm_config.find_unique = AsyncMock(
            return_value=env_var_entry
        )
        mock_prisma.db.litellm_config.update = AsyncMock()
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        # Mock encryption to return values as-is
        from litellm.proxy.proxy_server import proxy_config

        monkeypatch.setattr(
            proxy_config,
            "_encrypt_env_variables",
            lambda environment_variables: environment_variables,
        )

        # Set some initial environment variables for runtime testing
        monkeypatch.setenv("GOOGLE_CLIENT_ID", "test_existing_google_id")
        monkeypatch.setenv("MICROSOFT_CLIENT_ID", "test_existing_microsoft_id")

        # Send SSO settings with null values to clear them
        clear_sso_settings = {
            "google_client_id": None,
            "google_client_secret": None,
            "microsoft_client_id": None,
            "microsoft_client_secret": None,
            "microsoft_tenant": None,
            "generic_client_id": None,
            "generic_client_secret": None,
            "generic_authorization_endpoint": None,
            "generic_token_endpoint": None,
            "generic_userinfo_endpoint": None,
            "proxy_base_url": None,
            "user_email": None,
            "sso_provider": None,
        }

        response = client.patch("/update/sso_settings", json=clear_sso_settings)

        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "success"

        # Verify that runtime environment variables were cleared
        assert "GOOGLE_CLIENT_ID" not in os.environ
        assert "MICROSOFT_CLIENT_ID" not in os.environ

        # Verify upsert was called with correct parameters
        assert mock_prisma.db.litellm_ssoconfig.upsert.called
        call_args = mock_prisma.db.litellm_ssoconfig.upsert.call_args

        # Verify null values are stored in database
        create_data = call_args.kwargs["data"]["create"]
        create_sso_settings = json.loads(create_data["sso_settings"])
        assert create_sso_settings["google_client_id"] is None
        assert create_sso_settings["microsoft_client_id"] is None

    def test_update_sso_settings_with_empty_strings_clears_env_vars(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Test that updating SSO settings with empty strings also clears environment variables and updates database"""
        import json
        from unittest.mock import AsyncMock, MagicMock

        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        # Mock the prisma client
        mock_prisma = MagicMock()
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
        mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
        mock_prisma.db.litellm_config = MagicMock()
        env_var_entry = MagicMock()
        env_var_entry.param_value = json.dumps(
            {
                "GOOGLE_CLIENT_ID": "old_google_id",
                "MICROSOFT_CLIENT_SECRET": "old_secret",
                "PROXY_BASE_URL": "old_proxy_url",
            }
        )
        mock_prisma.db.litellm_config.find_unique = AsyncMock(
            return_value=env_var_entry
        )
        mock_prisma.db.litellm_config.update = AsyncMock()
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        # Mock encryption to return values as-is
        from litellm.proxy.proxy_server import proxy_config

        monkeypatch.setattr(
            proxy_config,
            "_encrypt_env_variables",
            lambda environment_variables: environment_variables,
        )

        # Set some initial environment variables for runtime testing
        monkeypatch.setenv("GOOGLE_CLIENT_ID", "test_existing_google_id")
        monkeypatch.setenv("MICROSOFT_CLIENT_SECRET", "test_existing_microsoft_secret")

        # Send SSO settings with empty strings to clear them
        clear_sso_settings = {
            "google_client_id": "",
            "google_client_secret": "",
            "microsoft_client_secret": "",
            "proxy_base_url": "",
            "user_email": "",
        }

        response = client.patch("/update/sso_settings", json=clear_sso_settings)

        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "success"

        # Verify that runtime environment variables were cleared
        assert "GOOGLE_CLIENT_ID" not in os.environ
        assert "MICROSOFT_CLIENT_SECRET" not in os.environ

        # Verify upsert was called with correct parameters
        assert mock_prisma.db.litellm_ssoconfig.upsert.called
        call_args = mock_prisma.db.litellm_ssoconfig.upsert.call_args

        # Verify empty strings are stored in database
        create_data = call_args.kwargs["data"]["create"]
        create_sso_settings = json.loads(create_data["sso_settings"])
        assert create_sso_settings["google_client_id"] == ""
        assert create_sso_settings["microsoft_client_secret"] == ""

    def test_update_sso_settings_mixed_null_and_valid_values(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Test updating SSO settings with mix of null and valid values - verifies both env vars and database"""
        import json
        from unittest.mock import AsyncMock, MagicMock

        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        # Mock the prisma client
        mock_prisma = MagicMock()
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
        mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
        mock_prisma.db.litellm_config = MagicMock()

        env_var_entry = MagicMock()
        env_var_entry.param_value = json.dumps(
            {
                "GOOGLE_CLIENT_ID": "test_existing_google_id",
                "MICROSOFT_CLIENT_SECRET": "test_existing_microsoft_secret",
            }
        )
        mock_prisma.db.litellm_config.find_unique = AsyncMock(
            return_value=env_var_entry
        )
        mock_prisma.db.litellm_config.update = AsyncMock()
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        # Mock encryption to return values as-is
        from litellm.proxy.proxy_server import proxy_config

        monkeypatch.setattr(
            proxy_config,
            "_encrypt_env_variables",
            lambda environment_variables: environment_variables,
        )

        # Set some initial environment variables
        monkeypatch.setenv("GOOGLE_CLIENT_ID", "old_google_id")
        monkeypatch.setenv("MICROSOFT_CLIENT_ID", "old_microsoft_id")
        monkeypatch.setenv("PROXY_BASE_URL", "old_proxy_url")

        # Send mixed SSO settings - some null, some valid
        mixed_sso_settings = {
            "google_client_id": "new_google_client_id",  # Valid value
            "google_client_secret": None,  # Null to clear
            "microsoft_client_id": None,  # Null to clear
            "microsoft_client_secret": "new_microsoft_secret",  # Valid value
            "proxy_base_url": "https://newproxy.com",  # Valid value
            "user_email": None,  # Null to clear
        }

        response = client.patch("/update/sso_settings", json=mixed_sso_settings)

        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "success"

        # Verify runtime environment variables
        assert os.environ.get("GOOGLE_CLIENT_ID") == "new_google_client_id"
        assert os.environ.get("MICROSOFT_CLIENT_SECRET") == "new_microsoft_secret"
        assert "GOOGLE_CLIENT_SECRET" not in os.environ
        assert "MICROSOFT_CLIENT_ID" not in os.environ

        # Verify upsert was called with correct parameters
        assert mock_prisma.db.litellm_ssoconfig.upsert.called
        call_args = mock_prisma.db.litellm_ssoconfig.upsert.call_args

        # Verify the mixed values are stored correctly in database
        create_data = call_args.kwargs["data"]["create"]
        create_sso_settings = json.loads(create_data["sso_settings"])
        assert create_sso_settings["google_client_id"] == "new_google_client_id"
        assert create_sso_settings["google_client_secret"] is None
        assert create_sso_settings["microsoft_client_id"] is None
        assert create_sso_settings["microsoft_client_secret"] == "new_microsoft_secret"
        assert create_sso_settings["proxy_base_url"] == "https://newproxy.com"

    def test_update_sso_settings_ui_access_mode_handling(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Test that ui_access_mode is handled correctly and stored in database"""
        import json
        from unittest.mock import AsyncMock, MagicMock

        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        # Mock the prisma client
        mock_prisma = MagicMock()
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
        mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
        mock_prisma.db.litellm_config = MagicMock()
        mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
        mock_prisma.db.litellm_config.update = AsyncMock()
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        # Mock encryption to return values as-is
        from litellm.proxy.proxy_server import proxy_config

        monkeypatch.setattr(
            proxy_config,
            "_encrypt_env_variables",
            lambda environment_variables: environment_variables,
        )

        # Test setting ui_access_mode
        sso_settings_with_ui_mode = {
            "ui_access_mode": "admin_only",
            "user_email": "admin@test.com",
        }

        response = client.patch("/update/sso_settings", json=sso_settings_with_ui_mode)

        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "success"

        # Verify upsert was called with correct data
        assert mock_prisma.db.litellm_ssoconfig.upsert.called
        call_args = mock_prisma.db.litellm_ssoconfig.upsert.call_args
        create_data = call_args.kwargs["data"]["create"]
        create_sso_settings = json.loads(create_data["sso_settings"])
        assert create_sso_settings["ui_access_mode"] == "admin_only"
        assert create_sso_settings["user_email"] == "admin@test.com"

        # Test clearing ui_access_mode
        clear_ui_mode = {"ui_access_mode": None, "user_email": None}

        response = client.patch("/update/sso_settings", json=clear_ui_mode)

        assert response.status_code == 200

        # Verify upsert was called again with null values
        assert mock_prisma.db.litellm_ssoconfig.upsert.call_count == 2
        call_args = mock_prisma.db.litellm_ssoconfig.upsert.call_args
        create_data = call_args.kwargs["data"]["create"]
        create_sso_settings = json.loads(create_data["sso_settings"])
        assert create_sso_settings["ui_access_mode"] is None
        assert create_sso_settings["user_email"] is None

    def test_get_ui_theme_settings(self, mock_proxy_config):
        """Test getting UI theme settings without authentication"""
        response = client.get("/get/ui_theme_settings")

        assert response.status_code == 200
        data = response.json()

        assert "values" in data
        assert "field_schema" in data

    def test_update_ui_theme_settings(self, mock_proxy_config, mock_auth, monkeypatch):
        """Test updating UI theme settings"""
        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        new_theme = {"logo_url": "https://example.com/new-logo.png"}

        response = client.patch("/update/ui_theme_settings", json=new_theme)

        assert response.status_code == 200
        data = response.json()

        assert data["status"] == "success"
        assert data["theme_config"]["logo_url"] == "https://example.com/new-logo.png"

        # The logo path is applied to the live process immediately
        assert os.environ["UI_LOGO_PATH"] == "https://example.com/new-logo.png"
        assert mock_proxy_config["save_call_count"]() == 1

        # env vars are persisted through the dedicated per-key path, and ONLY
        # the keys this endpoint owns are touched. The unrelated SSO env
        # vars in the merged config are never snapshotted.
        env_updates = mock_proxy_config["env_updates"]()
        assert env_updates == [
            {
                "UI_LOGO_PATH": "https://example.com/new-logo.png",
                "UI_LOGO_PATH_DARK": None,
                "LITELLM_FAVICON_URL": None,
            }
        ]

    def test_update_ui_theme_settings_with_favicon(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Test updating UI theme settings with favicon_url"""
        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        new_theme = {
            "logo_url": "https://example.com/new-logo.png",
            "favicon_url": "https://example.com/custom-favicon.ico",
        }

        response = client.patch("/update/ui_theme_settings", json=new_theme)

        assert response.status_code == 200
        data = response.json()

        assert data["status"] == "success"
        assert data["theme_config"]["logo_url"] == "https://example.com/new-logo.png"
        assert (
            data["theme_config"]["favicon_url"]
            == "https://example.com/custom-favicon.ico"
        )

        assert os.environ["UI_LOGO_PATH"] == "https://example.com/new-logo.png"
        assert os.environ["LITELLM_FAVICON_URL"] == "https://example.com/custom-favicon.ico"
        # Only the owned keys are persisted, each with its new value
        assert mock_proxy_config["env_updates"]() == [
            {
                "UI_LOGO_PATH": "https://example.com/new-logo.png",
                "UI_LOGO_PATH_DARK": None,
                "LITELLM_FAVICON_URL": "https://example.com/custom-favicon.ico",
            }
        ]

    def test_update_ui_theme_settings_with_dark_logo(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """A dark-mode logo is stored and applied to the live process like the light one."""
        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        new_theme = {
            "logo_url": "https://example.com/logo.png",
            "logo_url_dark": "https://example.com/logo-dark.png",
        }

        response = client.patch("/update/ui_theme_settings", json=new_theme)

        assert response.status_code == 200
        assert response.json()["theme_config"]["logo_url_dark"] == "https://example.com/logo-dark.png"
        assert os.environ["UI_LOGO_PATH_DARK"] == "https://example.com/logo-dark.png"
        assert mock_proxy_config["env_updates"]() == [
            {
                "UI_LOGO_PATH": "https://example.com/logo.png",
                "UI_LOGO_PATH_DARK": "https://example.com/logo-dark.png",
                "LITELLM_FAVICON_URL": None,
            }
        ]

    def test_update_ui_theme_settings_rejects_local_path_dark_logo(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """The dark logo is served by the unauthenticated /get_image, so a local
        filesystem path must be refused exactly as it is for the light logo."""
        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        response = client.patch(
            "/update/ui_theme_settings",
            json={"logo_url_dark": "/etc/passwd"},
        )

        assert response.status_code == 400
        assert "logo_url_dark" in str(response.json())

    def test_update_ui_theme_settings_persists_every_env_var_it_resolves(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Read and write must cover the same env vars.

        /get/ui_theme_settings resolves each field through _UI_THEME_FIELD_ENV_VARS,
        so a var missing from the update path would read back from an env value the
        save never cleared, and the settings page would show a field it cannot unset.
        """
        from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
            _UI_THEME_FIELD_ENV_VARS,
        )

        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        response = client.patch("/update/ui_theme_settings", json={})

        assert response.status_code == 200
        persisted = mock_proxy_config["env_updates"]()
        assert len(persisted) == 1
        assert set(persisted[0]) == set(_UI_THEME_FIELD_ENV_VARS.values())

    def test_get_ui_theme_settings_surfaces_dark_logo_from_process_env(
        self, mock_proxy_config, monkeypatch
    ):
        """A dark logo supplied only as a process env var must surface in the read."""
        monkeypatch.setenv("UI_LOGO_PATH_DARK", "https://cdn.example.com/logo-dark.png")

        response = client.get("/get/ui_theme_settings")

        assert response.status_code == 200
        assert response.json()["values"]["logo_url_dark"] == "https://cdn.example.com/logo-dark.png"

    def test_update_ui_theme_settings_clear_favicon(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Test clearing favicon_url from UI theme settings"""
        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        new_theme = {
            "favicon_url": "https://example.com/custom-favicon.ico",
        }
        response = client.patch("/update/ui_theme_settings", json=new_theme)
        assert response.status_code == 200

        clear_theme = {"favicon_url": None}
        response = client.patch("/update/ui_theme_settings", json=clear_theme)
        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "success"
        assert "LITELLM_FAVICON_URL" not in os.environ

    def test_get_ui_theme_settings_includes_favicon_schema(self, mock_proxy_config):
        """Test UI theme settings includes favicon_url in schema"""
        response = client.get("/get/ui_theme_settings")

        assert response.status_code == 200
        data = response.json()

        assert "values" in data
        assert "field_schema" in data
        assert "properties" in data["field_schema"]
        assert "favicon_url" in data["field_schema"]["properties"]
        assert "description" in data["field_schema"]["properties"]["favicon_url"]

    def test_get_ui_theme_settings_with_favicon_configured(self, mock_proxy_config):
        """Test getting UI theme settings when favicon is configured"""
        mock_proxy_config["config"]["litellm_settings"]["ui_theme_config"] = {
            "logo_url": "https://example.com/logo.png",
            "favicon_url": "https://example.com/favicon.ico",
        }

        response = client.get("/get/ui_theme_settings")

        assert response.status_code == 200
        data = response.json()

        assert data["values"]["logo_url"] == "https://example.com/logo.png"
        assert data["values"]["favicon_url"] == "https://example.com/favicon.ico"

    def test_get_ui_theme_settings_falls_back_to_process_env(
        self, mock_proxy_config, monkeypatch
    ):
        """Branding supplied only as process env vars must surface in the read.

        A deployment that sets UI_LOGO_PATH / LITELLM_FAVICON_URL via IaC and
        never touches the UI has no stored ui_theme_config, yet the branding is
        live, so the settings page must reflect it rather than reading blank.
        """
        monkeypatch.delenv("UI_LOGO_PATH", raising=False)
        monkeypatch.delenv("LITELLM_FAVICON_URL", raising=False)
        monkeypatch.setenv("UI_LOGO_PATH", "https://cdn.example.com/logo.png")
        monkeypatch.setenv("LITELLM_FAVICON_URL", "https://cdn.example.com/favicon.ico")

        response = client.get("/get/ui_theme_settings")

        assert response.status_code == 200
        values = response.json()["values"]
        assert values["logo_url"] == "https://cdn.example.com/logo.png"
        assert values["favicon_url"] == "https://cdn.example.com/favicon.ico"

    def test_get_ui_theme_settings_stored_value_wins_over_env(
        self, mock_auth, monkeypatch
    ):
        """A stored ui_theme_config field outranks the env var for that field.

        The env fallback only fills fields the stored config leaves blank, so the
        UI-driven flow is unchanged while an unstored field still resolves.
        """
        from litellm.proxy.proxy_server import proxy_config

        stored_config = {
            "litellm_settings": {
                "ui_theme_config": {"logo_url": "https://db.example.com/logo.png"}
            }
        }

        async def mock_get_config():
            return stored_config

        monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
        monkeypatch.setenv("UI_LOGO_PATH", "https://env.example.com/logo.png")
        monkeypatch.setenv("LITELLM_FAVICON_URL", "https://env.example.com/favicon.ico")

        response = client.get("/get/ui_theme_settings")

        assert response.status_code == 200
        values = response.json()["values"]
        assert values["logo_url"] == "https://db.example.com/logo.png"
        assert values["favicon_url"] == "https://env.example.com/favicon.ico"

    def test_get_ui_theme_settings_reports_unset_when_absent_everywhere(
        self, mock_proxy_config, monkeypatch
    ):
        """A field set in neither the stored config nor the env stays null."""
        monkeypatch.delenv("UI_LOGO_PATH", raising=False)
        monkeypatch.delenv("LITELLM_FAVICON_URL", raising=False)

        response = client.get("/get/ui_theme_settings")

        assert response.status_code == 200
        values = response.json()["values"]
        assert values["logo_url"] is None
        assert values["favicon_url"] is None

    def test_get_ui_theme_settings_does_not_disclose_local_path_env_value(
        self, mock_proxy_config, monkeypatch
    ):
        """This endpoint is public, so an env-configured local filesystem branding
        path must never be surfaced to anonymous callers; only public http(s) URLs.
        """
        monkeypatch.setenv("UI_LOGO_PATH", "/mnt/secret/internal/logo.png")
        monkeypatch.setenv("LITELLM_FAVICON_URL", "file:///etc/favicon.ico")

        response = client.get("/get/ui_theme_settings")

        assert response.status_code == 200
        values = response.json()["values"]
        # the local path / file scheme is withheld rather than disclosed
        assert values["logo_url"] is None
        assert values["favicon_url"] is None

    def test_get_ui_settings(self, mock_auth, monkeypatch):
        """Test retrieving UI settings with allowlist sanitization"""
        from unittest.mock import AsyncMock, MagicMock

        mock_prisma = MagicMock()
        mock_db_record = MagicMock()
        mock_db_record.ui_settings = {
            "disable_model_add_for_internal_users": True,
            "require_auth_for_public_ai_hub": True,
            "unexpected_flag": True,
        }
        mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(
            return_value=mock_db_record
        )
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        response = client.get("/get/ui_settings")

        assert response.status_code == 200
        data = response.json()
        assert data["values"]["disable_model_add_for_internal_users"] is True
        assert data["values"]["require_auth_for_public_ai_hub"] is True
        assert "unexpected_flag" not in data["values"]
        assert (
            "disable_model_add_for_internal_users" in data["field_schema"]["properties"]
        )
        assert "require_auth_for_public_ai_hub" in data["field_schema"]["properties"]
        mock_prisma.db.litellm_uisettings.find_unique.assert_called_once_with(
            where={"id": "ui_settings"}
        )

    def test_get_ui_settings_schema_description_preserved_with_extensions(
        self, mock_auth, monkeypatch
    ):
        """The UI renders ``schema.description`` as a header paragraph.
        When an extension package registers extra fields, the effective
        class is built via ``create_model`` — which drops the base
        class docstring unless we pass ``__doc__`` explicitly."""
        from unittest.mock import AsyncMock, MagicMock

        from pydantic.fields import FieldInfo

        from litellm.proxy.ui_crud_endpoints import proxy_setting_endpoints
        from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
            _EXTRA_UI_SETTINGS_FIELDS,
            ALLOWED_UI_SETTINGS_FIELDS,
            register_extra_ui_setting,
        )

        # Snapshot + restore extension registry so the test doesn't leak.
        original_fields = dict(_EXTRA_UI_SETTINGS_FIELDS)
        original_allowed = set(ALLOWED_UI_SETTINGS_FIELDS)
        monkeypatch.setattr(
            proxy_setting_endpoints, "_EFFECTIVE_UI_SETTINGS_CLASS", None
        )

        try:
            register_extra_ui_setting(
                "test_extension_flag", bool, FieldInfo(default=False)
            )

            mock_prisma = MagicMock()
            mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None)
            monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

            response = client.get("/get/ui_settings")

            assert response.status_code == 200
            data = response.json()
            assert (
                data["field_schema"]["description"]
                == "Configuration for UI-specific flags"
            )
        finally:
            _EXTRA_UI_SETTINGS_FIELDS.clear()
            _EXTRA_UI_SETTINGS_FIELDS.update(original_fields)
            ALLOWED_UI_SETTINGS_FIELDS.clear()
            ALLOWED_UI_SETTINGS_FIELDS.update(original_allowed)
            proxy_setting_endpoints._EFFECTIVE_UI_SETTINGS_CLASS = None

    @pytest.mark.parametrize(
        "user_role",
        [
            LitellmUserRoles.INTERNAL_USER,
            LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
        ],
    )
    def test_get_ui_settings_allows_internal_roles(self, monkeypatch, user_role):
        """Ensure internal users and viewers can fetch UI settings"""
        from unittest.mock import AsyncMock, MagicMock

        from litellm.proxy.ui_crud_endpoints import proxy_setting_endpoints

        mock_prisma = MagicMock()
        mock_db_record = MagicMock()
        mock_db_record.ui_settings = {"disable_model_add_for_internal_users": False}
        mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(
            return_value=mock_db_record
        )
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        class MockUser:
            def __init__(self, role):
                self.user_role = role
                self.team_id = "litellm-dashboard"
                self.allowed_routes = []

        async def mock_user_api_key_auth():
            return MockUser(user_role)

        app.dependency_overrides[proxy_setting_endpoints.user_api_key_auth] = (
            mock_user_api_key_auth
        )

        try:
            response = client.get("/get/ui_settings")
        finally:
            app.dependency_overrides.pop(
                proxy_setting_endpoints.user_api_key_auth, None
            )

        assert response.status_code == 200
        data = response.json()
        assert data["values"]["disable_model_add_for_internal_users"] is False
        mock_prisma.db.litellm_uisettings.find_unique.assert_called_once_with(
            where={"id": "ui_settings"}
        )

    def test_update_ui_settings_allowlisted_value(self, mock_auth, monkeypatch):
        """Test updating UI settings with an allowlisted field"""
        from unittest.mock import AsyncMock, MagicMock

        from litellm.proxy._types import UserAPIKeyAuth
        from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

        # Override the FastAPI dependency with a proper mock
        mock_user_auth = UserAPIKeyAuth(
            user_id="test-user-123",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )
        app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth

        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
        mock_prisma = MagicMock()
        mock_prisma.db.litellm_uisettings.upsert = AsyncMock()
        mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None)
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        payload = {"disable_model_add_for_internal_users": True}

        try:
            response = client.patch("/update/ui_settings", json=payload)
        finally:
            # Clean up the dependency override
            app.dependency_overrides.clear()

        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "success"
        assert data["settings"]["disable_model_add_for_internal_users"] is True

        assert mock_prisma.db.litellm_uisettings.upsert.called
        call_args = mock_prisma.db.litellm_uisettings.upsert.call_args
        assert call_args.kwargs["where"]["id"] == "ui_settings"
        create_data = call_args.kwargs["data"]["create"]
        stored_settings = json.loads(create_data["ui_settings"])
        assert stored_settings["disable_model_add_for_internal_users"] is True

    def test_update_ui_settings_ignores_non_allowlisted_value(
        self, mock_auth, monkeypatch
    ):
        """Test non-allowlisted UI settings are ignored on update"""
        from unittest.mock import AsyncMock, MagicMock

        from litellm.proxy._types import UserAPIKeyAuth
        from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

        # Override the FastAPI dependency with a proper mock
        mock_user_auth = UserAPIKeyAuth(
            user_id="test-user-123",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )
        app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth

        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
        mock_prisma = MagicMock()
        mock_prisma.db.litellm_uisettings.upsert = AsyncMock()
        mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None)
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        payload = {
            "disable_model_add_for_internal_users": False,
            "unsupported_flag": True,
        }

        try:
            response = client.patch("/update/ui_settings", json=payload)
        finally:
            # Clean up the dependency override
            app.dependency_overrides.clear()

        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "success"
        assert "unsupported_flag" not in data["settings"]
        assert data["settings"]["disable_model_add_for_internal_users"] is False

        assert mock_prisma.db.litellm_uisettings.upsert.called
        call_args = mock_prisma.db.litellm_uisettings.upsert.call_args
        stored_settings = json.loads(call_args.kwargs["data"]["create"]["ui_settings"])
        assert "unsupported_flag" not in stored_settings
        assert stored_settings["disable_model_add_for_internal_users"] is False

    def test_update_ui_settings_preserves_public_ai_hub_auth_flag(
        self, mock_auth, monkeypatch
    ):
        """Public AI Hub auth is an existing UI setting and must remain writable."""
        from unittest.mock import AsyncMock, MagicMock

        from litellm.proxy._types import UserAPIKeyAuth
        from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

        mock_user_auth = UserAPIKeyAuth(
            user_id="test-user-123",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )
        app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth

        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
        mock_prisma = MagicMock()
        mock_prisma.db.litellm_uisettings.upsert = AsyncMock()
        mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None)
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        payload = {"require_auth_for_public_ai_hub": True}

        try:
            response = client.patch("/update/ui_settings", json=payload)
        finally:
            app.dependency_overrides.clear()

        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "success"
        assert data["settings"]["require_auth_for_public_ai_hub"] is True

        call_args = mock_prisma.db.litellm_uisettings.upsert.call_args
        stored_settings = json.loads(call_args.kwargs["data"]["create"]["ui_settings"])
        assert stored_settings["require_auth_for_public_ai_hub"] is True

    def test_update_ui_settings_persists_forward_llm_provider_auth_headers(
        self, mock_auth, monkeypatch
    ):
        """BYOK flag must be allowlisted and persisted to litellm_uisettings."""
        from unittest.mock import AsyncMock, MagicMock

        from litellm.proxy._types import UserAPIKeyAuth
        from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

        mock_user_auth = UserAPIKeyAuth(
            user_id="test-user-123",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )
        app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth

        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
        mock_prisma = MagicMock()
        mock_prisma.db.litellm_uisettings.upsert = AsyncMock()
        mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None)
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        payload = {"forward_llm_provider_auth_headers": True}

        try:
            response = client.patch("/update/ui_settings", json=payload)
        finally:
            app.dependency_overrides.clear()

        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "success"
        assert data["settings"]["forward_llm_provider_auth_headers"] is True

        assert mock_prisma.db.litellm_uisettings.upsert.called
        call_args = mock_prisma.db.litellm_uisettings.upsert.call_args
        create_data = call_args.kwargs["data"]["create"]
        stored_settings = json.loads(create_data["ui_settings"])
        assert stored_settings["forward_llm_provider_auth_headers"] is True

    def test_update_ui_settings_syncs_forward_llm_provider_auth_headers_to_general_settings(
        self, mock_auth, monkeypatch
    ):
        """BYOK flag must be synced into general_settings dict so the request path sees it."""
        from unittest.mock import AsyncMock, MagicMock

        from litellm.proxy._types import UserAPIKeyAuth
        from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

        mock_user_auth = UserAPIKeyAuth(
            user_id="test-user-123",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )
        app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth

        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        # Reset general_settings so the test is hermetic
        general_settings: dict = {}
        monkeypatch.setattr(
            "litellm.proxy.proxy_server.general_settings", general_settings
        )

        mock_prisma = MagicMock()
        mock_prisma.db.litellm_uisettings.upsert = AsyncMock()
        mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None)
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        payload = {"forward_llm_provider_auth_headers": True}

        try:
            response = client.patch("/update/ui_settings", json=payload)
        finally:
            app.dependency_overrides.clear()

        assert response.status_code == 200
        assert general_settings.get("forward_llm_provider_auth_headers") is True

    def test_update_ui_settings_syncs_public_health_readiness_details_to_general_settings(
        self, mock_auth, monkeypatch
    ):
        """Public readiness details flag must be synced so the health route sees it."""
        from unittest.mock import AsyncMock, MagicMock

        from litellm.proxy._types import UserAPIKeyAuth
        from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

        mock_user_auth = UserAPIKeyAuth(
            user_id="test-user-123",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )
        app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth

        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        general_settings: dict = {}
        monkeypatch.setattr(
            "litellm.proxy.proxy_server.general_settings", general_settings
        )

        mock_prisma = MagicMock()
        mock_prisma.db.litellm_uisettings.upsert = AsyncMock()
        mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None)
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        payload = {"allow_public_health_readiness_details": True}

        try:
            response = client.patch("/update/ui_settings", json=payload)
        finally:
            app.dependency_overrides.clear()

        assert response.status_code == 200
        assert general_settings.get("allow_public_health_readiness_details") is True

    def test_update_ui_settings_persists_and_syncs_disable_key_generate_for_org_admin(
        self, mock_auth, monkeypatch
    ):
        """disable_key_generate_for_org_admin must be allowlisted, persisted, and synced to general_settings."""
        from unittest.mock import AsyncMock, MagicMock

        from litellm.proxy._types import UserAPIKeyAuth
        from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

        mock_user_auth = UserAPIKeyAuth(
            user_id="test-user-123",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )
        app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth

        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        general_settings: dict = {}
        monkeypatch.setattr(
            "litellm.proxy.proxy_server.general_settings", general_settings
        )

        mock_prisma = MagicMock()
        mock_prisma.db.litellm_uisettings.upsert = AsyncMock()
        mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None)
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        flag_name = "disable_key_generate_for_org_admin"
        payload = {flag_name: True}

        try:
            response = client.patch("/update/ui_settings", json=payload)
        finally:
            app.dependency_overrides.clear()

        assert response.status_code == 200
        data = response.json()
        assert data["settings"][flag_name] is True

        # Persisted in DB
        call_args = mock_prisma.db.litellm_uisettings.upsert.call_args
        stored_settings = json.loads(call_args.kwargs["data"]["create"]["ui_settings"])
        assert stored_settings[flag_name] is True

        # Synced into general_settings so the enforcement helper sees it
        assert general_settings.get(flag_name) is True

    def test_get_sso_settings_from_database(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Test getting SSO settings from the dedicated database table"""
        import json
        from unittest.mock import AsyncMock, MagicMock

        # Mock the prisma client
        mock_prisma = MagicMock()
        mock_db_record = MagicMock()

        # Simulate encrypted data from database
        mock_sso_settings = {
            "google_client_id": "encrypted_google_id",
            "google_client_secret": "encrypted_google_secret",
            "microsoft_client_id": "encrypted_microsoft_id",
            "proxy_base_url": "encrypted_proxy_url",
        }

        mock_db_record.sso_settings = mock_sso_settings
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(
            return_value=mock_db_record
        )

        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        # The resolver decrypts each stored value via decrypt_value_helper; map
        # the ciphertext fixtures to their plaintext.
        decrypted_by_ciphertext = {
            "encrypted_google_id": "decrypted_google_id",
            "encrypted_google_secret": "decrypted_google_secret",
            "encrypted_microsoft_id": "decrypted_microsoft_id",
            "encrypted_proxy_url": "https://decrypted.example.com",
        }

        def mock_decrypt(value, key, exception_type="error", return_original_value=False):
            return decrypted_by_ciphertext.get(value, value)

        monkeypatch.setattr(
            "litellm.proxy.config_resolvers.sso.decrypt_value_helper", mock_decrypt
        )

        response = client.get("/get/sso_settings")

        assert response.status_code == 200
        data = response.json()

        # Verify structure
        assert "values" in data
        assert "field_schema" in data

        # Verify decrypted values are returned. OAuth client secrets are
        # masked on read so plaintext is never sent to the UI.
        values = data["values"]
        assert values["google_client_id"] == "decrypted_google_id"
        assert values["google_client_secret"] != "decrypted_google_secret"
        assert "*" in values["google_client_secret"]
        assert values["microsoft_client_id"] == "decrypted_microsoft_id"
        assert values["proxy_base_url"] == "https://decrypted.example.com"

        # Verify role_mappings is present in response (can be None if not set)
        assert "role_mappings" in values
        assert values["role_mappings"] is None

    def test_update_sso_settings_to_database(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Test updating SSO settings saves to the dedicated database table"""
        import json
        from unittest.mock import AsyncMock, MagicMock

        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")

        # Mock the prisma client
        mock_prisma = MagicMock()
        upsert_mock = AsyncMock()
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
        mock_prisma.db.litellm_ssoconfig.upsert = upsert_mock
        mock_prisma.db.litellm_config = MagicMock()
        mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
        mock_prisma.db.litellm_config.update = AsyncMock()

        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        # Track what was encrypted
        encrypted_data = {}

        def mock_encrypt(environment_variables):
            # Simulate encryption by adding prefix
            encrypted = {
                k: f"encrypted_{v}" if v else v
                for k, v in environment_variables.items()
            }
            encrypted_data.update(encrypted)
            return encrypted

        from litellm.proxy.proxy_server import proxy_config

        monkeypatch.setattr(proxy_config, "_encrypt_env_variables", mock_encrypt)

        # New SSO settings to save
        new_sso_settings = {
            "google_client_id": "new_google_id",
            "google_client_secret": "new_google_secret",
            "microsoft_client_id": "new_microsoft_id",
            "proxy_base_url": "https://new.example.com",
        }

        response = client.patch("/update/sso_settings", json=new_sso_settings)

        assert response.status_code == 200
        data = response.json()

        assert data["status"] == "success"
        assert data["settings"]["google_client_id"] == "new_google_id"

        # Verify upsert was called
        assert upsert_mock.called
        call_args = upsert_mock.call_args

        # Verify it's using the correct ID
        assert call_args.kwargs["where"]["id"] == "sso_config"

        # Verify encrypted data was saved
        create_data = call_args.kwargs["data"]["create"]
        update_data = call_args.kwargs["data"]["update"]

        assert create_data["id"] == "sso_config"
        # The sso_settings should be JSON string of encrypted data
        assert "sso_settings" in create_data
        assert "sso_settings" in update_data

        # Verify the encrypted data is correctly stored
        create_sso_settings = json.loads(create_data["sso_settings"])
        assert create_sso_settings["google_client_id"] == "encrypted_new_google_id"
        assert (
            create_sso_settings["google_client_secret"] == "encrypted_new_google_secret"
        )
        assert (
            create_sso_settings["proxy_base_url"] == "encrypted_https://new.example.com"
        )

    def test_update_sso_settings_removes_sso_env_vars_from_config(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Ensure SSO-related env vars are deleted from stored config"""
        import json
        from unittest.mock import AsyncMock, MagicMock

        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        mock_prisma = MagicMock()
        mock_prisma.db = MagicMock()
        mock_prisma.db.litellm_ssoconfig = MagicMock()
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
        mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()

        env_var_entry = MagicMock()
        env_var_entry.param_value = json.dumps(
            {
                "GOOGLE_CLIENT_ID": "old_google_id",
                "GENERIC_TOKEN_ENDPOINT": "old_endpoint",
                "UNCHANGED_ENV": "keep_me",
            }
        )
        mock_prisma.db.litellm_config = MagicMock()
        mock_prisma.db.litellm_config.find_unique = AsyncMock(
            return_value=env_var_entry
        )
        mock_prisma.db.litellm_config.update = AsyncMock()
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        from litellm.proxy.proxy_server import proxy_config

        monkeypatch.setattr(
            proxy_config,
            "_encrypt_env_variables",
            lambda environment_variables: environment_variables,
        )

        response = client.patch(
            "/update/sso_settings", json={"google_client_id": "new_google_id"}
        )

        assert response.status_code == 200
        mock_prisma.db.litellm_config.find_unique.assert_called_once()
        mock_prisma.db.litellm_config.update.assert_called_once()
        update_call = mock_prisma.db.litellm_config.update.call_args
        updated_env_vars = json.loads(update_call.kwargs["data"]["param_value"])
        assert "GOOGLE_CLIENT_ID" not in updated_env_vars
        assert "GENERIC_TOKEN_ENDPOINT" not in updated_env_vars
        assert updated_env_vars["UNCHANGED_ENV"] == "keep_me"

    def test_update_sso_settings_preserves_non_sso_env_vars(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Ensure env vars outside SSO mapping remain unchanged"""
        import json
        from unittest.mock import AsyncMock, MagicMock

        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        mock_prisma = MagicMock()
        mock_prisma.db = MagicMock()
        mock_prisma.db.litellm_ssoconfig = MagicMock()
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
        mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()

        env_var_entry = MagicMock()
        env_var_entry.param_value = {
            "UNRELATED_ENV": "keep_this",
            "ANOTHER_ENV": "also_keep",
        }
        mock_prisma.db.litellm_config = MagicMock()
        mock_prisma.db.litellm_config.find_unique = AsyncMock(
            return_value=env_var_entry
        )
        mock_prisma.db.litellm_config.update = AsyncMock()
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        from litellm.proxy.proxy_server import proxy_config

        monkeypatch.setattr(
            proxy_config,
            "_encrypt_env_variables",
            lambda environment_variables: environment_variables,
        )

        response = client.patch(
            "/update/sso_settings", json={"microsoft_client_id": "new_microsoft_id"}
        )

        assert response.status_code == 200
        mock_prisma.db.litellm_config.find_unique.assert_called_once()
        mock_prisma.db.litellm_config.update.assert_called_once()
        update_call = mock_prisma.db.litellm_config.update.call_args
        updated_env_vars = json.loads(update_call.kwargs["data"]["param_value"])
        assert updated_env_vars == env_var_entry.param_value

    def test_get_sso_settings_empty_database(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Test getting SSO settings when database table is empty"""
        from unittest.mock import AsyncMock, MagicMock

        # Mock the prisma client to return None (no record found)
        mock_prisma = MagicMock()
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)

        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        # Mock the decryption method
        def mock_decrypt_and_set(environment_variables):
            # Should receive empty dict
            return environment_variables

        from litellm.proxy.proxy_server import proxy_config

        monkeypatch.setattr(
            proxy_config, "_decrypt_and_set_db_env_variables", mock_decrypt_and_set
        )

        response = client.get("/get/sso_settings")

        assert response.status_code == 200
        data = response.json()

        # Verify structure is still correct with empty values
        assert "values" in data
        assert "field_schema" in data

        # All values should be None
        values = data["values"]
        assert values.get("google_client_id") is None
        assert values.get("google_client_secret") is None
        assert values.get("microsoft_client_id") is None
        assert values.get("role_mappings") is None

    def test_update_sso_settings_no_database_connection(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Test updating SSO settings when database is not connected"""
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)

        new_sso_settings = {
            "google_client_id": "new_google_id",
        }

        response = client.patch("/update/sso_settings", json=new_sso_settings)

        assert response.status_code == 500
        data = response.json()
        assert "error" in data["detail"]
        assert "Database not connected" in data["detail"]["error"]

    def test_get_sso_settings_no_database_connection(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Test getting SSO settings when database is not connected"""
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)

        response = client.get("/get/sso_settings")

        assert response.status_code == 500
        data = response.json()
        assert "error" in data["detail"]
        assert "Database not connected" in data["detail"]["error"]

    def test_get_sso_settings_with_role_mappings(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Test getting SSO settings when role_mappings is present in database"""
        from unittest.mock import AsyncMock, MagicMock

        from litellm.proxy._types import LitellmUserRoles

        # Mock the prisma client with database record containing role_mappings
        mock_prisma = MagicMock()
        mock_db_record = MagicMock()
        mock_db_record.sso_settings = {
            "google_client_id": "test_google_client_id",
            "role_mappings": {
                "provider": "google",
                "group_claim": "groups",
                "default_role": LitellmUserRoles.INTERNAL_USER,
                "roles": {
                    LitellmUserRoles.PROXY_ADMIN: ["admin-group"],
                },
            },
        }
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(
            return_value=mock_db_record
        )
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        # Mock decryption to return the values as-is (role_mappings should not be passed to decryption)
        from litellm.proxy.proxy_server import proxy_config

        def mock_decrypt(environment_variables):
            # role_mappings should not be in environment_variables since it's extracted before decryption
            assert "role_mappings" not in environment_variables
            return environment_variables

        monkeypatch.setattr(
            proxy_config, "_decrypt_and_set_db_env_variables", mock_decrypt
        )

        response = client.get("/get/sso_settings")

        assert response.status_code == 200
        data = response.json()

        # Verify role_mappings is returned correctly
        values = data["values"]
        assert "role_mappings" in values
        assert values["role_mappings"] is not None
        assert values["role_mappings"]["provider"] == "google"
        assert values["role_mappings"]["group_claim"] == "groups"
        assert values["role_mappings"]["default_role"] == LitellmUserRoles.INTERNAL_USER
        assert values["role_mappings"]["roles"][LitellmUserRoles.PROXY_ADMIN] == [
            "admin-group"
        ]

    def test_role_mappings_stored_and_retrieved(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        """Test that role_mappings is properly stored and retrieved from SSO settings"""
        import json
        from unittest.mock import AsyncMock, MagicMock

        from litellm.proxy._types import LitellmUserRoles

        monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

        # Mock the prisma client
        mock_prisma = MagicMock()
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
        mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
        mock_prisma.db.litellm_config = MagicMock()
        mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
        mock_prisma.db.litellm_config.update = AsyncMock()
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        # Mock encryption to return values as-is
        from litellm.proxy.proxy_server import proxy_config

        monkeypatch.setattr(
            proxy_config,
            "_encrypt_env_variables",
            lambda environment_variables: environment_variables,
        )

        # SSO settings with role_mappings
        role_mappings_data = {
            "provider": "google",
            "group_claim": "groups",
            "default_role": LitellmUserRoles.INTERNAL_USER,
            "roles": {
                LitellmUserRoles.PROXY_ADMIN: ["admin-group"],
                LitellmUserRoles.INTERNAL_USER: ["user-group"],
            },
        }

        new_sso_settings = {
            "google_client_id": "test_google_id",
            "role_mappings": role_mappings_data,
        }

        response = client.patch("/update/sso_settings", json=new_sso_settings)

        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "success"
        assert "role_mappings" in data["settings"]

        # Verify role_mappings structure in response
        returned_role_mappings = data["settings"]["role_mappings"]
        assert returned_role_mappings["provider"] == "google"
        assert returned_role_mappings["group_claim"] == "groups"
        assert returned_role_mappings["default_role"] == LitellmUserRoles.INTERNAL_USER
        assert returned_role_mappings["roles"][LitellmUserRoles.PROXY_ADMIN] == [
            "admin-group"
        ]

        # Verify upsert was called with role_mappings in the data
        assert mock_prisma.db.litellm_ssoconfig.upsert.called
        call_args = mock_prisma.db.litellm_ssoconfig.upsert.call_args
        create_data = call_args.kwargs["data"]["create"]
        stored_sso_settings = json.loads(create_data["sso_settings"])
        assert "role_mappings" in stored_sso_settings
        assert stored_sso_settings["role_mappings"]["provider"] == "google"

        # Now test retrieving role_mappings
        mock_db_record = MagicMock()
        mock_db_record.sso_settings = stored_sso_settings
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(
            return_value=mock_db_record
        )
        monkeypatch.setattr(
            proxy_config,
            "_decrypt_and_set_db_env_variables",
            lambda environment_variables: environment_variables,
        )

        get_response = client.get("/get/sso_settings")
        assert get_response.status_code == 200
        get_data = get_response.json()

        # Verify role_mappings is returned correctly
        assert "role_mappings" in get_data["values"]
        retrieved_role_mappings = get_data["values"]["role_mappings"]
        assert retrieved_role_mappings is not None
        assert retrieved_role_mappings["provider"] == "google"
        assert retrieved_role_mappings["group_claim"] == "groups"
        assert retrieved_role_mappings["default_role"] == LitellmUserRoles.INTERNAL_USER

    def test_setup_role_mappings_custom_logic_with_env_vars(self, monkeypatch):
        """Test the _setup_role_mappings function directly with custom role mapping logic from environment variables"""
        import asyncio
        import os

        from litellm.proxy._types import LitellmUserRoles
        from litellm.proxy.management_endpoints.ui_sso import _setup_role_mappings

        # Set up environment variables for custom role mappings using valid Python dict format
        monkeypatch.setenv(
            "GENERIC_ROLE_MAPPINGS_ROLES",
            "{'proxy_admin': ['custom-admin-group'], 'internal_user': ['custom-user-group'], 'proxy_admin_viewer': ['custom-viewer-group']}",
        )
        monkeypatch.setenv("GENERIC_ROLE_MAPPINGS_GROUP_CLAIM", "custom-groups")
        monkeypatch.setenv("GENERIC_ROLE_MAPPINGS_DEFAULT_ROLE", "internal_user_viewer")

        # Debug: Print environment variables
        print("GENERIC_ROLE_MAPPINGS_ROLES:", os.getenv("GENERIC_ROLE_MAPPINGS_ROLES"))
        print(
            "GENERIC_ROLE_MAPPINGS_GROUP_CLAIM:",
            os.getenv("GENERIC_ROLE_MAPPINGS_GROUP_CLAIM"),
        )
        print(
            "GENERIC_ROLE_MAPPINGS_DEFAULT_ROLE:",
            os.getenv("GENERIC_ROLE_MAPPINGS_DEFAULT_ROLE"),
        )

        # Run the async function
        role_mappings = asyncio.run(_setup_role_mappings())

        # Debug: Print result
        print("role_mappings result:", role_mappings)

        # Verify role_mappings is returned correctly from environment variables
        assert role_mappings is not None
        assert role_mappings.provider == "generic"
        assert role_mappings.group_claim == "custom-groups"
        assert role_mappings.default_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY
        assert role_mappings.roles[LitellmUserRoles.PROXY_ADMIN] == [
            "custom-admin-group"
        ]
        assert role_mappings.roles[LitellmUserRoles.INTERNAL_USER] == [
            "custom-user-group"
        ]
        assert role_mappings.roles[LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY] == [
            "custom-viewer-group"
        ]

    def test_setup_role_mappings_custom_logic_with_no_config(self, monkeypatch):
        """Test the _setup_role_mappings function returns None when no configuration is available"""
        import asyncio
        from unittest.mock import AsyncMock, MagicMock

        from litellm.proxy.management_endpoints.ui_sso import _setup_role_mappings

        # Ensure environment variables are not set
        monkeypatch.delenv("GENERIC_ROLE_MAPPINGS_ROLES", raising=False)
        monkeypatch.delenv("GENERIC_ROLE_MAPPINGS_GROUP_CLAIM", raising=False)
        monkeypatch.delenv("GENERIC_ROLE_MAPPINGS_DEFAULT_ROLE", raising=False)

        # Mock the prisma client to return None (no database record)
        mock_prisma = MagicMock()
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
        # Run the async function
        role_mappings = asyncio.run(_setup_role_mappings())

        # Should return None when no configuration is available
        assert role_mappings is None

    def test_get_sso_settings_with_env_role_mappings(
        self, mock_proxy_config, mock_auth, monkeypatch
    ):
        import json
        from unittest.mock import AsyncMock, MagicMock

        from litellm.proxy._types import LitellmUserRoles

        monkeypatch.setenv(
            "GENERIC_ROLE_MAPPINGS_ROLES",
            '{"proxy_admin": ["custom-admin-group"], "internal_user": ["custom-user-group"], "proxy_admin_viewer": ["custom-viewer-group"]}',
        )
        monkeypatch.setenv("GENERIC_ROLE_MAPPINGS_GROUP_CLAIM", "custom-groups")
        monkeypatch.setenv("GENERIC_ROLE_MAPPINGS_DEFAULT_ROLE", "internal_user_viewer")

        mock_prisma = MagicMock()
        mock_db_record = MagicMock()
        mock_db_record.sso_settings = {
            "google_client_id": "test_google_client_id",
            "role_mappings": {
                "provider": "google",
                "group_claim": "db-groups",
                "default_role": "proxy_admin",
                "roles": {
                    "proxy_admin": ["db-admin-group"],
                },
            },
        }
        mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(
            return_value=mock_db_record
        )
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)

        from litellm.proxy.proxy_server import proxy_config

        monkeypatch.setattr(
            proxy_config,
            "_decrypt_and_set_db_env_variables",
            lambda environment_variables: environment_variables,
        )

        response = client.get("/get/sso_settings")

        assert response.status_code == 200
        data = response.json()

        values = data["values"]
        assert "role_mappings" in values
        assert values["role_mappings"] is not None

        # The database values shoeld override the environment variables
        assert values["role_mappings"]["provider"] == "google"
        assert values["role_mappings"]["group_claim"] == "db-groups"
        assert values["role_mappings"]["default_role"] == LitellmUserRoles.PROXY_ADMIN
        assert values["role_mappings"]["roles"][LitellmUserRoles.PROXY_ADMIN] == [
            "db-admin-group"
        ]

        # Verify that the database was checked but environment variables took priority
        mock_prisma.db.litellm_ssoconfig.find_unique.assert_called_once_with(
            where={"id": "sso_config"}
        )

        # Verify other SSO settings are still correctly returned
        assert values["google_client_id"] == "test_google_client_id"

        # Verify field_schema is still present
        assert "field_schema" in data
        assert "properties" in data["field_schema"]
        assert "role_mappings" in data["field_schema"]["properties"]


def test_update_internal_user_settings_writes_audit_log(mock_proxy_config, monkeypatch):
    """Regression for the reported scenario: an admin changes Default User
    Settings from the dashboard, which issues PATCH /update/internal_user_settings
    (NOT /config/update). An audit row must record who changed it and what
    changed."""
    from unittest.mock import AsyncMock, MagicMock

    import litellm
    import litellm.proxy.proxy_server as proxy_server_module
    from litellm.proxy._types import UserAPIKeyAuth
    from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

    audit_create = AsyncMock()
    fake_prisma = MagicMock()
    fake_prisma.db.litellm_auditlog.create = audit_create

    monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma)
    monkeypatch.setattr(proxy_server_module, "premium_user", True)
    monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
    monkeypatch.setattr(litellm, "store_audit_logs", True)
    monkeypatch.setattr(litellm, "default_internal_user_params", {})

    async def _admin_auth():
        return UserAPIKeyAuth(
            user_id="audit-admin",
            api_key="hashed-admin-key",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )

    app.dependency_overrides[user_api_key_auth] = _admin_auth
    try:
        resp = client.patch(
            "/update/internal_user_settings",
            json={"max_budget": 999.0, "models": ["gpt-4"]},
        )
        assert resp.status_code == 200, resp.text

        audit_create.assert_awaited_once()
        written = audit_create.await_args.kwargs["data"]
        assert written["object_id"] == "default_internal_user_params"
        assert written["action"] == "updated"
        assert written["table_name"] == "LiteLLM_Config"
        assert written["changed_by"] == "audit-admin"
        assert written["changed_by_api_key"] == "hashed-admin-key"

        before = json.loads(written["before_value"])
        after = json.loads(written["updated_values"])
        assert before["max_budget"] == 100.0
        assert after["max_budget"] == 999.0
    finally:
        app.dependency_overrides.pop(user_api_key_auth, None)


def test_update_internal_user_settings_returns_200_when_audit_write_raises(
    mock_proxy_config, monkeypatch
):
    """The settings change is already committed by save_config, so an
    audit-log failure must never surface as a 500. Scheduling via
    asyncio.create_task keeps the audit call off the request path; this
    test asserts that contract by making the audit helper raise."""
    import litellm
    import litellm.proxy.proxy_server as proxy_server_module
    from litellm.proxy._types import UserAPIKeyAuth
    from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

    monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
    monkeypatch.setattr(litellm, "default_internal_user_params", {})

    async def _raise(**_kwargs):
        raise RuntimeError("audit prisma blip")

    monkeypatch.setattr(proxy_server_module, "create_config_audit_log", _raise)

    async def _admin_auth():
        return UserAPIKeyAuth(
            user_id="audit-admin",
            api_key="hashed-admin-key",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )

    app.dependency_overrides[user_api_key_auth] = _admin_auth
    try:
        resp = client.patch(
            "/update/internal_user_settings", json={"max_budget": 42.0}
        )
        assert resp.status_code == 200, resp.text
        assert resp.json()["status"] == "success"
    finally:
        app.dependency_overrides.pop(user_api_key_auth, None)


def test_update_sso_settings_writes_redacted_audit_log(mock_proxy_config, monkeypatch):
    """Updating SSO settings must write an audit row to the SSO config table
    with the client secret redacted."""
    from unittest.mock import AsyncMock, MagicMock

    import litellm
    import litellm.proxy.proxy_server as proxy_server_module
    from litellm.proxy._types import UserAPIKeyAuth
    from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

    audit_create = AsyncMock()
    fake_prisma = MagicMock()
    fake_prisma.db.litellm_auditlog.create = audit_create
    fake_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
    # No prior SSO row, so before_value resolves to None.
    fake_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
    fake_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)

    monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma)
    monkeypatch.setattr(proxy_server_module, "premium_user", True)
    monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
    monkeypatch.setattr(litellm, "store_audit_logs", True)
    monkeypatch.setattr(
        proxy_server_module.proxy_config,
        "_encrypt_env_variables",
        lambda environment_variables: environment_variables,
    )

    async def _admin_auth():
        return UserAPIKeyAuth(
            user_id="audit-admin",
            api_key="hashed-admin-key",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )

    app.dependency_overrides[user_api_key_auth] = _admin_auth
    try:
        resp = client.patch(
            "/update/sso_settings",
            json={
                "google_client_id": "client-id-123",
                "google_client_secret": "super-secret-xyz",
            },
        )
        assert resp.status_code == 200, resp.text

        audit_create.assert_awaited_once()
        written = audit_create.await_args.kwargs["data"]
        assert written["object_id"] == "sso_config"
        assert written["table_name"] == "LiteLLM_SSOConfig"
        assert written["changed_by"] == "audit-admin"

        after = json.loads(written["updated_values"])
        assert after["google_client_id"] == "client-id-123"
        assert after["google_client_secret"] == "REDACTED"
        assert "super-secret-xyz" not in written["updated_values"]
    finally:
        app.dependency_overrides.pop(user_api_key_auth, None)


def test_update_sso_settings_audit_captures_redacted_before_snapshot(
    mock_proxy_config, monkeypatch
):
    """An auditor reviewing an SSO secret rotation needs to see a real
    before/after diff in the audit row, not before_value=None. The endpoint
    reads the existing (encrypted) SSO row, decrypts it, and lets the audit
    helper redact the *_client_secret fields before persistence so neither
    the old nor the new plaintext secret is recorded."""
    from unittest.mock import AsyncMock, MagicMock

    import litellm
    import litellm.proxy.proxy_server as proxy_server_module
    from litellm.proxy._types import UserAPIKeyAuth
    from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

    audit_create = AsyncMock()
    fake_prisma = MagicMock()
    fake_prisma.db.litellm_auditlog.create = audit_create
    fake_prisma.db.litellm_ssoconfig.upsert = AsyncMock()

    # Pre-existing SSO row contains the *prior* secret (would be ciphertext in
    # production; the test patches _decrypt_db_variables to pass through).
    existing_record = MagicMock()
    existing_record.sso_settings = {
        "google_client_id": "old-client-id",
        "google_client_secret": "OLD-SUPER-SECRET",
    }
    fake_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=existing_record)
    fake_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)

    monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma)
    monkeypatch.setattr(proxy_server_module, "premium_user", True)
    monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
    monkeypatch.setattr(litellm, "store_audit_logs", True)
    monkeypatch.setattr(
        proxy_server_module.proxy_config,
        "_encrypt_env_variables",
        lambda environment_variables: environment_variables,
    )
    # Pretend the stored value is already plaintext for the test (production
    # decrypts via Fernet); the audit helper still has to redact it.
    monkeypatch.setattr(
        proxy_server_module.proxy_config,
        "_decrypt_db_variables",
        lambda variables_dict: dict(variables_dict),
    )

    async def _admin_auth():
        return UserAPIKeyAuth(
            user_id="audit-admin",
            api_key="hashed-admin-key",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )

    app.dependency_overrides[user_api_key_auth] = _admin_auth
    try:
        resp = client.patch(
            "/update/sso_settings",
            json={
                "google_client_id": "new-client-id",
                "google_client_secret": "NEW-SUPER-SECRET",
            },
        )
        assert resp.status_code == 200, resp.text

        audit_create.assert_awaited_once()
        written = audit_create.await_args.kwargs["data"]
        before = json.loads(written["before_value"])
        after = json.loads(written["updated_values"])

        # Non-secret field shows the diff
        assert before["google_client_id"] == "old-client-id"
        assert after["google_client_id"] == "new-client-id"

        # Secret field is redacted in BOTH snapshots — auditor sees the
        # rotation event without ever seeing either plaintext secret.
        assert before["google_client_secret"] == "REDACTED"
        assert after["google_client_secret"] == "REDACTED"
        assert "OLD-SUPER-SECRET" not in written["before_value"]
        assert "NEW-SUPER-SECRET" not in written["updated_values"]
    finally:
        app.dependency_overrides.pop(user_api_key_auth, None)


def test_add_allowed_ip_writes_audit_log(mock_proxy_config, monkeypatch):
    """Adding an allowed IP is a system-wide security setting change and must
    be audited with the before and after IP list."""
    from unittest.mock import AsyncMock, MagicMock

    import litellm
    import litellm.proxy.proxy_server as proxy_server_module
    from litellm.proxy._types import UserAPIKeyAuth
    from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

    audit_create = AsyncMock()
    fake_prisma = MagicMock()
    fake_prisma.db.litellm_auditlog.create = audit_create

    monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma)
    monkeypatch.setattr(proxy_server_module, "premium_user", True)
    monkeypatch.setattr(proxy_server_module, "general_settings", {})
    monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
    monkeypatch.setattr(litellm, "store_audit_logs", True)

    async def _admin_auth():
        return UserAPIKeyAuth(
            user_id="audit-admin",
            api_key="hashed-admin-key",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )

    app.dependency_overrides[user_api_key_auth] = _admin_auth
    try:
        resp = client.post("/add/allowed_ip", json={"ip": "203.0.113.77"})
        assert resp.status_code == 200, resp.text

        audit_create.assert_awaited_once()
        written = audit_create.await_args.kwargs["data"]
        assert written["object_id"] == "general_settings"
        assert written["action"] == "updated"
        assert written["changed_by"] == "audit-admin"

        before = json.loads(written["before_value"])
        after = json.loads(written["updated_values"])
        assert "203.0.113.77" not in before["allowed_ips"]
        assert "203.0.113.77" in after["allowed_ips"]
    finally:
        app.dependency_overrides.pop(user_api_key_auth, None)


def test_delete_allowed_ip_writes_deleted_audit_log(monkeypatch):
    """Removing an allowed IP must be audited as a deletion, symmetric with the
    add path."""
    from unittest.mock import AsyncMock, MagicMock

    import litellm
    import litellm.proxy.proxy_server as proxy_server_module
    from litellm.proxy._types import UserAPIKeyAuth
    from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

    audit_create = AsyncMock()
    fake_prisma = MagicMock()
    fake_prisma.db.litellm_auditlog.create = audit_create

    config = {"general_settings": {"allowed_ips": ["203.0.113.77", "198.51.100.1"]}}

    async def _get_config():
        return config

    async def _save_config(new_config=None):
        nonlocal config
        if new_config is not None:
            config = new_config
        return config

    monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma)
    monkeypatch.setattr(proxy_server_module, "premium_user", True)
    monkeypatch.setattr(
        proxy_server_module, "general_settings", {"allowed_ips": ["203.0.113.77"]}
    )
    monkeypatch.setattr(litellm, "store_audit_logs", True)
    monkeypatch.setattr(proxy_server_module.proxy_config, "get_config", _get_config)
    monkeypatch.setattr(proxy_server_module.proxy_config, "save_config", _save_config)

    async def _admin_auth():
        return UserAPIKeyAuth(
            user_id="audit-admin",
            api_key="hashed-admin-key",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )

    app.dependency_overrides[user_api_key_auth] = _admin_auth
    try:
        resp = client.post("/delete/allowed_ip", json={"ip": "203.0.113.77"})
        assert resp.status_code == 200, resp.text

        audit_create.assert_awaited_once()
        written = audit_create.await_args.kwargs["data"]
        assert written["object_id"] == "general_settings"
        assert written["action"] == "deleted"
        before = json.loads(written["before_value"])
        after = json.loads(written["updated_values"])
        assert "203.0.113.77" in before["allowed_ips"]
        assert "203.0.113.77" not in after["allowed_ips"]
    finally:
        app.dependency_overrides.pop(user_api_key_auth, None)


def test_update_ui_theme_settings_writes_audit_log(mock_proxy_config, monkeypatch):
    """Updating the UI theme must be audited under ui_theme_config."""
    from unittest.mock import AsyncMock, MagicMock

    import litellm
    import litellm.proxy.proxy_server as proxy_server_module
    from litellm.proxy._types import UserAPIKeyAuth
    from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

    audit_create = AsyncMock()
    fake_prisma = MagicMock()
    fake_prisma.db.litellm_auditlog.create = audit_create

    monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma)
    monkeypatch.setattr(proxy_server_module, "premium_user", True)
    monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
    monkeypatch.setattr(litellm, "store_audit_logs", True)
    monkeypatch.setattr(
        proxy_server_module.proxy_config,
        "_encrypt_env_variables",
        lambda environment_variables: environment_variables,
    )

    async def _admin_auth():
        return UserAPIKeyAuth(
            user_id="audit-admin",
            api_key="hashed-admin-key",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )

    app.dependency_overrides[user_api_key_auth] = _admin_auth
    try:
        resp = client.patch(
            "/update/ui_theme_settings",
            json={"logo_url": "https://example.com/logo.png"},
        )
        assert resp.status_code == 200, resp.text

        audit_create.assert_awaited_once()
        written = audit_create.await_args.kwargs["data"]
        assert written["object_id"] == "ui_theme_config"
        assert written["action"] == "updated"
        assert written["changed_by"] == "audit-admin"
        after = json.loads(written["updated_values"])
        assert after["logo_url"] == "https://example.com/logo.png"
    finally:
        app.dependency_overrides.pop(user_api_key_auth, None)


def test_update_ui_settings_writes_audit_log(monkeypatch):
    """Updating UI settings must be audited under the UI settings table."""
    from unittest.mock import AsyncMock, MagicMock

    import litellm
    import litellm.proxy.proxy_server as proxy_server_module
    from litellm.proxy._types import UserAPIKeyAuth
    from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

    audit_create = AsyncMock()
    fake_prisma = MagicMock()
    fake_prisma.db.litellm_auditlog.create = audit_create
    fake_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None)
    fake_prisma.db.litellm_uisettings.upsert = AsyncMock()

    monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma)
    monkeypatch.setattr(proxy_server_module, "premium_user", True)
    monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
    monkeypatch.setattr(litellm, "store_audit_logs", True)

    async def _admin_auth():
        return UserAPIKeyAuth(
            user_id="audit-admin",
            api_key="hashed-admin-key",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )

    app.dependency_overrides[user_api_key_auth] = _admin_auth
    try:
        resp = client.patch(
            "/update/ui_settings", json={"disable_custom_api_keys": True}
        )
        assert resp.status_code == 200, resp.text

        audit_create.assert_awaited_once()
        written = audit_create.await_args.kwargs["data"]
        assert written["object_id"] == "ui_settings"
        assert written["table_name"] == "LiteLLM_UISettings"
        assert written["changed_by"] == "audit-admin"
        after = json.loads(written["updated_values"])
        assert after["disable_custom_api_keys"] is True
    finally:
        app.dependency_overrides.pop(user_api_key_auth, None)


@pytest.fixture
def mock_team_lookup(monkeypatch):
    """Back /update/internal_user_settings with a fake team table.

    Yields the set of team ids that exist; the test mutates it before the call.
    Also exposes the find_many mock so a test can assert the lookup was skipped.
    """
    from unittest.mock import AsyncMock, MagicMock

    import litellm
    import litellm.proxy.proxy_server as proxy_server_module

    existing_team_ids: set = set()

    async def _find_many(where, **_):
        requested = where["team_id"]["in"]
        return [{"team_id": team_id} for team_id in requested if team_id in existing_team_ids]

    find_many = AsyncMock(side_effect=_find_many)
    fake_prisma = MagicMock()
    fake_prisma.db.litellm_teamtable.find_many = find_many

    member_budget_update = AsyncMock()

    monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma)
    monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
    monkeypatch.setattr(litellm, "default_internal_user_params", {})
    monkeypatch.setattr(
        "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.update_default_team_member_budget",
        member_budget_update,
    )

    return {
        "existing_team_ids": existing_team_ids,
        "find_many": find_many,
        "member_budget_update": member_budget_update,
    }


def test_update_internal_user_settings_rejects_unknown_team_object(mock_proxy_config, mock_auth, mock_team_lookup):
    """Regression: saving a default team that doesn't exist used to return 200,
    then silently fail for every SSO user because the membership write 404s."""
    mock_team_lookup["existing_team_ids"].add("real-team")

    resp = client.patch(
        "/update/internal_user_settings",
        json={
            "max_budget": 10.0,
            "teams": [
                {"team_id": "real-team", "max_budget_in_team": 5.0},
                {"team_id": "ghost-team"},
            ],
        },
    )

    assert resp.status_code == 400, resp.text
    assert "ghost-team" in resp.json()["detail"]["error"]
    assert "real-team" not in resp.json()["detail"]["error"]
    assert mock_proxy_config["save_call_count"]() == 0
    assert mock_team_lookup["member_budget_update"].await_count == 0, (
        "per-member budgets must not be written before the team ids are validated"
    )

    import litellm

    assert litellm.default_internal_user_params == {}


def test_update_internal_user_settings_rejects_unknown_team_string(mock_proxy_config, mock_auth, mock_team_lookup):
    """The bare-string team shape must be validated too."""
    resp = client.patch(
        "/update/internal_user_settings",
        json={"teams": ["ghost-team"]},
    )

    assert resp.status_code == 400, resp.text
    assert "ghost-team" in resp.json()["detail"]["error"]
    assert mock_proxy_config["save_call_count"]() == 0


def test_update_internal_user_settings_rejects_duplicate_team_ids(mock_proxy_config, mock_auth, mock_team_lookup):
    """Listing a team twice makes its per-member budget a race between the two
    entries, so the payload is rejected rather than silently resolved."""
    mock_team_lookup["existing_team_ids"].add("real-team")

    resp = client.patch(
        "/update/internal_user_settings",
        json={
            "teams": [
                {"team_id": "real-team", "max_budget_in_team": 5.0},
                {"team_id": "real-team", "max_budget_in_team": 50.0},
            ]
        },
    )

    assert resp.status_code == 400, resp.text
    assert "real-team" in resp.json()["detail"]["error"]
    assert mock_proxy_config["save_call_count"]() == 0


def test_update_internal_user_settings_saves_when_all_teams_exist(mock_proxy_config, mock_auth, mock_team_lookup):
    """Valid team ids still save, and still reach the per-member budget update."""
    mock_team_lookup["existing_team_ids"].update({"team-a", "team-b"})

    resp = client.patch(
        "/update/internal_user_settings",
        json={
            "max_budget": 10.0,
            "teams": [
                {"team_id": "team-a", "max_budget_in_team": 5.0},
                {"team_id": "team-b"},
            ],
        },
    )

    assert resp.status_code == 200, resp.text
    assert [team["team_id"] for team in resp.json()["settings"]["teams"]] == [
        "team-a",
        "team-b",
    ]
    assert mock_proxy_config["save_call_count"]() == 1
    mock_team_lookup["member_budget_update"].assert_awaited_once()


def test_update_internal_user_settings_without_teams_skips_team_lookup(mock_proxy_config, mock_auth, mock_team_lookup):
    """Settings changes that don't touch teams must not pay for a DB round trip."""
    resp = client.patch(
        "/update/internal_user_settings",
        json={"max_budget": 10.0},
    )

    assert resp.status_code == 200, resp.text
    mock_team_lookup["find_many"].assert_not_awaited()
    assert mock_proxy_config["save_call_count"]() == 1


@pytest.fixture
def mock_organization_lookup(monkeypatch):
    """Back /update/default_team_settings with a fake organization table.

    Yields the set of organization ids that exist; the test mutates it before the call.
    """
    from unittest.mock import AsyncMock, MagicMock

    import litellm
    import litellm.proxy.proxy_server as proxy_server_module

    existing_organization_ids: set = set()

    async def _find_unique(where):
        organization_id = where["organization_id"]
        if organization_id not in existing_organization_ids:
            return None
        return {"organization_id": organization_id}

    find_unique = AsyncMock(side_effect=_find_unique)
    fake_prisma = MagicMock()
    fake_prisma.db.litellm_organizationtable.find_unique = find_unique

    monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma)
    monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
    monkeypatch.setattr(litellm, "default_team_params", {})

    return {
        "existing_organization_ids": existing_organization_ids,
        "find_unique": find_unique,
    }


def test_update_default_team_settings_rejects_unknown_organization(
    mock_proxy_config, mock_auth, mock_organization_lookup
):
    """Regression: an unknown default org saved fine here and then failed every
    future team creation, far from the admin who typed it."""
    mock_organization_lookup["existing_organization_ids"].add("real-org")

    resp = client.patch(
        "/update/default_team_settings",
        json={"max_budget": 10.0, "organization_id": "ghost-org"},
    )

    assert resp.status_code == 400, resp.text
    assert "ghost-org" in resp.json()["detail"]["error"]
    assert mock_proxy_config["save_call_count"]() == 0

    import litellm

    assert litellm.default_team_params == {}


def test_update_default_team_settings_saves_when_organization_exists(
    mock_proxy_config, mock_auth, mock_organization_lookup
):
    """A real organization id still saves and reaches the in-memory settings."""
    mock_organization_lookup["existing_organization_ids"].add("real-org")

    resp = client.patch(
        "/update/default_team_settings",
        json={"max_budget": 10.0, "organization_id": "real-org"},
    )

    assert resp.status_code == 200, resp.text
    assert resp.json()["settings"]["organization_id"] == "real-org"
    assert mock_proxy_config["save_call_count"]() == 1

    import litellm

    assert litellm.default_team_params["organization_id"] == "real-org"


def test_update_default_team_settings_without_organization_skips_lookup(
    mock_proxy_config, mock_auth, mock_organization_lookup
):
    """Settings changes that don't set an organization must not pay for a DB round trip."""
    resp = client.patch(
        "/update/default_team_settings",
        json={"max_budget": 10.0},
    )

    assert resp.status_code == 200, resp.text
    mock_organization_lookup["find_unique"].assert_not_awaited()
    assert mock_proxy_config["save_call_count"]() == 1


def test_update_mcp_semantic_filter_settings_requires_proxy_admin(monkeypatch):
    """Non-admin callers must not mutate global MCP semantic filter settings."""
    from litellm.proxy._types import UserAPIKeyAuth
    from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

    monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

    async def _internal_user_auth():
        return UserAPIKeyAuth(
            user_id="internal-user-1",
            api_key="hashed-internal-key",
            user_role=LitellmUserRoles.INTERNAL_USER,
        )

    app.dependency_overrides[user_api_key_auth] = _internal_user_auth
    try:
        resp = client.patch(
            "/update/mcp_semantic_filter_settings",
            json={"enabled": True, "top_k": 99, "similarity_threshold": 0.01},
        )
        assert resp.status_code == 403
        assert "proxy admin" in resp.json()["detail"].lower()
    finally:
        app.dependency_overrides.pop(user_api_key_auth, None)


class TestMcpToolSearchSettingsEndpoints:
    """`litellm_settings.mcp_tool_search` drives the native `mcp_tool_search` virtual tool, so the UI must round-trip it."""

    @staticmethod
    def _override_auth(role: LitellmUserRoles):
        from litellm.proxy._types import UserAPIKeyAuth
        from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

        app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
            user_id="u", api_key="hashed", user_role=role
        )

    def test_get_returns_stored_values_and_field_schema(self, mock_proxy_config, mock_auth, monkeypatch):
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object())
        mock_proxy_config["config"]["litellm_settings"]["mcp_tool_search"] = {
            "embedding_model": "text-embedding-3-small",
            "core_tools": ["treasury-get_rates"],
        }

        resp = client.get("/get/mcp_tool_search_settings")

        assert resp.status_code == 200, resp.text
        assert resp.json()["values"] == {
            "embedding_model": "text-embedding-3-small",
            "top_k": 5,
            "similarity_threshold": 0.0,
            "core_tools": ["treasury-get_rates"],
        }
        assert resp.json()["field_schema"]["properties"]["core_tools"]["type"] == "array"

    def test_update_requires_proxy_admin(self, monkeypatch):
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
        self._override_auth(LitellmUserRoles.INTERNAL_USER)
        try:
            resp = client.patch("/update/mcp_tool_search_settings", json={"top_k": 3})
        finally:
            app.dependency_overrides.clear()
        assert resp.status_code == 403

    def test_update_persists_and_applies_in_memory(self, mock_proxy_config, monkeypatch):
        import litellm

        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
        monkeypatch.setattr(litellm, "mcp_tool_search", None)
        self._override_auth(LitellmUserRoles.PROXY_ADMIN)
        payload = {
            "embedding_model": "text-embedding-3-small",
            "top_k": 3,
            "similarity_threshold": 0.25,
            "core_tools": ["treasury-get_rates"],
        }
        try:
            resp = client.patch("/update/mcp_tool_search_settings", json=payload)
        finally:
            app.dependency_overrides.clear()

        assert resp.status_code == 200, resp.text
        assert mock_proxy_config["save_call_count"]() == 1
        assert litellm.mcp_tool_search == payload
        assert mock_proxy_config["config"]["litellm_settings"]["mcp_tool_search"] == payload

    def test_update_rejects_out_of_range_top_k(self, mock_proxy_config, monkeypatch):
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
        self._override_auth(LitellmUserRoles.PROXY_ADMIN)
        try:
            resp = client.patch("/update/mcp_tool_search_settings", json={"top_k": 0})
        finally:
            app.dependency_overrides.clear()
        assert resp.status_code == 422
        assert mock_proxy_config["save_call_count"]() == 0


def test_upload_logo_requires_proxy_admin(monkeypatch):
    """Any authenticated key could previously write a file to the server's disk here."""
    from litellm.proxy._types import UserAPIKeyAuth
    from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

    async def _internal_user_auth():
        return UserAPIKeyAuth(
            user_id="internal-user-1",
            api_key="hashed-internal-key",
            user_role=LitellmUserRoles.INTERNAL_USER,
        )

    app.dependency_overrides[user_api_key_auth] = _internal_user_auth
    try:
        resp = client.post(
            "/upload/logo",
            files={"file": ("logo.png", b"\x89PNG\r\n\x1a\n" + b"x" * 32, "image/png")},
        )
        assert resp.status_code == 403
        assert "proxy admin" in resp.json()["detail"].lower()
    finally:
        app.dependency_overrides.pop(user_api_key_auth, None)


def test_upload_logo_allows_proxy_admin(monkeypatch, tmp_path):
    from litellm.proxy._types import UserAPIKeyAuth
    from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

    async def _admin_auth():
        return UserAPIKeyAuth(
            user_id="admin-1",
            api_key="hashed-admin-key",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )

    app.dependency_overrides[user_api_key_auth] = _admin_auth
    try:
        resp = client.post(
            "/upload/logo",
            files={"file": ("logo.png", b"\x89PNG\r\n\x1a\n" + b"x" * 32, "image/png")},
        )
        assert resp.status_code == 200, resp.text
        assert resp.json()["status"] == "success"
    finally:
        app.dependency_overrides.pop(user_api_key_auth, None)
        uploaded_path = resp.json().get("file_path")
        if uploaded_path and os.path.exists(uploaded_path):
            os.remove(uploaded_path)


class TestPtuCostAttributionUISetting:
    """``enable_ptu_cost_attribution`` is derived from the environment on every GET.

    It is deliberately not an allowlisted, persisted setting: the point of gating PTU
    flat cost on an env var is that an admin cannot flip it at runtime from the UI.
    """

    @staticmethod
    def _mock_prisma(monkeypatch, stored=None):
        from unittest.mock import AsyncMock, MagicMock

        mock_prisma = MagicMock()
        mock_record = None
        if stored is not None:
            mock_record = MagicMock()
            mock_record.ui_settings = stored
        mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=mock_record)
        mock_prisma.db.litellm_uisettings.upsert = AsyncMock()
        monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
        return mock_prisma

    def test_reported_false_when_the_env_var_is_unset(self, mock_auth, monkeypatch):
        from litellm.proxy.spend_tracking.ptu_feature_flag import PTU_COST_ATTRIBUTION_ENV_VAR

        monkeypatch.delenv(PTU_COST_ATTRIBUTION_ENV_VAR, raising=False)
        self._mock_prisma(monkeypatch)

        response = client.get("/get/ui_settings")

        assert response.status_code == 200
        assert response.json()["values"]["enable_ptu_cost_attribution"] is False

    def test_reported_true_once_the_env_var_is_set(self, mock_auth, monkeypatch):
        from litellm.proxy.spend_tracking.ptu_feature_flag import PTU_COST_ATTRIBUTION_ENV_VAR

        monkeypatch.setenv(PTU_COST_ATTRIBUTION_ENV_VAR, "true")
        self._mock_prisma(monkeypatch)

        response = client.get("/get/ui_settings")

        assert response.status_code == 200
        assert response.json()["values"]["enable_ptu_cost_attribution"] is True

    def test_a_persisted_true_cannot_forge_the_derived_value(self, mock_auth, monkeypatch):
        """A row written before the allowlist existed must not be able to turn the feature on."""
        from litellm.proxy.spend_tracking.ptu_feature_flag import PTU_COST_ATTRIBUTION_ENV_VAR

        monkeypatch.delenv(PTU_COST_ATTRIBUTION_ENV_VAR, raising=False)
        self._mock_prisma(monkeypatch, stored={"enable_ptu_cost_attribution": True})

        response = client.get("/get/ui_settings")

        assert response.status_code == 200
        assert response.json()["values"]["enable_ptu_cost_attribution"] is False

    def test_is_not_an_allowlisted_persisted_setting(self):
        from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
            ALLOWED_UI_SETTINGS_FIELDS,
        )

        assert "enable_ptu_cost_attribution" not in ALLOWED_UI_SETTINGS_FIELDS

    def test_the_body_get_returns_is_a_valid_patch_body(self, mock_auth, monkeypatch):
        """Read-modify-write is how a client edits one setting. GET injects the derived key,
        so rejecting it on presence made GET's own output an invalid PATCH body: the caller
        got a 400 and silently lost the edit it actually wanted."""
        from litellm.proxy._types import UserAPIKeyAuth
        from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
        from litellm.proxy.spend_tracking.ptu_feature_flag import PTU_COST_ATTRIBUTION_ENV_VAR

        app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
            user_id="test-user-123",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
        monkeypatch.delenv(PTU_COST_ATTRIBUTION_ENV_VAR, raising=False)
        mock_prisma = self._mock_prisma(monkeypatch)

        try:
            round_tripped = client.get("/get/ui_settings").json()["values"]
            assert "enable_ptu_cost_attribution" in round_tripped
            response = client.patch("/update/ui_settings", json=round_tripped)
        finally:
            app.dependency_overrides.clear()

        assert response.status_code == 200
        assert mock_prisma.db.litellm_uisettings.upsert.called

    def test_a_co_submitted_setting_still_applies_alongside_the_derived_key(self, mock_auth, monkeypatch):
        """The derived key riding along must not discard the caller's real edit."""
        from litellm.proxy._types import UserAPIKeyAuth
        from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
        from litellm.proxy.spend_tracking.ptu_feature_flag import PTU_COST_ATTRIBUTION_ENV_VAR

        app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
            user_id="test-user-123",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
        monkeypatch.delenv(PTU_COST_ATTRIBUTION_ENV_VAR, raising=False)
        mock_prisma = self._mock_prisma(monkeypatch)

        try:
            response = client.patch(
                "/update/ui_settings",
                json={"enable_ptu_cost_attribution": False, "enable_chat_ui": True},
            )
        finally:
            app.dependency_overrides.clear()

        assert response.status_code == 200
        upsert_data = mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"]
        persisted = json.loads(upsert_data["create"]["ui_settings"])
        assert persisted["enable_chat_ui"] is True
        assert "enable_ptu_cost_attribution" not in persisted

    def test_patch_rejects_the_derived_setting(self, mock_auth, monkeypatch):
        from litellm.proxy._types import UserAPIKeyAuth
        from litellm.proxy.auth.user_api_key_auth import user_api_key_auth

        app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
            user_id="test-user-123",
            user_role=LitellmUserRoles.PROXY_ADMIN,
        )
        monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
        mock_prisma = self._mock_prisma(monkeypatch)

        try:
            response = client.patch(
                "/update/ui_settings",
                json={"enable_ptu_cost_attribution": True},
            )
        finally:
            app.dependency_overrides.clear()

        assert response.status_code == 400
        assert "enable_ptu_cost_attribution" in str(response.json()["detail"])
        assert not mock_prisma.db.litellm_uisettings.upsert.called
