From c7b1efd685828004b3d45c0ccc21568d661cdcd0 Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Fri, 6 Mar 2026 20:18:27 -0600 Subject: [PATCH] Fix duplicate async success log emissions for streaming requests (#21355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix duplicate async success log emission for streaming calls * fix(logging): close remaining async streaming duplicate emission paths * chore: address PR review follow-up comments * Update litellm/litellm_core_utils/streaming_handler.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * [Test] Add unit tests for 5 untested policy components Adds Vitest + RTL test files for policy_table, policy_templates, guardrail_selection_modal, impact_popover, and add_attachment_form. 53 tests total covering rendering, user interactions, API calls, and conditional UI behavior. Co-Authored-By: Claude Sonnet 4.6 * feat(key_management): allow @ in key_alias for email-based aliases (#23003) Adds @ to the _KEY_ALIAS_PATTERN allowed character set so that key aliases like user/user@example.com are accepted. Updates tests to cover email-based alias formats. * [Feat[ extends OAuth2 M2M authentication support to info routes (/key/info, /team/info, /user/info, /model/info) (#22713) * added info_route * greptile pt1 * greptile pt2 * greptile pt3 * fix(caching): check REDIS_CLUSTER_NODES env var in Cache and Router class selection (#22790) When Redis Cluster is configured via the REDIS_CLUSTER_NODES environment variable, Cache.__init__() and Router._create_redis_cache() ignored the env var and always created RedisCache instead of RedisClusterCache. This caused the v3 rate limiter's cluster detection (_is_redis_cluster()) to return False, skipping hash-slot key grouping. The resulting CROSSLOT errors were silently caught, falling back to per-instance in-memory counting — breaking RPM/TPM enforcement across multiple proxy instances. Add REDIS_CLUSTER_NODES env var detection to both Cache.__init__() and Router._create_redis_cache(), matching the existing pattern in _redis.py:215-220. When the env var is set and no explicit startup_nodes parameter is provided, parse it and create RedisClusterCache. Fixes #22748 Related to #20836 * address async logging review feedback * fix websocket async logging duplication --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: yuneng-jiang Co-authored-by: Claude Sonnet 4.6 Co-authored-by: hliu-roblox <87345548+hliu-roblox@users.noreply.github.com> Co-authored-by: v0rtex20k <55466324+v0rtex20k@users.noreply.github.com> Co-authored-by: michelligabriele --- litellm/a2a_protocol/streaming_iterator.py | 2 +- litellm/caching/caching.py | 8 + litellm/interactions/streaming_iterator.py | 2 +- litellm/litellm_core_utils/litellm_logging.py | 116 +++++++++-- .../litellm_core_utils/realtime_streaming.py | 6 +- .../litellm_core_utils/streaming_handler.py | 13 +- litellm/proxy/auth/auth_checks.py | 2 +- litellm/proxy/auth/route_checks.py | 12 +- litellm/proxy/auth/user_api_key_auth.py | 5 +- .../proxy/hooks/proxy_track_cost_callback.py | 5 +- .../key_management_endpoints.py | 4 +- litellm/proxy/utils.py | 7 +- litellm/responses/streaming_iterator.py | 7 +- litellm/router.py | 14 +- litellm/utils.py | 8 +- .../caching/test_redis_cluster_cache.py | 115 +++++++++++ .../test_litellm_logging.py | 137 +++++++++++++ .../test_streaming_handler.py | 151 ++++++++++++++ .../proxy/auth/test_info_routes.py | 119 +++++++++++ .../test_key_management_endpoints.py | 4 +- tests/test_litellm/proxy/test_proxy_utils.py | 14 +- .../test_responses_websocket_all_providers.py | 34 ++++ .../policies/add_attachment_form.test.tsx | 110 +++++++++++ .../guardrail_selection_modal.test.tsx | 127 ++++++++++++ .../policies/impact_popover.test.tsx | 184 ++++++++++++++++++ .../components/policies/policy_table.test.tsx | 150 ++++++++++++++ .../policies/policy_templates.test.tsx | 144 ++++++++++++++ 27 files changed, 1461 insertions(+), 39 deletions(-) create mode 100644 tests/test_litellm/proxy/auth/test_info_routes.py create mode 100644 ui/litellm-dashboard/src/components/policies/add_attachment_form.test.tsx create mode 100644 ui/litellm-dashboard/src/components/policies/guardrail_selection_modal.test.tsx create mode 100644 ui/litellm-dashboard/src/components/policies/impact_popover.test.tsx create mode 100644 ui/litellm-dashboard/src/components/policies/policy_table.test.tsx create mode 100644 ui/litellm-dashboard/src/components/policies/policy_templates.test.tsx diff --git a/litellm/a2a_protocol/streaming_iterator.py b/litellm/a2a_protocol/streaming_iterator.py index 921dc0e52e0..662a1e5feb2 100644 --- a/litellm/a2a_protocol/streaming_iterator.py +++ b/litellm/a2a_protocol/streaming_iterator.py @@ -142,6 +142,7 @@ class A2AStreamingIterator: cache_hit=None, start_time=self.start_time, end_time=end_time, + called_from_async=True, ) verbose_logger.info( @@ -170,4 +171,3 @@ class A2AStreamingIterator: pass return result - diff --git a/litellm/caching/caching.py b/litellm/caching/caching.py index ad02d2ea891..406a4f8c98a 100644 --- a/litellm/caching/caching.py +++ b/litellm/caching/caching.py @@ -166,6 +166,14 @@ class Cache: None. Cache is set as a litellm param """ if type == LiteLLMCacheType.REDIS: + # Check REDIS_CLUSTER_NODES env var if no explicit startup nodes + if not redis_startup_nodes: + _env_cluster_nodes = litellm.get_secret("REDIS_CLUSTER_NODES") + if _env_cluster_nodes is not None and isinstance( + _env_cluster_nodes, str + ): + redis_startup_nodes = json.loads(_env_cluster_nodes) + if redis_startup_nodes: # Only pass GCP parameters if they are provided cluster_kwargs = { diff --git a/litellm/interactions/streaming_iterator.py b/litellm/interactions/streaming_iterator.py index f65d08d3ca9..700679d2b75 100644 --- a/litellm/interactions/streaming_iterator.py +++ b/litellm/interactions/streaming_iterator.py @@ -188,6 +188,7 @@ class InteractionsAPIStreamingIterator(BaseInteractionsAPIStreamingIterator): cache_hit=None, start_time=self.start_time, end_time=datetime.now(), + called_from_async=True, ) @@ -261,4 +262,3 @@ class SyncInteractionsAPIStreamingIterator(BaseInteractionsAPIStreamingIterator) start_time=self.start_time, end_time=datetime.now(), ) - diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 6f587abcdf1..14b2f1db9bc 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -217,6 +217,76 @@ _CUSTOM_PRICING_KEYS: frozenset = frozenset( CustomPricingLiteLLMParams.model_fields.keys() ) +# Explicit allowlist of CallTypes that represent async requests. +# Keep this list explicit to avoid accidental classification from naming heuristics. +_ASYNC_CALL_TYPE_VALUES: frozenset[str] = frozenset( + { + CallTypes.aembedding.value, + CallTypes.acompletion.value, + CallTypes.atext_completion.value, + CallTypes.aimage_generation.value, + CallTypes.aimage_edit.value, + CallTypes.amoderation.value, + CallTypes.atranscription.value, + CallTypes.aspeech.value, + CallTypes.arerank.value, + CallTypes.asearch.value, + CallTypes.arealtime.value, + CallTypes.aresponses_websocket.value, + CallTypes.acreate_batch.value, + CallTypes.aretrieve_batch.value, + CallTypes.acancel_batch.value, + CallTypes.anthropic_messages.value, + CallTypes.aget_assistants.value, + CallTypes.acreate_assistants.value, + CallTypes.adelete_assistant.value, + CallTypes.acreate_thread.value, + CallTypes.aget_thread.value, + CallTypes.a_add_message.value, + CallTypes.aget_messages.value, + CallTypes.arun_thread.value, + CallTypes.arun_thread_stream.value, + CallTypes.afile_retrieve.value, + CallTypes.afile_delete.value, + CallTypes.afile_list.value, + CallTypes.acreate_file.value, + CallTypes.afile_content.value, + CallTypes.acreate_fine_tuning_job.value, + CallTypes.acreate_video.value, + CallTypes.avideo_retrieve.value, + CallTypes.avideo_content.value, + CallTypes.avideo_remix.value, + CallTypes.avideo_list.value, + CallTypes.avideo_retrieve_job.value, + CallTypes.avideo_delete.value, + CallTypes.avector_store_file_create.value, + CallTypes.avector_store_file_list.value, + CallTypes.avector_store_file_retrieve.value, + CallTypes.avector_store_file_content.value, + CallTypes.avector_store_file_update.value, + CallTypes.avector_store_file_delete.value, + CallTypes.avector_store_create.value, + CallTypes.avector_store_search.value, + CallTypes.acreate_container.value, + CallTypes.alist_containers.value, + CallTypes.aretrieve_container.value, + CallTypes.adelete_container.value, + CallTypes.alist_container_files.value, + CallTypes.aupload_container_file.value, + CallTypes.acancel_fine_tuning_job.value, + CallTypes.alist_fine_tuning_jobs.value, + CallTypes.aretrieve_fine_tuning_job.value, + CallTypes.aresponses.value, + CallTypes.alist_input_items.value, + CallTypes.allm_passthrough_route.value, + CallTypes.agenerate_content.value, + CallTypes.agenerate_content_stream.value, + CallTypes.aocr.value, + CallTypes.asend_message.value, + CallTypes.acreate_skill.value, + } +) + sentry_sdk_instance = None capture_exception = None add_breadcrumb = None @@ -1581,6 +1651,30 @@ class Logging(LiteLLMLoggingBaseClass): return True + def _is_async_litellm_request(self, litellm_params: Dict[str, Any]) -> bool: + """ + Best-effort async request detection for logging flows. + + We need this to prevent sync handler payload/callback duplication when the same + request is processed by both async and sync callback paths. + """ + if litellm_params.get("async_call", False) is True: + return True + + for async_call_type in _ASYNC_CALL_TYPE_VALUES: + if litellm_params.get(async_call_type, False) is True: + return True + + call_type = self.call_type + if isinstance(call_type, CallTypes): + call_type_value = call_type.value + else: + call_type_value = str(call_type) + if call_type_value.startswith("CallTypes."): + call_type_value = call_type_value.split("CallTypes.", 1)[1] + + return call_type_value in _ASYNC_CALL_TYPE_VALUES + def _update_completion_start_time(self, completion_start_time: datetime.datetime): self.completion_start_time = completion_start_time self.model_call_details["completion_start_time"] = self.completion_start_time @@ -1918,12 +2012,8 @@ class Logging(LiteLLMLoggingBaseClass): standard_logging_object=kwargs.get("standard_logging_object", None), ) litellm_params = self.model_call_details.get("litellm_params", {}) - is_sync_request = ( - litellm_params.get(CallTypes.acompletion.value, False) is not True - and litellm_params.get(CallTypes.aresponses.value, False) is not True - and litellm_params.get(CallTypes.aembedding.value, False) is not True - and litellm_params.get(CallTypes.aimage_generation.value, False) is not True - and litellm_params.get(CallTypes.atranscription.value, False) is not True + is_sync_request = not self._is_async_litellm_request( + litellm_params=litellm_params ) try: ## BUILD COMPLETE STREAMED RESPONSE @@ -1961,7 +2051,10 @@ class Logging(LiteLLMLoggingBaseClass): ) ) is not None: # Only emit for sync requests (async_success_handler handles async) - if is_sync_request: + if ( + is_sync_request + and kwargs.get("called_from_async", False) is not True + ): emit_standard_logging_payload(standard_logging_payload) callbacks = self.get_combined_callback_list( dynamic_success_callbacks=self.dynamic_success_callbacks, @@ -2821,12 +2914,8 @@ class Logging(LiteLLMLoggingBaseClass): ): # prevent double logging return litellm_params = self.model_call_details.get("litellm_params", {}) - is_sync_request = ( - litellm_params.get(CallTypes.acompletion.value, False) is not True - and litellm_params.get(CallTypes.aresponses.value, False) is not True - and litellm_params.get(CallTypes.aembedding.value, False) is not True - and litellm_params.get(CallTypes.aimage_generation.value, False) is not True - and litellm_params.get(CallTypes.atranscription.value, False) is not True + is_sync_request = not self._is_async_litellm_request( + litellm_params=litellm_params ) try: @@ -3153,6 +3242,7 @@ class Logging(LiteLLMLoggingBaseClass): start_time, end_time, cache_hit, + called_from_async=True, ) def _should_run_sync_callbacks_for_async_calls(self) -> bool: diff --git a/litellm/litellm_core_utils/realtime_streaming.py b/litellm/litellm_core_utils/realtime_streaming.py index 14a25e61d63..7c3f8dccfa4 100644 --- a/litellm/litellm_core_utils/realtime_streaming.py +++ b/litellm/litellm_core_utils/realtime_streaming.py @@ -218,7 +218,11 @@ class RealTimeStreaming: # Create an event loop for the new thread asyncio.create_task(self.logging_obj.async_success_handler(self.messages)) ## SYNC LOGGING - executor.submit(self.logging_obj.success_handler(self.messages)) + executor.submit( + self.logging_obj.success_handler, + self.messages, + called_from_async=True, + ) async def _send_to_backend(self, message: str) -> None: """Send a message to the backend WebSocket. diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 317f1037686..19a4d0d2222 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -2042,13 +2042,23 @@ class CustomStreamWrapper: raise StopAsyncIteration else: # temporary patch for non-aiohttp async calls # example - boto3 bedrock llms + exhausted_sentinel = object() while True: if isinstance(self.completion_stream, str) or isinstance( self.completion_stream, bytes ): chunk = self.completion_stream else: - chunk = next(self.completion_stream) # type: ignore[arg-type] + # Sync iterators can block (e.g. boto3 streams). Run next() + # off the event loop thread to keep async callers responsive. + # NOTE: We use a sentinel default because StopIteration cannot + # be stored in an asyncio.Future (raises TypeError), which + # would cause the await to hang forever. + chunk = await asyncio.to_thread( # type: ignore[arg-type] + next, self.completion_stream, exhausted_sentinel + ) + if chunk is exhausted_sentinel: + raise StopIteration if chunk is not None and chunk != b"": processed_chunk = self.chunk_creator(chunk=chunk) if processed_chunk is None: @@ -2123,6 +2133,7 @@ class CustomStreamWrapper: cache_hit=cache_hit, start_time=None, end_time=None, + called_from_async=True, ) raise StopAsyncIteration # Re-raise StopIteration diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 39d4bdee6b3..eb690931fb9 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -839,7 +839,7 @@ def _check_end_user_budget( Raises: litellm.BudgetExceededError: If end user has exceeded their budget """ - if route in LiteLLMRoutes.info_routes.value: + if RouteChecks.is_info_route(route): return if end_user_obj.litellm_budget_table is None: diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index a00401008fc..12edb74af30 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -164,9 +164,8 @@ class RouteChecks: if RouteChecks.is_llm_api_route(route=route): pass - elif ( - route in LiteLLMRoutes.info_routes.value - ): # check if user allowed to call an info route + elif RouteChecks.is_info_route(route=route): + # check if user allowed to call an info route if route == "/key/info": # handled by function itself pass @@ -358,6 +357,13 @@ class RouteChecks: """ return route in LiteLLMRoutes.management_routes.value + @staticmethod + def is_info_route(route: str) -> bool: + """ + Check if route is an info route + """ + return route in LiteLLMRoutes.info_routes.value + @staticmethod def _is_azure_openai_route(route: str) -> bool: """ diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index ab162bcfdc3..82341c9a704 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -632,9 +632,10 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 ########## End of Route Checks Before Reading DB / Cache for "token" ######## if general_settings.get("enable_oauth2_auth", False) is True: - # Only apply OAuth2 M2M authentication to LLM API routes, not UI/management routes + # Only apply OAuth2 M2M authentication to LLM API routes and info routes, not UI/management routes # This allows UI SSO to work separately from API M2M authentication - if RouteChecks.is_llm_api_route(route=route): + # Note: Info routes are already scoped to the user + if RouteChecks.is_llm_api_route(route=route) or RouteChecks.is_info_route(route=route): # return UserAPIKeyAuth object # helper to check if the api_key is a valid oauth2 token from litellm.proxy.proxy_server import premium_user diff --git a/litellm/proxy/hooks/proxy_track_cost_callback.py b/litellm/proxy/hooks/proxy_track_cost_callback.py index 0734756d8ed..8abec22e60b 100644 --- a/litellm/proxy/hooks/proxy_track_cost_callback.py +++ b/litellm/proxy/hooks/proxy_track_cost_callback.py @@ -38,8 +38,9 @@ class _ProxyDBLogger(CustomLogger): request_route = user_api_key_dict.request_route if _ProxyDBLogger._should_track_errors_in_db() is False: return - elif request_route is not None and not RouteChecks.is_llm_api_route( - route=request_route + elif request_route is not None and not ( + RouteChecks.is_llm_api_route(route=request_route) or + RouteChecks.is_info_route(route=request_route) ): return diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 5d189e0db49..bce51b9ccd2 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -4991,7 +4991,7 @@ async def test_key_logging( ) -_KEY_ALIAS_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-/\.]{0,253}[a-zA-Z0-9]$") +_KEY_ALIAS_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-/\.@]{0,253}[a-zA-Z0-9]$") def _validate_key_alias_format(key_alias: Optional[str]) -> None: @@ -5009,7 +5009,7 @@ def _validate_key_alias_format(key_alias: Optional[str]) -> None: if not _KEY_ALIAS_PATTERN.match(key_alias): raise ProxyException( - message="Invalid key_alias format. Must be 2-255 characters, start/end with alphanumeric, and only contain a-zA-Z0-9_-/.", + message="Invalid key_alias format. Must be 2-255 characters, start/end with alphanumeric, and only contain a-zA-Z0-9_-/.@.", type=ProxyErrorTypes.bad_request_error, param="key_alias", code=400, diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index e6da95bb78f..c5f399e3adc 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -1773,7 +1773,7 @@ class ProxyLogging: """ ######################################################### - # Only log LLM API errors for proxy level hooks + # Only log LLM API and info route errors for proxy level hooks # eg. Authentication errors, rate limit errors, etc. # Note: This fixes a security issue where we # would log temporary keys/auth info @@ -1781,7 +1781,10 @@ class ProxyLogging: ######################################################### if route is None: return False - if RouteChecks.is_llm_api_route(route) is not True: + if not ( + RouteChecks.is_llm_api_route(route) or + RouteChecks.is_info_route(route) + ): return False return isinstance(original_exception, HTTPException) or ( diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 705756cadd3..83b65da8b4c 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -468,6 +468,7 @@ class ResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): cache_hit=None, start_time=self.start_time, end_time=datetime.now(), + called_from_async=True, ) self._run_post_success_hooks(end_time=datetime.now()) @@ -803,7 +804,11 @@ class ResponsesWebSocketStreaming: asyncio.create_task( self.logging_obj.async_success_handler(self.messages) ) - _ws_executor.submit(self.logging_obj.success_handler, self.messages) + _ws_executor.submit( + self.logging_obj.success_handler, + self.messages, + called_from_async=True, + ) async def backend_to_client(self) -> None: """Forward events from backend WebSocket to the client.""" diff --git a/litellm/router.py b/litellm/router.py index 8d44882cdf8..7119d2e850d 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -728,8 +728,18 @@ class Router: """ Initializes either a RedisCache or RedisClusterCache based on the cache_config. """ - if cache_config.get("startup_nodes"): - return RedisClusterCache(**cache_config) + startup_nodes = cache_config.get("startup_nodes") + if not startup_nodes: + _env_cluster_nodes = get_secret("REDIS_CLUSTER_NODES") + if _env_cluster_nodes is not None and isinstance( + _env_cluster_nodes, str + ): + startup_nodes = json.loads(_env_cluster_nodes) + + if startup_nodes: + return RedisClusterCache( + **{**cache_config, "startup_nodes": startup_nodes} + ) else: return RedisCache(**cache_config) diff --git a/litellm/utils.py b/litellm/utils.py index 72423f84831..fef4908b6f2 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -1796,6 +1796,9 @@ def client(original_function): # noqa: PLR0915 assert ( logging_obj is not None ), "logging_obj should not be None after function_setup" + # Mark this logging object as async-driven so sync callback paths can + # safely avoid duplicate standard payload/callback emission. + logging_obj.litellm_params["async_call"] = True modified_kwargs = await async_pre_call_deployment_hook(kwargs, call_type) if modified_kwargs is not None: @@ -1940,11 +1943,6 @@ def client(original_function): # noqa: PLR0915 is_completion_with_fallbacks=is_completion_with_fallbacks, ) ) - logging_obj.handle_sync_success_callbacks_for_async_calls( - result=result, - start_time=start_time, - end_time=end_time, - ) # REBUILD EMBEDDING CACHING if ( isinstance(result, EmbeddingResponse) diff --git a/tests/test_litellm/caching/test_redis_cluster_cache.py b/tests/test_litellm/caching/test_redis_cluster_cache.py index a23a1296f2d..26878865187 100644 --- a/tests/test_litellm/caching/test_redis_cluster_cache.py +++ b/tests/test_litellm/caching/test_redis_cluster_cache.py @@ -10,6 +10,7 @@ sys.path.insert( 0, os.path.abspath("../../..") ) # Adds the parent directory to the system path +from litellm.caching.redis_cache import RedisCache from litellm.caching.redis_cluster_cache import RedisClusterCache @@ -64,3 +65,117 @@ async def test_redis_cluster_async_batch_get(mock_init_redis_cluster): # Verify mget_nonatomic was called instead of mget mock_redis.mget_nonatomic.assert_called_once() assert not mock_redis.mget.called + + +@patch("litellm._redis.get_redis_connection_pool") +@patch("litellm._redis.get_redis_client") +@patch("litellm.caching.redis_cache.RedisCache._setup_health_pings") +def test_cache_init_creates_cluster_cache_from_env_var( + mock_health, mock_get_client, mock_get_pool, monkeypatch +): + """ + Test that Cache() creates RedisClusterCache when REDIS_CLUSTER_NODES env var is set. + + Regression test for https://github.com/BerriAI/litellm/issues/22748 + """ + from litellm.caching.caching import Cache + + startup_nodes = [{"host": "127.0.0.1", "port": "7001"}] + monkeypatch.setenv("REDIS_CLUSTER_NODES", json.dumps(startup_nodes)) + monkeypatch.delenv("REDIS_HOST", raising=False) + monkeypatch.delenv("REDIS_PORT", raising=False) + monkeypatch.delenv("REDIS_PASSWORD", raising=False) + monkeypatch.delenv("REDIS_URL", raising=False) + + mock_get_client.return_value = MagicMock() + mock_get_pool.return_value = MagicMock() + + cache = Cache(type="redis") + assert isinstance(cache.cache, RedisClusterCache) + + +@patch("litellm._redis.get_redis_connection_pool") +@patch("litellm._redis.get_redis_client") +@patch("litellm.caching.redis_cache.RedisCache._setup_health_pings") +def test_cache_init_creates_redis_cache_without_cluster_config( + mock_health, mock_get_client, mock_get_pool, monkeypatch +): + """ + Test that Cache() creates RedisCache when no cluster config is present. + + Ensures backward compatibility: without REDIS_CLUSTER_NODES or + redis_startup_nodes, the standard RedisCache is still used. + """ + from litellm.caching.caching import Cache + + monkeypatch.delenv("REDIS_CLUSTER_NODES", raising=False) + monkeypatch.setenv("REDIS_HOST", "localhost") + monkeypatch.setenv("REDIS_PORT", "6379") + monkeypatch.delenv("REDIS_URL", raising=False) + + mock_get_client.return_value = MagicMock() + mock_get_pool.return_value = MagicMock() + + cache = Cache(type="redis") + assert isinstance(cache.cache, RedisCache) + assert not isinstance(cache.cache, RedisClusterCache) + + +@pytest.mark.parametrize( + "startup_nodes, env_var, expected_cache_type", + [ + pytest.param( + [dict(host="node1.localhost", port=6379)], + None, + RedisClusterCache, + id="cluster-via-explicit-startup-nodes", + ), + pytest.param( + None, + '[{"host": "node1.localhost", "port": 6379}]', + RedisClusterCache, + id="cluster-via-env-var", + ), + pytest.param( + None, + None, + RedisCache, + id="standard-redis-when-no-cluster-config", + ), + pytest.param( + [dict(host="explicit-node.localhost", port=6379)], + '[{"host": "env-node.localhost", "port": 6379}]', + RedisClusterCache, + id="explicit-startup-nodes-takes-precedence-over-env-var", + ), + ], +) +def test_router_create_redis_cache_cluster_detection( + startup_nodes, env_var, expected_cache_type, monkeypatch +): + """ + Test that Router._create_redis_cache() creates RedisClusterCache when + either startup_nodes is in config or REDIS_CLUSTER_NODES env var is set. + Also verifies that explicit startup_nodes take precedence over env var. + + Regression test for https://github.com/BerriAI/litellm/issues/22748 + """ + from litellm import Router + + cache_config = dict( + host="mockhost", + port=6379, + password="mock-password", + startup_nodes=startup_nodes, + ) + + if env_var is not None: + monkeypatch.setenv("REDIS_CLUSTER_NODES", env_var) + else: + monkeypatch.delenv("REDIS_CLUSTER_NODES", raising=False) + + def _mock_redis_cache_init(*args, **kwargs): ... + + with patch.object(RedisCache, "__init__", _mock_redis_cache_init): + redis_cache = Router._create_redis_cache(cache_config) + assert isinstance(redis_cache, expected_cache_type) diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 28624ea8b20..51b5942d721 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -365,6 +365,143 @@ def test_success_handler_skips_sync_callbacks_for_async_requests(logging_obj, as dummy_logger.log_stream_event.assert_not_called() +@pytest.mark.parametrize( + "call_type, expected_async", + [ + ("agenerate_content_stream", True), + ("anthropic_messages", True), + ("_aresponses_websocket", True), + ("add_message", False), + ("completion", False), + ], +) +def test_is_async_litellm_request_detection_uses_call_type(logging_obj, call_type, expected_async): + logging_obj.call_type = call_type + logging_obj.model_call_details["litellm_params"] = {} + logging_obj.litellm_params = {} + + assert ( + logging_obj._is_async_litellm_request(logging_obj.model_call_details["litellm_params"]) + is expected_async + ) + + +def test_success_handler_does_not_emit_standard_payload_for_async_call_marker(logging_obj): + """ + Regression test for async call types that don't set legacy `litellm_params` flags + (e.g. `agenerate_content_stream`). + """ + from litellm.types.utils import CallTypes + + logging_obj.stream = True + logging_obj.call_type = CallTypes.agenerate_content_stream.value + logging_obj.model_call_details["litellm_params"] = {"async_call": True} + logging_obj.litellm_params = logging_obj.model_call_details["litellm_params"] + + model_response = ModelResponse( + id="resp-123", + model="gemini-2.5-pro", + choices=[ + { + "message": {"role": "assistant", "content": "hello"}, + "finish_reason": "stop", + "index": 0, + } + ], + usage={"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + ) + + with ( + patch.object( + logging_obj, + "_get_assembled_streaming_response", + return_value=model_response, + ), + patch( + "litellm.litellm_core_utils.litellm_logging.get_standard_logging_object_payload", + return_value={"id": "test-payload"}, + ), + patch.object(logging_obj, "get_combined_callback_list", return_value=[]), + patch( + "litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload" + ) as mock_emit, + ): + logging_obj.success_handler(result=model_response) + + mock_emit.assert_not_called() + + +@pytest.mark.asyncio +async def test_wrapper_async_calls_sync_success_callbacks_once(): + """ + Regression test: wrapper_async should trigger sync-success callbacks once. + + Historically, wrapper_async invoked handle_sync_success_callbacks_for_async_calls() + directly and via _client_async_logging_helper, causing duplicate calls. + """ + import asyncio + + import litellm + + test_logging_obj = LitellmLogging( + model="openai/codex-mini-latest", + messages=[{"role": "user", "content": "hello"}], + stream=False, + call_type="acompletion", + start_time=time.time(), + litellm_call_id="test-call-once", + function_id="test-function-once", + ) + test_logging_obj.handle_sync_success_callbacks_for_async_calls = MagicMock() + + await litellm.acompletion( + model="openai/codex-mini-latest", + messages=[{"role": "user", "content": "hello"}], + max_tokens=10, + mock_response="hi", + caching=False, + litellm_logging_obj=test_logging_obj, + ) + await asyncio.sleep(1.0) + + assert ( + test_logging_obj.handle_sync_success_callbacks_for_async_calls.call_count == 1 + ) + + +def test_success_handler_does_not_emit_standard_payload_when_called_from_async(logging_obj): + """ + Regression test: sync success handler should not emit standard payload when + invoked from async flow (async_success_handler already emits). + """ + from litellm.types.utils import CallTypes + + logging_obj.stream = True + logging_obj.call_type = CallTypes.anthropic_messages.value + logging_obj.model_call_details["litellm_params"] = {} + logging_obj.litellm_params = {} + + model_response = ModelResponse( + id="resp-123", + model="claude-sonnet-4-5", + choices=[ + { + "message": {"role": "assistant", "content": "hello"}, + "finish_reason": "stop", + "index": 0, + } + ], + usage={"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + ) + + with patch( + "litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload" + ) as mock_emit: + logging_obj.success_handler(result=model_response, called_from_async=True) + + mock_emit.assert_not_called() + + @pytest.mark.parametrize("call_type", ["completion", "responses"]) def test_success_handler_runs_sync_callbacks_for_sync_requests(logging_obj, call_type): """Ensure sync success callbacks execute when call type is sync (completion/responses).""" diff --git a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py index 6a64e7020b9..544353322b4 100644 --- a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py +++ b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py @@ -22,6 +22,7 @@ from litellm.litellm_core_utils.streaming_handler import ( from litellm.types.utils import ( CompletionTokensDetailsWrapper, Delta, + ModelResponse, ModelResponseStream, PromptTokensDetailsWrapper, StandardLoggingPayload, @@ -1355,6 +1356,156 @@ def test_usage_chunk_after_finish_reason_updates_hidden_params(logging_obj): f"Expected completion_tokens=135 from provider, got {hidden_usage.completion_tokens}" ) + +@pytest.mark.asyncio +async def test_custom_stream_wrapper_anext_does_not_block_event_loop_for_sync_iterators( + logging_obj: Logging, +): + """ + Regression test: __anext__ must not call blocking next() on a sync iterator on the + event loop thread. This happens for some provider streams which are sync iterators + but used in async contexts (e.g. boto3-style streaming). + """ + + class BlockingIterator: + def __init__(self, chunks, delay_s: float): + self._it = iter(chunks) + self._delay_s = delay_s + + def __iter__(self): + return self + + def __next__(self): + time.sleep(self._delay_s) # simulate blocking I/O + return next(self._it) + + test_chunk = ModelResponseStream( + id="chatcmpl-test", + created=int(time.time()), + model="test-model", + object="chat.completion.chunk", + system_fingerprint=None, + choices=[ + StreamingChoices( + finish_reason="stop", + index=0, + delta=Delta( + provider_specific_fields=None, + content="hello", + role="assistant", + function_call=None, + tool_calls=None, + audio=None, + ), + logprobs=None, + ) + ], + provider_specific_fields={}, + usage=None, + ) + + # Delay is intentionally > the wait_for timeout used to detect event loop blocking. + wrapper = CustomStreamWrapper( + completion_stream=BlockingIterator([test_chunk], delay_s=0.3), + model="test-model", + logging_obj=logging_obj, + custom_llm_provider="cached_response", + ) + + tick_event = asyncio.Event() + + async def background_tick(): + await asyncio.sleep(0.05) + tick_event.set() + + bg_task = asyncio.create_task(background_tick()) + anext_task = asyncio.create_task(wrapper.__anext__()) + try: + # If the event loop is blocked by a sync next(), this will time out. + await asyncio.wait_for(tick_event.wait(), timeout=0.15) + + out = await asyncio.wait_for(anext_task, timeout=2.0) + assert isinstance(out, ModelResponseStream) + finally: + if not anext_task.done(): + anext_task.cancel() + try: + await anext_task + except asyncio.CancelledError: + pass + await bg_task + + +@pytest.mark.asyncio +async def test_custom_stream_wrapper_anext_marks_sync_success_handler_as_async_origin(): + """ + Regression test: async stream finalization should call success_handler with + called_from_async=True to avoid duplicate standard payload emission. + """ + + class EmptyAsyncIterator: + def __aiter__(self): + return self + + async def __anext__(self): + raise StopAsyncIteration + + logging_obj = MagicMock() + logging_obj.model_call_details = {"litellm_params": {}} + logging_obj.async_success_handler = AsyncMock(return_value=None) + + wrapper = CustomStreamWrapper( + completion_stream=EmptyAsyncIterator(), + model="vertex_ai/gemini-2.5-pro", + logging_obj=logging_obj, + custom_llm_provider="vertex_ai", + ) + wrapper.sent_last_chunk = True + wrapper.chunks = [ + ModelResponseStream( + id="chunk-id", + created=int(time.time()), + model="vertex_ai/gemini-2.5-pro", + object="chat.completion.chunk", + choices=[ + StreamingChoices( + finish_reason="stop", + index=0, + delta=Delta(content="hello", role="assistant"), + ) + ], + ) + ] + + complete_streaming_response = ModelResponse( + id="resp-123", + model="vertex_ai/gemini-2.5-pro", + choices=[ + { + "message": {"role": "assistant", "content": "hello"}, + "finish_reason": "stop", + "index": 0, + } + ], + usage={"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + ) + + with ( + patch( + "litellm.litellm_core_utils.streaming_handler.litellm.stream_chunk_builder", + return_value=complete_streaming_response, + ), + patch( + "litellm.litellm_core_utils.streaming_handler.executor.submit" + ) as mock_submit, + ): + with pytest.raises(StopAsyncIteration): + await wrapper.__anext__() + + assert mock_submit.call_count == 1 + assert mock_submit.call_args.kwargs.get("called_from_async") is True + + @pytest.mark.asyncio async def test_custom_stream_wrapper_aclose(): """Test that aclose() delegates to the underlying completion_stream's aclose()""" diff --git a/tests/test_litellm/proxy/auth/test_info_routes.py b/tests/test_litellm/proxy/auth/test_info_routes.py new file mode 100644 index 00000000000..6c403883fb7 --- /dev/null +++ b/tests/test_litellm/proxy/auth/test_info_routes.py @@ -0,0 +1,119 @@ +import pytest +from unittest.mock import MagicMock + +from fastapi import HTTPException, Request + +from litellm.proxy._types import LiteLLM_UserTable, LiteLLMRoutes, LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.auth.route_checks import RouteChecks + + +def test_info_route_identification(): + """Test that info routes are correctly identified""" + for route in LiteLLMRoutes.info_routes.value: + assert RouteChecks.is_info_route(route) is True + + # Non-info routes should return False + assert RouteChecks.is_info_route("/chat/completions") is False + assert RouteChecks.is_info_route("/key/generate") is False + + +def test_key_info_route_access(): + """Test access control for /key/info route""" + # This route handles its own access control, so it should pass for any user + user_obj = LiteLLM_UserTable( + user_id="test_user", + user_email="test@example.com", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + valid_token = UserAPIKeyAuth(user_id="test_user") + request = MagicMock(spec=Request) + request.query_params = {} + + # Should not raise exception as /key/info handles its own logic + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER, + route="/key/info", + request=request, + valid_token=valid_token, + request_data={}, + ) + + +def test_user_info_route_access(): + """Test access control for /user/info route""" + user_obj = LiteLLM_UserTable( + user_id="test_user", + user_email="test@example.com", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + valid_token = UserAPIKeyAuth(user_id="test_user") + request = MagicMock(spec=Request) + request.query_params = {"user_id": "test_user"} + + # Should not raise exception when user_id matches token's user_id + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER, + route="/user/info", + request=request, + valid_token=valid_token, + request_data={}, + ) + + # Should raise exception when user_id does not match + request.query_params = {"user_id": "different_user"} + with pytest.raises(HTTPException) as exc_info: + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER, + route="/user/info", + request=request, + valid_token=valid_token, + request_data={}, + ) + assert exc_info.value.status_code == 403 + + +def test_model_info_route_access(): + """Test access control for /model/info route""" + user_obj = LiteLLM_UserTable( + user_id="test_user", + user_email="test@example.com", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + valid_token = UserAPIKeyAuth(user_id="test_user") + request = MagicMock(spec=Request) + request.query_params = {} + + # Should not raise exception as /model/info is accessible to show user's models + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER, + route="/model/info", + request=request, + valid_token=valid_token, + request_data={}, + ) + + +def test_team_info_route_access(): + """Test access control for /team/info route""" + user_obj = LiteLLM_UserTable( + user_id="test_user", + user_email="test@example.com", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + valid_token = UserAPIKeyAuth(user_id="test_user") + request = MagicMock(spec=Request) + request.query_params = {} + + # Should not raise exception as /team/info handles its own logic + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER, + route="/team/info", + request=request, + valid_token=valid_token, + request_data={}, + ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index bc5c6925107..5f068d4817a 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -6460,6 +6460,8 @@ class TestValidateKeyAliasFormat: _validate_key_alias_format("valid/alias") _validate_key_alias_format("a" * 255) _validate_key_alias_format("my-key-123") + _validate_key_alias_format("user/user@example.com") + _validate_key_alias_format("team/user@example.com") def test_validate_key_alias_format_invalid(self): from litellm.proxy.management_endpoints.key_management_endpoints import _validate_key_alias_format @@ -6472,7 +6474,7 @@ class TestValidateKeyAliasFormat: "!", # special char "-start", # non-alphanumeric start "end-", # non-alphanumeric end - "invalid@char", # invalid char + "invalid#char", # invalid char "a" * 256, # too long " leading", "trailing ", diff --git a/tests/test_litellm/proxy/test_proxy_utils.py b/tests/test_litellm/proxy/test_proxy_utils.py index 7deda21c215..4b50e9a4d31 100644 --- a/tests/test_litellm/proxy/test_proxy_utils.py +++ b/tests/test_litellm/proxy/test_proxy_utils.py @@ -35,7 +35,7 @@ def test_proxy_only_error_true_for_llm_route(): ) -def test_proxy_only_error_false_for_non_llm_route(): +def test_proxy_only_error_true_for_info_route(): proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache()) assert ( proxy_logging_obj._is_proxy_only_llm_api_error( @@ -43,6 +43,18 @@ def test_proxy_only_error_false_for_non_llm_route(): error_type=ProxyErrorTypes.auth_error, route="/key/info", ) + is True + ) + + +def test_proxy_only_error_false_for_non_llm_non_info_route(): + proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache()) + assert ( + proxy_logging_obj._is_proxy_only_llm_api_error( + original_exception=Exception(), + error_type=ProxyErrorTypes.auth_error, + route="/key/generate", + ) is False ) diff --git a/tests/test_litellm/responses/test_responses_websocket_all_providers.py b/tests/test_litellm/responses/test_responses_websocket_all_providers.py index 0d83b9f88de..22dde582fb8 100644 --- a/tests/test_litellm/responses/test_responses_websocket_all_providers.py +++ b/tests/test_litellm/responses/test_responses_websocket_all_providers.py @@ -165,6 +165,40 @@ class TestManagedWebSocketHandlerIntegration: assert handler.timeout == 30.0 assert handler.custom_llm_provider == "test_provider" + @pytest.mark.asyncio + async def test_websocket_log_messages_marks_sync_success_handler_as_async_origin( + self, + ): + """WebSocket logging should suppress duplicate standard payload emission.""" + from unittest.mock import AsyncMock, MagicMock, patch + + from litellm.responses.streaming_iterator import ResponsesWebSocketStreaming + + mock_logging_obj = MagicMock() + mock_logging_obj.async_success_handler = AsyncMock() + mock_logging_obj.success_handler = MagicMock() + mock_logging_obj.model_call_details = {} + + streaming = ResponsesWebSocketStreaming( + websocket=MagicMock(), + backend_ws=MagicMock(), + logging_obj=mock_logging_obj, + ) + streaming.messages = [{"type": "response.completed"}] + streaming.input_messages = [{"role": "user", "content": "hello"}] + + with patch( + "litellm.responses.streaming_iterator._ws_executor.submit" + ) as mock_submit: + await streaming._log_messages() + + assert mock_logging_obj.model_call_details["messages"] == streaming.input_messages + assert mock_submit.call_args.args == ( + mock_logging_obj.success_handler, + streaming.messages, + ) + assert mock_submit.call_args.kwargs.get("called_from_async") is True + class TestChunkTransformation: """Test chunk serialization and transformation for WebSocket streaming""" diff --git a/ui/litellm-dashboard/src/components/policies/add_attachment_form.test.tsx b/ui/litellm-dashboard/src/components/policies/add_attachment_form.test.tsx new file mode 100644 index 00000000000..8982f72da35 --- /dev/null +++ b/ui/litellm-dashboard/src/components/policies/add_attachment_form.test.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as networking from "../networking"; +import AddAttachmentForm from "./add_attachment_form"; +import { Policy } from "./types"; + +vi.mock("../networking"); + +vi.mock("./impact_preview_alert", () => ({ + default: ({ impactResult }: { impactResult: any }) => + React.createElement("div", { "data-testid": "impact-preview" }, `${impactResult.affected_keys_count} keys`), +})); + +const makePolicy = (overrides: Partial = {}): Policy => ({ + policy_id: "policy-id-1", + policy_name: "test-policy", + inherit: null, + description: null, + guardrails_add: [], + guardrails_remove: [], + condition: null, + ...overrides, +}); + +const defaultProps = { + visible: true, + onClose: vi.fn(), + onSuccess: vi.fn(), + accessToken: "test-token", + policies: [makePolicy({ policy_name: "policy-alpha" }), makePolicy({ policy_name: "policy-beta", policy_id: "id-2" })], + createAttachment: vi.fn(), +}; + +describe("AddAttachmentForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(networking.teamListCall).mockResolvedValue([]); + vi.mocked(networking.keyListCall).mockResolvedValue({ keys: [] }); + vi.mocked(networking.modelAvailableCall).mockResolvedValue({ data: [] }); + }); + + it("should render the modal title when visible", async () => { + renderWithProviders(); + expect(await screen.findByText("Create Policy Attachment")).toBeInTheDocument(); + }); + + it("should not render modal content when visible is false", () => { + renderWithProviders(); + expect(screen.queryByText("Create Policy Attachment")).not.toBeInTheDocument(); + }); + + it("should fetch teams, keys, and models on mount when visible and accessToken are provided", async () => { + renderWithProviders(); + await waitFor(() => { + expect(networking.teamListCall).toHaveBeenCalled(); + expect(networking.keyListCall).toHaveBeenCalled(); + expect(networking.modelAvailableCall).toHaveBeenCalled(); + }); + }); + + it("should not fetch teams, keys, or models when accessToken is null", () => { + renderWithProviders(); + expect(networking.teamListCall).not.toHaveBeenCalled(); + expect(networking.keyListCall).not.toHaveBeenCalled(); + expect(networking.modelAvailableCall).not.toHaveBeenCalled(); + }); + + it("should call onClose when the Cancel button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(await screen.findByRole("button", { name: /cancel/i })); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it("should not show scope-specific fields when scope is global (default)", async () => { + renderWithProviders(); + await screen.findByText("Create Policy Attachment"); + expect(screen.queryByText("Teams")).not.toBeInTheDocument(); + expect(screen.queryByText("Keys")).not.toBeInTheDocument(); + expect(screen.queryByText("Models")).not.toBeInTheDocument(); + }); + + it("should show Teams, Keys, Models, and Tags fields when scope is switched to specific", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("Create Policy Attachment"); + await user.click(screen.getByRole("radio", { name: /specific/i })); + expect(screen.getByText("Teams")).toBeInTheDocument(); + expect(screen.getByText("Keys")).toBeInTheDocument(); + expect(screen.getByText("Models")).toBeInTheDocument(); + expect(screen.getByText("Tags")).toBeInTheDocument(); + }); + + it("should show the 'Estimate Impact' button only when scope is specific", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("Create Policy Attachment"); + expect(screen.queryByRole("button", { name: /estimate impact/i })).not.toBeInTheDocument(); + await user.click(screen.getByRole("radio", { name: /specific/i })); + expect(screen.getByRole("button", { name: /estimate impact/i })).toBeInTheDocument(); + }); + + it("should render a 'Create Attachment' submit button", async () => { + renderWithProviders(); + expect(await screen.findByRole("button", { name: /create attachment/i })).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/policies/guardrail_selection_modal.test.tsx b/ui/litellm-dashboard/src/components/policies/guardrail_selection_modal.test.tsx new file mode 100644 index 00000000000..e730de71e6b --- /dev/null +++ b/ui/litellm-dashboard/src/components/policies/guardrail_selection_modal.test.tsx @@ -0,0 +1,127 @@ +import React from "react"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import GuardrailSelectionModal from "./guardrail_selection_modal"; + +const makeGuardrailDef = (name: string, description = "A guardrail description") => ({ + guardrail_name: name, + guardrail_info: { description }, + litellm_params: { guardrail: "presidio", mode: "pre_call" }, +}); + +const makeTemplate = (guardrailDefs: any[] = [], overrides: any = {}) => ({ + title: "Test Template", + guardrailDefinitions: guardrailDefs, + ...overrides, +}); + +const defaultProps = { + visible: true, + template: makeTemplate([makeGuardrailDef("guardrail-new-1"), makeGuardrailDef("guardrail-new-2")]), + existingGuardrails: new Set(), + onConfirm: vi.fn(), + onCancel: vi.fn(), +}; + +describe("GuardrailSelectionModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render guardrail names from the template", async () => { + renderWithProviders(); + expect(await screen.findByText("guardrail-new-1")).toBeInTheDocument(); + expect(screen.getByText("guardrail-new-2")).toBeInTheDocument(); + }); + + it("should pre-select only new guardrails when the modal opens", async () => { + renderWithProviders(); + await screen.findByText("guardrail-new-1"); + const checkboxes = screen.getAllByRole("checkbox"); + checkboxes.forEach((cb) => expect(cb).toBeChecked()); + }); + + it("should not show a checkbox for guardrails that already exist", async () => { + const props = { + ...defaultProps, + template: makeTemplate([makeGuardrailDef("existing-g"), makeGuardrailDef("new-g")]), + existingGuardrails: new Set(["existing-g"]), + }; + renderWithProviders(); + await screen.findByText("existing-g"); + expect(screen.getAllByRole("checkbox")).toHaveLength(1); + }); + + it("should show an 'Already exists' tag for guardrails that exist in the system", async () => { + const props = { + ...defaultProps, + template: makeTemplate([makeGuardrailDef("existing-g")]), + existingGuardrails: new Set(["existing-g"]), + }; + renderWithProviders(); + expect(await screen.findByText("Already exists")).toBeInTheDocument(); + }); + + it("should show 'Create N Guardrails & Use Template' on the confirm button when N guardrails are selected", async () => { + renderWithProviders(); + expect(await screen.findByRole("button", { name: /create 2 guardrails & use template/i })).toBeInTheDocument(); + }); + + it("should show 'Use Template' on the confirm button when no new guardrails are selected", async () => { + const props = { + ...defaultProps, + template: makeTemplate([makeGuardrailDef("existing-g")]), + existingGuardrails: new Set(["existing-g"]), + }; + renderWithProviders(); + expect(await screen.findByRole("button", { name: /^use template$/i })).toBeInTheDocument(); + }); + + it("should call onConfirm with the definitions of selected guardrails when confirmed", async () => { + const user = userEvent.setup(); + const def = makeGuardrailDef("my-guardrail"); + const props = { ...defaultProps, template: makeTemplate([def]) }; + renderWithProviders(); + await user.click(await screen.findByRole("button", { name: /create 1 guardrail/i })); + expect(defaultProps.onConfirm).toHaveBeenCalledWith([def]); + }); + + it("should deselect all guardrails when 'Deselect All' is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("guardrail-new-1"); + await user.click(screen.getByRole("button", { name: /deselect all/i })); + screen.getAllByRole("checkbox").forEach((cb) => expect(cb).not.toBeChecked()); + }); + + it("should re-select all new guardrails when 'Select All New' is clicked after deselecting", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("guardrail-new-1"); + await user.click(screen.getByRole("button", { name: /deselect all/i })); + await user.click(screen.getByRole("button", { name: /select all new/i })); + screen.getAllByRole("checkbox").forEach((cb) => expect(cb).toBeChecked()); + }); + + it("should show 'No guardrails defined' when the template has no guardrail definitions", async () => { + const props = { ...defaultProps, template: makeTemplate([]) }; + renderWithProviders(); + expect(await screen.findByText(/no guardrails defined for this template/i)).toBeInTheDocument(); + }); + + it("should show a progress badge when progressInfo is provided", async () => { + const props = { ...defaultProps, progressInfo: { current: 2, total: 5 } }; + renderWithProviders(); + expect(await screen.findByText(/template 2 of 5/i)).toBeInTheDocument(); + }); + + it("should call onCancel when the Cancel button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("guardrail-new-1"); + await user.click(screen.getByRole("button", { name: /^cancel$/i })); + expect(defaultProps.onCancel).toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/policies/impact_popover.test.tsx b/ui/litellm-dashboard/src/components/policies/impact_popover.test.tsx new file mode 100644 index 00000000000..b8758927794 --- /dev/null +++ b/ui/litellm-dashboard/src/components/policies/impact_popover.test.tsx @@ -0,0 +1,184 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as networking from "../networking"; +import ImpactPopover from "./impact_popover"; +import { PolicyAttachment } from "./types"; + +vi.mock("../networking"); + +vi.mock("@heroicons/react/outline", () => ({ + EyeIcon: function EyeIcon() { return null; }, +})); + +vi.mock("@tremor/react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Icon: ({ icon: IconComp, onClick, className }: any) => + React.createElement("button", { type: "button", onClick, className }, IconComp?.displayName ?? IconComp?.name ?? "icon"), + }; +}); + +// Expose the Popover's onOpenChange so tests can trigger it programmatically. +vi.mock("antd", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Popover: ({ children, onOpenChange, content }: any) => + React.createElement( + "div", + null, + React.createElement("div", { "data-testid": "popover-content" }, content), + React.createElement( + "div", + { + role: "button", + "aria-label": "open-popover", + onClick: () => onOpenChange?.(true), + }, + children + ) + ), + Tooltip: ({ children }: any) => React.createElement(React.Fragment, null, children), + Spin: () => React.createElement("span", null, "Loading..."), + Tag: ({ children }: any) => React.createElement("span", null, children), + }; +}); + +const makeAttachment = (overrides: Partial = {}): PolicyAttachment => ({ + attachment_id: "att-001", + policy_name: "my-policy", + scope: null, + teams: [], + keys: [], + models: [], + tags: [], + ...overrides, +}); + +describe("ImpactPopover", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render", () => { + renderWithProviders(); + expect(screen.getByRole("button", { name: /open-popover/i })).toBeInTheDocument(); + }); + + it("should show 'Click to load' as the initial popover content", () => { + renderWithProviders(); + expect(screen.getByText(/click to load/i)).toBeInTheDocument(); + }); + + it("should call estimateAttachmentImpactCall when the popover is opened", async () => { + const user = userEvent.setup(); + vi.mocked(networking.estimateAttachmentImpactCall).mockResolvedValue({ + affected_keys_count: 0, + affected_teams_count: 0, + sample_keys: [], + sample_teams: [], + }); + const attachment = makeAttachment({ policy_name: "rate-limit", teams: ["team-a"] }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + await waitFor(() => { + expect(networking.estimateAttachmentImpactCall).toHaveBeenCalledWith("my-token", { + policy_name: "rate-limit", + scope: null, + teams: ["team-a"], + keys: [], + models: [], + tags: [], + }); + }); + }); + + it("should not call the API when accessToken is null", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + expect(networking.estimateAttachmentImpactCall).not.toHaveBeenCalled(); + }); + + it("should show a loading indicator while the impact is being fetched", async () => { + const user = userEvent.setup(); + vi.mocked(networking.estimateAttachmentImpactCall).mockReturnValue(new Promise(() => {})); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + // Multiple "Loading..." nodes exist (Spin + adjacent text) — assert at least one is present + expect(screen.queryAllByText(/loading/i).length).toBeGreaterThan(0); + }); + + it("should show a global scope warning when affected_keys_count is -1", async () => { + const user = userEvent.setup(); + vi.mocked(networking.estimateAttachmentImpactCall).mockResolvedValue({ + affected_keys_count: -1, + affected_teams_count: -1, + sample_keys: [], + sample_teams: [], + }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + expect(await screen.findByText(/global scope.*affects all keys and teams/i)).toBeInTheDocument(); + }); + + it("should show key and team counts when impact data is loaded for a specific scope", async () => { + const user = userEvent.setup(); + vi.mocked(networking.estimateAttachmentImpactCall).mockResolvedValue({ + affected_keys_count: 5, + affected_teams_count: 2, + sample_keys: ["sk-abc"], + sample_teams: ["team-x"], + }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + expect(await screen.findByText(/5/)).toBeInTheDocument(); + expect(screen.getByText(/2/)).toBeInTheDocument(); + }); + + it("should render sample key tags when returned from the API", async () => { + const user = userEvent.setup(); + vi.mocked(networking.estimateAttachmentImpactCall).mockResolvedValue({ + affected_keys_count: 2, + affected_teams_count: 0, + sample_keys: ["sk-key-one", "sk-key-two"], + sample_teams: [], + }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + expect(await screen.findByText("sk-key-one")).toBeInTheDocument(); + expect(screen.getByText("sk-key-two")).toBeInTheDocument(); + }); + + it("should show 'No keys or teams currently affected' when both counts are 0", async () => { + const user = userEvent.setup(); + vi.mocked(networking.estimateAttachmentImpactCall).mockResolvedValue({ + affected_keys_count: 0, + affected_teams_count: 0, + sample_keys: [], + sample_teams: [], + }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + expect(await screen.findByText(/no keys or teams currently affected/i)).toBeInTheDocument(); + }); + + it("should not call the API a second time when the popover is already loaded", async () => { + const user = userEvent.setup(); + vi.mocked(networking.estimateAttachmentImpactCall).mockResolvedValue({ + affected_keys_count: 1, + affected_teams_count: 0, + sample_keys: ["sk-abc"], + sample_teams: [], + }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + await screen.findByText("sk-abc"); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + expect(networking.estimateAttachmentImpactCall).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/components/policies/policy_table.test.tsx b/ui/litellm-dashboard/src/components/policies/policy_table.test.tsx new file mode 100644 index 00000000000..f671fae6b9b --- /dev/null +++ b/ui/litellm-dashboard/src/components/policies/policy_table.test.tsx @@ -0,0 +1,150 @@ +import React from "react"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import PolicyTable from "./policy_table"; +import { Policy } from "./types"; + +vi.mock("@heroicons/react/outline", () => ({ + TrashIcon: function TrashIcon() { return null; }, + PencilIcon: function PencilIcon() { return null; }, + SwitchVerticalIcon: function SwitchVerticalIcon() { return null; }, + ChevronUpIcon: function ChevronUpIcon() { return null; }, + ChevronDownIcon: function ChevronDownIcon() { return null; }, +})); + +vi.mock("@tremor/react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Button: React.forwardRef(({ children, ...props }, ref) => + React.createElement("button", { ...props, ref }, children) + ), + Icon: ({ icon: IconComp, onClick, className }: any) => + React.createElement("button", { type: "button", onClick, className }, IconComp?.displayName ?? IconComp?.name ?? "icon"), + Tooltip: ({ children }: { children?: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + Badge: ({ children }: { children?: React.ReactNode }) => + React.createElement("span", null, children), + }; +}); + +const makePolicy = (overrides: Partial = {}): Policy => ({ + policy_id: "policy-id-1", + policy_name: "test-policy", + inherit: null, + description: null, + guardrails_add: [], + guardrails_remove: [], + condition: null, + ...overrides, +}); + +const defaultProps = { + policies: [], + isLoading: false, + onDeleteClick: vi.fn(), + onEditClick: vi.fn(), + onViewClick: vi.fn(), + isAdmin: true, +}; + +describe("PolicyTable", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render column headers", () => { + renderWithProviders(); + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("Description")).toBeInTheDocument(); + expect(screen.getByText("Actions")).toBeInTheDocument(); + }); + + it("should show a loading message when isLoading is true", () => { + renderWithProviders(); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it("should show 'No policies found' when there are no policies", () => { + renderWithProviders(); + expect(screen.getByText(/no policies found/i)).toBeInTheDocument(); + }); + + it("should render a button with the policy name for each grouped policy", () => { + const policies = [ + makePolicy({ policy_name: "alpha-policy", policy_id: "id-1" }), + makePolicy({ policy_name: "beta-policy", policy_id: "id-2" }), + ]; + renderWithProviders(); + expect(screen.getByRole("button", { name: "alpha-policy" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "beta-policy" })).toBeInTheDocument(); + }); + + it("should call onViewClick with the policy_id when the policy name button is clicked", async () => { + const user = userEvent.setup(); + const policy = makePolicy({ policy_name: "my-policy", policy_id: "view-id-1" }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: "my-policy" })); + expect(defaultProps.onViewClick).toHaveBeenCalledWith("view-id-1"); + }); + + it("should call onDeleteClick with policy_id and policy_name when the delete icon is clicked", async () => { + const user = userEvent.setup(); + const policy = makePolicy({ policy_name: "del-policy", policy_id: "del-id-1" }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /TrashIcon/i })); + expect(defaultProps.onDeleteClick).toHaveBeenCalledWith("del-id-1", "del-policy"); + }); + + it("should call onEditClick with the policy when the edit icon is clicked", async () => { + const user = userEvent.setup(); + const policy = makePolicy({ policy_name: "edit-policy", policy_id: "edit-id-1" }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /PencilIcon/i })); + expect(defaultProps.onEditClick).toHaveBeenCalledWith(policy); + }); + + it("should not show admin action icons for non-admins", () => { + const policy = makePolicy(); + renderWithProviders(); + expect(screen.queryByRole("button", { name: /TrashIcon/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /PencilIcon/i })).not.toBeInTheDocument(); + }); + + it("should show a version badge when multiple versions of the same policy name exist", () => { + const policies = [ + makePolicy({ policy_name: "versioned", policy_id: "v1", version_status: "published", version_number: 1 }), + makePolicy({ policy_name: "versioned", policy_id: "v2", version_status: "production", version_number: 2 }), + ]; + renderWithProviders(); + expect(screen.getByText(/2 version/i)).toBeInTheDocument(); + }); + + it("should group policies with the same name into a single row", () => { + const policies = [ + makePolicy({ policy_name: "shared", policy_id: "s1", version_status: "published" }), + makePolicy({ policy_name: "shared", policy_id: "s2", version_status: "production" }), + ]; + renderWithProviders(); + expect(screen.getAllByRole("button", { name: "shared" })).toHaveLength(1); + }); + + it("should show an overflow tag when more than 2 guardrails_add exist", () => { + const policy = makePolicy({ guardrails_add: ["g1", "g2", "g3", "g4"] }); + renderWithProviders(); + expect(screen.getByText("+2")).toBeInTheDocument(); + }); + + it("should prefer the production version as the primary policy when grouping", async () => { + const user = userEvent.setup(); + const policies = [ + makePolicy({ policy_name: "grouped", policy_id: "published-id", version_status: "published" }), + makePolicy({ policy_name: "grouped", policy_id: "prod-id", version_status: "production" }), + ]; + renderWithProviders(); + await user.click(screen.getByRole("button", { name: "grouped" })); + expect(defaultProps.onViewClick).toHaveBeenCalledWith("prod-id"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/policies/policy_templates.test.tsx b/ui/litellm-dashboard/src/components/policies/policy_templates.test.tsx new file mode 100644 index 00000000000..b53cab42208 --- /dev/null +++ b/ui/litellm-dashboard/src/components/policies/policy_templates.test.tsx @@ -0,0 +1,144 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as networking from "../networking"; +import PolicyTemplates from "./policy_templates"; + +vi.mock("../networking"); + +vi.mock("@heroicons/react/outline", () => ({ + ShieldCheckIcon: function ShieldCheckIcon() { return null; }, + ShieldExclamationIcon: function ShieldExclamationIcon() { return null; }, + BeakerIcon: function BeakerIcon() { return null; }, + CurrencyDollarIcon: function CurrencyDollarIcon() { return null; }, + CheckCircleIcon: function CheckCircleIcon() { return null; }, +})); + +const makeTemplate = (overrides: any = {}) => ({ + id: "tpl-1", + title: "Test Template", + description: "A test template", + icon: "ShieldCheckIcon", + iconColor: "text-green-500", + iconBg: "bg-green-50", + guardrails: ["guardrail-a"], + tags: [], + complexity: "Low" as const, + ...overrides, +}); + +const defaultProps = { + onUseTemplate: vi.fn(), + onOpenAiSuggestion: vi.fn(), + accessToken: "test-token", +}; + +describe("PolicyTemplates", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the section header after loading", async () => { + vi.mocked(networking.getPolicyTemplates).mockResolvedValue([]); + renderWithProviders(); + expect(await screen.findByText("Policy Templates")).toBeInTheDocument(); + }); + + it("should not show the template grid while fetching", () => { + vi.mocked(networking.getPolicyTemplates).mockReturnValue(new Promise(() => {})); + renderWithProviders(); + expect(screen.queryByText("Policy Templates")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /use ai to find templates/i })).not.toBeInTheDocument(); + }); + + it("should render a card for each fetched template", async () => { + const templates = [ + makeTemplate({ title: "Template Alpha" }), + makeTemplate({ id: "tpl-2", title: "Template Beta" }), + ]; + vi.mocked(networking.getPolicyTemplates).mockResolvedValue(templates); + renderWithProviders(); + expect(await screen.findByText("Template Alpha")).toBeInTheDocument(); + expect(screen.getByText("Template Beta")).toBeInTheDocument(); + }); + + it("should call onTemplatesLoaded with the fetched templates after loading", async () => { + const templates = [makeTemplate()]; + vi.mocked(networking.getPolicyTemplates).mockResolvedValue(templates); + const onTemplatesLoaded = vi.fn(); + renderWithProviders(); + await waitFor(() => { + expect(onTemplatesLoaded).toHaveBeenCalledWith(templates); + }); + }); + + it("should call onOpenAiSuggestion when the AI suggestion button is clicked", async () => { + vi.mocked(networking.getPolicyTemplates).mockResolvedValue([]); + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("Policy Templates"); + await user.click(screen.getByRole("button", { name: /use ai to find templates/i })); + expect(defaultProps.onOpenAiSuggestion).toHaveBeenCalled(); + }); + + it("should render tag filter checkboxes for unique tags across all templates", async () => { + const templates = [ + makeTemplate({ tags: ["compliance"] }), + makeTemplate({ id: "tpl-2", tags: ["compliance", "security"] }), + ]; + vi.mocked(networking.getPolicyTemplates).mockResolvedValue(templates); + renderWithProviders(); + expect(await screen.findByRole("checkbox", { name: /compliance/i })).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: /security/i })).toBeInTheDocument(); + }); + + it("should filter to only matching templates when a tag is selected", async () => { + const templates = [ + makeTemplate({ id: "tpl-1", title: "Compliance Template", tags: ["compliance"] }), + makeTemplate({ id: "tpl-2", title: "Security Template", tags: ["security"] }), + ]; + vi.mocked(networking.getPolicyTemplates).mockResolvedValue(templates); + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("Compliance Template"); + await user.click(screen.getByRole("checkbox", { name: /compliance/i })); + expect(screen.getByText("Compliance Template")).toBeInTheDocument(); + expect(screen.queryByText("Security Template")).not.toBeInTheDocument(); + }); + + it("should show 'No templates match' when selected tags exclude all templates", async () => { + const templates = [ + makeTemplate({ id: "tpl-1", title: "Alpha Template", tags: ["alpha"] }), + makeTemplate({ id: "tpl-2", title: "Beta Template", tags: ["beta"] }), + ]; + vi.mocked(networking.getPolicyTemplates).mockResolvedValue(templates); + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("Alpha Template"); + await user.click(screen.getByRole("checkbox", { name: /alpha/i })); + await user.click(screen.getByRole("checkbox", { name: /beta/i })); + expect(screen.getByText(/no templates match the selected filters/i)).toBeInTheDocument(); + }); + + it("should restore all templates when 'Clear all' is clicked", async () => { + const templates = [ + makeTemplate({ id: "tpl-1", title: "Alpha Template", tags: ["alpha"] }), + makeTemplate({ id: "tpl-2", title: "Beta Template", tags: ["beta"] }), + ]; + vi.mocked(networking.getPolicyTemplates).mockResolvedValue(templates); + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("Alpha Template"); + await user.click(screen.getByRole("checkbox", { name: /alpha/i })); + expect(screen.queryByText("Beta Template")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /clear all/i })); + expect(screen.getByText("Beta Template")).toBeInTheDocument(); + }); + + it("should not fetch templates when accessToken is null", () => { + renderWithProviders(); + expect(networking.getPolicyTemplates).not.toHaveBeenCalled(); + }); +});