"""U1 — Gateway KB cache fail-closed 行为测试。 验证安全要求 R1:KB settings 读取失败时必须 fail-closed(禁用缓存), 不得 fail-open(默认启用缓存)。 覆盖场景: 1. settings 正常读取 caching_disabled=False → 缓存启用 2. settings 正常读取 caching_disabled=True → 缓存禁用 3. get_settings_store() 抛异常 → fail-closed,缓存禁用 4. settings 返回 None(KB 不存在)→ fail-closed,缓存禁用 5. kb_id=None(非 RAG 请求)→ 不查 settings,缓存正常启用 """ from __future__ import annotations from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from agentkit.llm.gateway import LLMGateway from agentkit.llm.protocol import LLMRequest, LLMResponse, TokenUsage def _make_response() -> LLMResponse: """构造最小 LLMResponse。""" return LLMResponse( content="ok", model="test-model", usage=TokenUsage(prompt_tokens=5, completion_tokens=3), ) def _make_gateway_with_cache() -> LLMGateway: """构造带 mock 缓存管理器的 LLMGateway(避免 litellm 依赖)。 gateway.chat() 调用 LitellmCacheManager.cache_params_for_hit/no_cache(类静态方法), 仅 should_cache 和 build_cache_key 通过实例调用 — mock 这两个即可。 """ gateway = LLMGateway() # 不启用真实 cache(litellm 可能未安装) mock_manager = MagicMock() # should_cache: kb_caching_disabled=True 或 user_id=None 时返回 False mock_manager.should_cache = lambda kb_disabled, uid: not kb_disabled and uid is not None mock_manager.build_cache_key = MagicMock(return_value="mock_cache_key") mock_manager.record_cache_result = MagicMock() gateway._cache_manager = mock_manager return gateway def _register_mock_provider(gateway: LLMGateway) -> MagicMock: """注册 mock provider,返回带 cache 参数的 capture。 模型格式 "test/test-model" → _resolve_model 按 "/" 分割为 provider="test" + model="test-model"。 """ provider = MagicMock() provider.chat = AsyncMock(return_value=_make_response()) gateway.register_provider("test", provider) return provider _MODEL = "test/test-model" def _get_cache_arg(provider: MagicMock) -> dict: """从 provider.chat 调用中提取 cache 参数。""" call_args = provider.chat.call_args # provider.chat(req) — req 是第一个位置参数 req: LLMRequest = call_args.args[0] return req._cache or {} class TestGatewayCacheFailClosed: """U1 — KB settings 读取异常时 fail-closed。""" async def test_settings_caching_false_enables_cache(self): """settings 正常读取 caching_disabled=False → cache_key 传入 provider(启用缓存)。""" gateway = _make_gateway_with_cache() provider = _register_mock_provider(gateway) mock_settings = SimpleNamespace(caching_disabled=False) with patch("agentkit.rag_platform.settings.get_settings_store") as mock_get_store: mock_store = MagicMock() mock_store.get_settings = AsyncMock(return_value=mock_settings) mock_get_store.return_value = mock_store await gateway.chat( messages=[{"role": "user", "content": "hi"}], model=_MODEL, user_id="u1", kb_id="kb1", kb_acl_hash="acl1", ) cache_arg = _get_cache_arg(provider) assert "cache_key" in cache_arg, f"Expected cache_key (cache enabled), got {cache_arg}" async def test_settings_caching_true_disables_cache(self): """settings 正常读取 caching_disabled=True → no-cache 传入 provider(禁用缓存)。""" gateway = _make_gateway_with_cache() provider = _register_mock_provider(gateway) mock_settings = SimpleNamespace(caching_disabled=True) with patch("agentkit.rag_platform.settings.get_settings_store") as mock_get_store: mock_store = MagicMock() mock_store.get_settings = AsyncMock(return_value=mock_settings) mock_get_store.return_value = mock_store await gateway.chat( messages=[{"role": "user", "content": "hi"}], model=_MODEL, user_id="u1", kb_id="kb1", ) cache_arg = _get_cache_arg(provider) assert cache_arg.get("no-cache") is True, f"Expected no-cache=True, got {cache_arg}" async def test_settings_exception_fail_closed(self): """get_settings_store() 抛异常 → fail-closed(no-cache)。""" gateway = _make_gateway_with_cache() provider = _register_mock_provider(gateway) with patch("agentkit.rag_platform.settings.get_settings_store") as mock_get_store: mock_store = MagicMock() mock_store.get_settings = AsyncMock(side_effect=RuntimeError("DB down")) mock_get_store.return_value = mock_store await gateway.chat( messages=[{"role": "user", "content": "hi"}], model=_MODEL, user_id="u1", kb_id="kb1", ) cache_arg = _get_cache_arg(provider) assert cache_arg.get("no-cache") is True, ( f"fail-closed: 读取异常应禁用缓存,但 got {cache_arg}" ) async def test_settings_none_fail_closed(self): """settings 返回 None(KB 不存在)→ fail-closed(no-cache)。""" gateway = _make_gateway_with_cache() provider = _register_mock_provider(gateway) with patch("agentkit.rag_platform.settings.get_settings_store") as mock_get_store: mock_store = MagicMock() mock_store.get_settings = AsyncMock(return_value=None) mock_get_store.return_value = mock_store await gateway.chat( messages=[{"role": "user", "content": "hi"}], model=_MODEL, user_id="u1", kb_id="kb_nonexistent", ) cache_arg = _get_cache_arg(provider) assert cache_arg.get("no-cache") is True, ( f"Expected no-cache for None settings, got {cache_arg}" ) async def test_no_kb_id_skips_settings_lookup(self): """kb_id=None(非 RAG 请求)→ 不查 settings,缓存正常启用。""" gateway = _make_gateway_with_cache() provider = _register_mock_provider(gateway) with patch("agentkit.rag_platform.settings.get_settings_store") as mock_get_store: mock_store = MagicMock() mock_store.get_settings = AsyncMock() mock_get_store.return_value = mock_store await gateway.chat( messages=[{"role": "user", "content": "hi"}], model=_MODEL, user_id="u1", # kb_id 不传 ) # 不应查询 settings mock_store.get_settings.assert_not_called() # 缓存应启用(cache_key 传入) cache_arg = _get_cache_arg(provider) assert "cache_key" in cache_arg, f"Expected cache_key (no kb_id), got {cache_arg}"