test: 补充权限控制和用户隔离测试

- 新增 context_auth 权限控制单元测试
- 更新 chat_router 和 knowledge_router 集成测试
- 适配 agent_run_service 和 workspace_service 测试
This commit is contained in:
Wenjie Zhang 2026-05-18 09:41:24 +08:00
parent 96abe655fc
commit 7db54079a0
8 changed files with 234 additions and 22 deletions

View File

@ -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)

View File

@ -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):

View File

@ -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}}

View File

@ -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))

View File

@ -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)

View File

@ -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:

View File

@ -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:

View File

@ -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"))