test: 补充权限控制和用户隔离测试
- 新增 context_auth 权限控制单元测试 - 更新 chat_router 和 knowledge_router 集成测试 - 适配 agent_run_service 和 workspace_service 测试
This commit is contained in:
parent
96abe655fc
commit
7db54079a0
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
76
backend/test/unit/agents/test_context_auth.py
Normal file
76
backend/test/unit/agents/test_context_auth.py
Normal 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}}
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"))
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user