From 7db54079a05e1576b6ab8b324e80bab00c30bea2 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Mon, 18 May 2026 09:41:24 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=E6=9D=83=E9=99=90?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=92=8C=E7=94=A8=E6=88=B7=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 context_auth 权限控制单元测试 - 更新 chat_router 和 knowledge_router 集成测试 - 适配 agent_run_service 和 workspace_service 测试 --- .../test/integration/api/test_chat_router.py | 71 +++++++++++++++++ .../integration/api/test_knowledge_router.py | 12 ++- backend/test/unit/agents/test_context_auth.py | 76 +++++++++++++++++++ backend/test/unit/plugins/test_dify_kb.py | 35 +++++++++ .../unit/services/test_agent_run_service.py | 23 +++++- .../test_chat_service_langfuse_stream.py | 6 +- .../unit/services/test_chat_service_sync.py | 6 +- .../unit/services/test_workspace_service.py | 27 ++++--- 8 files changed, 234 insertions(+), 22 deletions(-) create mode 100644 backend/test/unit/agents/test_context_auth.py diff --git a/backend/test/integration/api/test_chat_router.py b/backend/test/integration/api/test_chat_router.py index d7d11e16..36e9acae 100644 --- a/backend/test/integration/api/test_chat_router.py +++ b/backend/test/integration/api/test_chat_router.py @@ -59,6 +59,77 @@ async def test_admin_can_read_default_agent(test_client, admin_headers): assert "default_agent_id" in response.json() +async def test_standard_user_manages_own_agent_config_with_role_filtered_fields( + test_client, + admin_headers, + standard_user, +): + agents_response = await test_client.get("/api/chat/agent", headers=standard_user["headers"]) + assert agents_response.status_code == 200, agents_response.text + agents = agents_response.json().get("agents", []) + if not agents: + pytest.skip("No agents are registered in the system.") + + agent_id = agents[0]["id"] + + user_agent_response = await test_client.get(f"/api/chat/agent/{agent_id}", headers=standard_user["headers"]) + assert user_agent_response.status_code == 200, user_agent_response.text + user_items = user_agent_response.json().get("configurable_items", {}) + assert "summary_threshold" not in user_items + + admin_agent_response = await test_client.get(f"/api/chat/agent/{agent_id}", headers=admin_headers) + assert admin_agent_response.status_code == 200, admin_agent_response.text + admin_items = admin_agent_response.json().get("configurable_items", {}) + assert "summary_threshold" in admin_items + + config_name = f"pytest-config-{uuid.uuid4().hex[:8]}" + create_response = await test_client.post( + f"/api/chat/agent/{agent_id}/configs", + json={ + "name": config_name, + "config_json": {"context": {"system_prompt": "user visible", "summary_threshold": 1}}, + "set_default": True, + }, + headers=standard_user["headers"], + ) + assert create_response.status_code == 200, create_response.text + created = create_response.json()["config"] + config_id = created["id"] + assert created["uid"] == standard_user["user"]["uid"] + assert created["is_default"] is True + assert created["config_json"]["context"]["system_prompt"] == "user visible" + assert "summary_threshold" not in created["config_json"]["context"] + + try: + admin_list_response = await test_client.get(f"/api/chat/agent/{agent_id}/configs", headers=admin_headers) + assert admin_list_response.status_code == 200, admin_list_response.text + admin_config_ids = {item["id"] for item in admin_list_response.json().get("configs", [])} + assert config_id not in admin_config_ids + + update_response = await test_client.put( + f"/api/chat/agent/{agent_id}/configs/{config_id}", + json={"config_json": {"context": {"system_prompt": "updated", "summary_threshold": 2}}}, + headers=standard_user["headers"], + ) + assert update_response.status_code == 200, update_response.text + updated_context = update_response.json()["config"]["config_json"]["context"] + assert updated_context == {"system_prompt": "updated"} + + default_response = await test_client.post( + f"/api/chat/agent/{agent_id}/configs/{config_id}/set_default", + json={}, + headers=standard_user["headers"], + ) + assert default_response.status_code == 200, default_response.text + assert default_response.json()["config"]["is_default"] is True + finally: + delete_response = await test_client.delete( + f"/api/chat/agent/{agent_id}/configs/{config_id}", + headers=standard_user["headers"], + ) + assert delete_response.status_code in (200, 404), delete_response.text + + async def test_setting_default_agent_requires_admin(test_client, admin_headers, standard_user): # Attempt as admin first to obtain a candidate agent id. agents_response = await test_client.get("/api/chat/agent", headers=admin_headers) diff --git a/backend/test/integration/api/test_knowledge_router.py b/backend/test/integration/api/test_knowledge_router.py index bb2940d0..89f7cd1f 100644 --- a/backend/test/integration/api/test_knowledge_router.py +++ b/backend/test/integration/api/test_knowledge_router.py @@ -201,7 +201,10 @@ async def test_create_dify_database_success(test_client, admin_headers): create_response = await test_client.post("/api/knowledge/databases", json=payload, headers=admin_headers) assert create_response.status_code == 200, create_response.text - db_id = create_response.json()["db_id"] + created_payload = create_response.json() + db_id = created_payload["db_id"] + assert created_payload["embedding_model_spec"] is None + assert "chunk_preset_id" not in created_payload["metadata"] info_response = await test_client.get(f"/api/knowledge/databases/{db_id}", headers=admin_headers) assert info_response.status_code == 200, info_response.text @@ -399,6 +402,13 @@ async def test_get_knowledge_base_types(test_client, admin_headers): payload = response.json() assert payload["message"] == "success" assert "kb_types" in payload + assert payload["kb_types"]["dify"]["requires_embedding_model"] is False + assert payload["kb_types"]["dify"]["supports_documents"] is False + assert [option["key"] for option in payload["kb_types"]["dify"]["create_params"]["options"]] == [ + "dify_api_url", + "dify_token", + "dify_dataset_id", + ] async def test_get_knowledge_base_statistics(test_client, admin_headers): diff --git a/backend/test/unit/agents/test_context_auth.py b/backend/test/unit/agents/test_context_auth.py new file mode 100644 index 00000000..73a317c7 --- /dev/null +++ b/backend/test/unit/agents/test_context_auth.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import importlib.util +import sys +import types +from dataclasses import dataclass, field +from pathlib import Path + + +def _load_context_module(): + context_path = Path(__file__).resolve().parents[3] / "package/yuxi/agents/context.py" + previous_yuxi = sys.modules.get("yuxi") + sys.modules["yuxi"] = types.SimpleNamespace(config=types.SimpleNamespace(default_model="test:model")) + try: + spec = importlib.util.spec_from_file_location("test_yuxi_agents_context", context_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + finally: + if previous_yuxi is None: + sys.modules.pop("yuxi", None) + else: + sys.modules["yuxi"] = previous_yuxi + + +context_module = _load_context_module() +BaseContext = context_module.BaseContext +filter_config_by_role = context_module.filter_config_by_role + + +@dataclass +class SuperAdminOnlyContext(BaseContext): + secret_setting: str = field(default="hidden", metadata={"name": "Secret", "auth": "superadmin"}) + + +def test_get_configurable_items_filters_admin_fields_for_user(): + items = BaseContext.get_configurable_items(user_role="user") + + assert "system_prompt" in items + assert "summary_threshold" not in items + + +def test_get_configurable_items_allows_admin_and_superadmin_fields(): + admin_items = BaseContext.get_configurable_items(user_role="admin") + superadmin_items = SuperAdminOnlyContext.get_configurable_items(user_role="superadmin") + + assert "summary_threshold" in admin_items + assert "secret_setting" in superadmin_items + + +def test_filter_config_by_role_removes_unauthorized_context_values(): + config_json = { + "context": { + "system_prompt": "visible", + "summary_threshold": 10, + "secret_setting": "nope", + }, + "other": {"keep": True}, + } + + filtered = filter_config_by_role(config_json, "user", context_schema=SuperAdminOnlyContext) + + assert filtered == {"context": {"system_prompt": "visible"}, "other": {"keep": True}} + assert config_json["context"]["summary_threshold"] == 10 + + +def test_filter_config_by_role_keeps_admin_context_values_for_admin(): + filtered = filter_config_by_role( + {"context": {"summary_threshold": 10, "secret_setting": "nope"}}, + "admin", + context_schema=SuperAdminOnlyContext, + ) + + assert filtered == {"context": {"summary_threshold": 10}} diff --git a/backend/test/unit/plugins/test_dify_kb.py b/backend/test/unit/plugins/test_dify_kb.py index 4a6493d6..36d03395 100644 --- a/backend/test/unit/plugins/test_dify_kb.py +++ b/backend/test/unit/plugins/test_dify_kb.py @@ -41,6 +41,41 @@ class _FakeAsyncClient: return _FakeResponse(self._response_payload) +def test_dify_create_params_config_and_validation(): + config = DifyKB.get_create_params_config() + keys = [option["key"] for option in config["options"]] + assert keys == ["dify_api_url", "dify_token", "dify_dataset_id"] + assert all(option["required"] for option in config["options"]) + + params = DifyKB.normalize_additional_params( + { + "dify_api_url": " https://api.dify.ai/v1 ", + "dify_token": " token ", + "dify_dataset_id": " dataset-123 ", + } + ) + assert params == { + "dify_api_url": "https://api.dify.ai/v1", + "dify_token": "token", + "dify_dataset_id": "dataset-123", + } + assert "chunk_preset_id" not in params + + +def test_dify_validation_rejects_missing_or_invalid_params(): + with pytest.raises(ValueError, match="Dify 参数缺失"): + DifyKB.normalize_additional_params({"dify_api_url": "https://api.dify.ai/v1"}) + + with pytest.raises(ValueError, match="必须以 /v1 结尾"): + DifyKB.normalize_additional_params( + { + "dify_api_url": "https://api.dify.ai", + "dify_token": "token", + "dify_dataset_id": "dataset-123", + } + ) + + @pytest.mark.asyncio async def test_dify_kb_aquery_maps_records(monkeypatch, tmp_path): kb = DifyKB(str(tmp_path)) diff --git a/backend/test/unit/services/test_agent_run_service.py b/backend/test/unit/services/test_agent_run_service.py index bb8e1408..7dce295e 100644 --- a/backend/test/unit/services/test_agent_run_service.py +++ b/backend/test/unit/services/test_agent_run_service.py @@ -14,7 +14,7 @@ class FakeConfigRepo: self.db = db_session async def get_by_id(self, config_id: int): - return SimpleNamespace(id=config_id, agent_id="ChatbotAgent", department_id=1) + return SimpleNamespace(id=config_id, agent_id="ChatbotAgent", uid="user-1") @pytest.mark.asyncio @@ -142,7 +142,12 @@ async def test_create_agent_run_commits_before_enqueue(monkeypatch: pytest.Monke async def get_conversation_by_thread_id(self, thread_id: str): del thread_id - return SimpleNamespace(uid="user-1", status="active", department_id=1, extra_metadata={"agent_config_id": 1}) + return SimpleNamespace( + uid="user-1", + status="active", + department_id=1, + extra_metadata={"agent_config_id": 1}, + ) class Queue: async def enqueue_job(self, job_name: str, run_id: str, _job_id: str): @@ -227,7 +232,12 @@ async def test_create_agent_run_handles_integrity_error_with_same_user_existing( async def get_conversation_by_thread_id(self, thread_id: str): del thread_id - return SimpleNamespace(uid="user-1", status="active", department_id=1, extra_metadata={"agent_config_id": 1}) + return SimpleNamespace( + uid="user-1", + status="active", + department_id=1, + extra_metadata={"agent_config_id": 1}, + ) async def fake_get_arq_pool(): raise AssertionError("should not enqueue on integrity fallback") @@ -300,7 +310,12 @@ async def test_create_agent_run_integrity_error_returns_409_for_other_user(monke async def get_conversation_by_thread_id(self, thread_id: str): del thread_id - return SimpleNamespace(uid="user-1", status="active", department_id=1, extra_metadata={"agent_config_id": 1}) + return SimpleNamespace( + uid="user-1", + status="active", + department_id=1, + extra_metadata={"agent_config_id": 1}, + ) monkeypatch.setattr(agent_run_service.agent_manager, "get_agent", lambda agent_id: object()) monkeypatch.setattr(agent_run_service, "AgentConfigRepository", FakeConfigRepo) diff --git a/backend/test/unit/services/test_chat_service_langfuse_stream.py b/backend/test/unit/services/test_chat_service_langfuse_stream.py index bc49cb0e..b4e7c060 100644 --- a/backend/test/unit/services/test_chat_service_langfuse_stream.py +++ b/backend/test/unit/services/test_chat_service_langfuse_stream.py @@ -14,10 +14,10 @@ class _FakeAgentConfigRepo: pass async def get_by_id(self, config_id: int): - return SimpleNamespace(id=config_id) + return SimpleNamespace(id=config_id, uid="user-1", agent_id="test-agent") - async def get_or_create_default(self, *, department_id: str, agent_id: str, created_by: str): - return SimpleNamespace(id=999, department_id=department_id, agent_id=agent_id, created_by=created_by) + async def get_or_create_default(self, *, uid: str, agent_id: str, created_by: str): + return SimpleNamespace(id=999, uid=uid, agent_id=agent_id, created_by=created_by) class _FakeConvRepo: diff --git a/backend/test/unit/services/test_chat_service_sync.py b/backend/test/unit/services/test_chat_service_sync.py index d59234eb..3532e1e3 100644 --- a/backend/test/unit/services/test_chat_service_sync.py +++ b/backend/test/unit/services/test_chat_service_sync.py @@ -17,10 +17,10 @@ class _FakeAgentConfigRepo: pass async def get_by_id(self, config_id: int): - return SimpleNamespace(id=config_id) + return SimpleNamespace(id=config_id, uid="user-1", agent_id="test-agent") - async def get_or_create_default(self, *, department_id: str, agent_id: str, created_by: str): - return SimpleNamespace(id=999, department_id=department_id, agent_id=agent_id, created_by=created_by) + async def get_or_create_default(self, *, uid: str, agent_id: str, created_by: str): + return SimpleNamespace(id=999, uid=uid, agent_id=agent_id, created_by=created_by) class _FakeConvRepo: diff --git a/backend/test/unit/services/test_workspace_service.py b/backend/test/unit/services/test_workspace_service.py index 55de5bfd..dd1ebfca 100644 --- a/backend/test/unit/services/test_workspace_service.py +++ b/backend/test/unit/services/test_workspace_service.py @@ -11,12 +11,17 @@ from yuxi.agents.backends.sandbox import paths as workspace_paths from yuxi.services import workspace_service as svc +def _user() -> SimpleNamespace: + return SimpleNamespace(id="db-id-1", uid="user-1") + + def test_workspace_root_creates_default_agents_prompt_file(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) - root = svc._workspace_root(SimpleNamespace(id="user-1")) + root = svc._workspace_root(_user()) agents_file = root / "agents" / "AGENTS.md" + assert root == tmp_path / "threads" / "shared" / "user-1" / "workspace" assert agents_file.is_file() assert agents_file.read_text(encoding="utf-8") == "" @@ -38,7 +43,7 @@ def test_workspace_root_keeps_existing_agents_prompt_file(tmp_path: Path, monkey agents_file = agents_dir / "AGENTS.md" agents_file.write_text("保留已有内容", encoding="utf-8") - root = svc._workspace_root(SimpleNamespace(id="user-1")) + root = svc._workspace_root(_user()) assert root == tmp_path / "threads" / "shared" / "user-1" / "workspace" assert agents_file.read_text(encoding="utf-8") == "保留已有内容" @@ -53,7 +58,7 @@ def test_workspace_root_rejects_symlink_root(tmp_path: Path, monkeypatch) -> Non (user_root / "workspace").symlink_to(outside_root, target_is_directory=True) with pytest.raises(HTTPException) as exc_info: - svc._workspace_root(SimpleNamespace(id="user-1")) + svc._workspace_root(_user()) assert exc_info.value.status_code == 403 @@ -64,7 +69,7 @@ async def test_read_workspace_file_content_returns_unsupported_for_non_utf8_text monkeypatch, ) -> None: monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) - user = SimpleNamespace(id="user-1") + user = _user() root = svc._workspace_root(user) target = root / "bad.txt" target.write_bytes(b"\xff\xfe\x00") @@ -79,7 +84,7 @@ async def test_read_workspace_file_content_returns_unsupported_for_non_utf8_text @pytest.mark.asyncio async def test_write_workspace_file_content_updates_markdown_file(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) - user = SimpleNamespace(id="user-1") + user = _user() root = svc._workspace_root(user) target = root / "note.md" target.write_text("旧内容", encoding="utf-8") @@ -95,7 +100,7 @@ async def test_write_workspace_file_content_updates_markdown_file(tmp_path: Path @pytest.mark.asyncio async def test_write_workspace_file_content_updates_txt_file(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) - user = SimpleNamespace(id="user-1") + user = _user() root = svc._workspace_root(user) target = root / "note.txt" target.write_text("old", encoding="utf-8") @@ -108,7 +113,7 @@ async def test_write_workspace_file_content_updates_txt_file(tmp_path: Path, mon @pytest.mark.asyncio async def test_write_workspace_file_content_rejects_unsupported_suffix(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) - user = SimpleNamespace(id="user-1") + user = _user() root = svc._workspace_root(user) target = root / "script.py" target.write_text("print('hello')", encoding="utf-8") @@ -123,7 +128,7 @@ async def test_write_workspace_file_content_rejects_unsupported_suffix(tmp_path: @pytest.mark.asyncio async def test_write_workspace_file_content_rejects_directory_and_missing_file(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) - user = SimpleNamespace(id="user-1") + user = _user() svc._workspace_root(user) with pytest.raises(HTTPException) as directory_error: @@ -143,7 +148,7 @@ async def test_write_workspace_file_content_blocks_path_traversal(tmp_path: Path await svc.write_workspace_file_content( path="/../outside.md", content="x", - current_user=SimpleNamespace(id="user-1"), + current_user=_user(), ) assert exc_info.value.status_code == 403 @@ -152,7 +157,7 @@ async def test_write_workspace_file_content_blocks_path_traversal(tmp_path: Path @pytest.mark.asyncio async def test_upload_workspace_file_writes_file(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) - user = SimpleNamespace(id="user-1") + user = _user() root = svc._workspace_root(user) upload = UploadFile(filename="demo.txt", file=BytesIO(b"hello")) @@ -171,7 +176,7 @@ async def test_upload_workspace_file_rejects_oversized_file_and_cleans_partial_f ) -> None: monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) monkeypatch.setattr(svc, "MAX_WORKSPACE_UPLOAD_SIZE_BYTES", 5) - user = SimpleNamespace(id="user-1") + user = _user() root = svc._workspace_root(user) upload = UploadFile(filename="large.txt", file=BytesIO(b"123456"))