ForcePilot/backend/test/unit/services/test_mention_search_service.py
supreme0597 6b29f659e5 feat: 修复 @ mention 搜索漏洞并重构优化 AgentPanel 自动打开策略
1. 修复 mention_router 中的 IDOR 越权安全漏洞,加入 thread 归属权校验,定义强类型 Pydantic Schema。
2. 修复 mention_search_service 中 ensure_thread_dirs 的 GET 副作用、空 Query 扫描和 MAX_SEARCH_DEPTH 深度问题,使用标准 Base64 编解码。
3. 增加前端 mention_api.js 参数的安全兜底。
4. 重构 AgentPanel 自动展开策略,彻底解决大仓库克隆时的卡死问题:
   - 将 fetchThreadFiles 改为 recursive=false 避免递归大目录。
   - 重构为只有在发生显性的物理文件写入(用户上传附件成功、用户保存交付件到工作区成功)的回调中才精准触发侧边栏自动展开,完美契合用户心智模型与性能诉求。
5. 精简 roadmap.md 文档,更新相关单元测试。
2026-05-25 21:11:41 +08:00

213 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import base64
from pathlib import Path
import pytest
import ormsgpack
import yuxi.services.mention_search_service as mention_service
class _FakeRedis:
def __init__(self):
self.data: dict[str, str] = {}
self.expire_calls: dict[str, int] = {}
self.delete_calls: list[str] = []
async def get(self, key: str) -> str | None:
return self.data.get(key)
async def set(self, key: str, value: str, ex: int | None = None) -> None:
self.data[key] = value
if ex is not None:
self.expire_calls[key] = ex
async def delete(self, key: str) -> None:
self.delete_calls.append(key)
self.data.pop(key, None)
@pytest.fixture
def mock_sandbox_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
# 创建模拟的工作区、上传、输出目录
workspace_dir = tmp_path / "shared" / "user_1" / "workspace"
uploads_dir = tmp_path / "threads" / "thread_1" / "user-data" / "uploads"
outputs_dir = tmp_path / "threads" / "thread_1" / "user-data" / "outputs"
workspace_dir.mkdir(parents=True, exist_ok=True)
uploads_dir.mkdir(parents=True, exist_ok=True)
outputs_dir.mkdir(parents=True, exist_ok=True)
# Mock sandbox_paths 的函数
monkeypatch.setattr(mention_service, "sandbox_workspace_dir", lambda t, u: workspace_dir)
monkeypatch.setattr(mention_service, "sandbox_uploads_dir", lambda t: uploads_dir)
monkeypatch.setattr(mention_service, "sandbox_outputs_dir", lambda t: outputs_dir)
return {
"workspace": workspace_dir,
"uploads": uploads_dir,
"outputs": outputs_dir,
}
@pytest.fixture
def fake_redis(monkeypatch: pytest.MonkeyPatch) -> _FakeRedis:
redis = _FakeRedis()
async def mock_get_redis():
return redis
monkeypatch.setattr(mention_service, "get_redis_client", mock_get_redis)
return redis
@pytest.mark.asyncio
async def test_scan_pruned_files_and_exclude_dirs(mock_sandbox_paths):
workspace = mock_sandbox_paths["workspace"]
# 创建常规文件
(workspace / "main.py").write_text("print('hello')")
(workspace / "utils.py").write_text("def run(): pass")
# 创建被排除的目录和文件
git_dir = workspace / ".git"
git_dir.mkdir()
(git_dir / "config").write_text("[core]")
node_modules = workspace / "node_modules"
node_modules.mkdir()
(node_modules / "express.js").write_text("module.exports = {}")
# 扫描
results = mention_service._scan_pruned_files(workspace, 100)
# 校验
files = {name for name, _ in results}
assert "main.py" in files
assert "utils.py" in files
assert "config" not in files
assert "express.js" not in files
@pytest.mark.asyncio
async def test_scan_depth_protection(mock_sandbox_paths):
workspace = mock_sandbox_paths["workspace"]
# 创建超深的文件树路径:超过 15 层
deep_dir = workspace
for i in range(18):
deep_dir = deep_dir / f"dir_{i}"
deep_dir.mkdir(parents=True, exist_ok=True)
(deep_dir / "deep_file.py").write_text("deep")
# 扫描
results = mention_service._scan_pruned_files(workspace, 100)
files = {name for name, _ in results}
# 深度限制应成功剪枝拦截该超深文件
assert "deep_file.py" not in files
@pytest.mark.asyncio
async def test_scan_width_limit(mock_sandbox_paths):
workspace = mock_sandbox_paths["workspace"]
# 创建 600 个扁平的小文件
for i in range(600):
(workspace / f"file_{i}.py").write_text(str(i))
# 扫描,设置 max_entries = 1000看看单目录 500 的宽度限额是否起作用)
results = mention_service._scan_pruned_files(workspace, 1000)
# 限制单目录 MAX_ENTRIES_PER_DIR = 500 熔断
assert len(results) == 500
@pytest.mark.asyncio
async def test_mention_cache_lifecycle_with_ormsgpack(mock_sandbox_paths, fake_redis):
workspace = mock_sandbox_paths["workspace"]
uploads = mock_sandbox_paths["uploads"]
(workspace / "main.py").write_text("main")
(uploads / "data.csv").write_text("csv")
# 1. 首次查询:构建缓存并存入 Redis
index_1 = await mention_service.get_or_build_file_index("thread_1", "user_1")
assert len(index_1) == 2
# 验证 Redis 中已存有缓存
redis_key = f"{mention_service.REDIS_KEY_PREFIX}thread_1"
cached_str = fake_redis.data.get(redis_key)
assert cached_str is not None
# 反序列化校验
packed_bytes = base64.b64decode(cached_str)
decoded_entries = ormsgpack.unpackb(packed_bytes)
assert len(decoded_entries) == 2
# 2. 修改磁盘文件,但在 TTL 内应仍然走 Redis 缓存,内容不更新
(workspace / "new_file.py").write_text("new")
index_2 = await mention_service.get_or_build_file_index("thread_1", "user_1")
assert len(index_2) == 2 # 仍然命中缓存,没有扫描出 new_file.py
# 3. 清理缓存后重新读取,应成功更新磁盘扫描内容
await mention_service.invalidate_mention_cache("thread_1")
assert fake_redis.data.get(redis_key) is None
index_3 = await mention_service.get_or_build_file_index("thread_1", "user_1")
assert len(index_3) == 3
assert any(name == "new_file.py" for name, _ in index_3)
@pytest.mark.asyncio
async def test_search_mention_files_in_index(mock_sandbox_paths, fake_redis):
workspace = mock_sandbox_paths["workspace"]
(workspace / "agent_config.json").write_text("config")
(workspace / "main.py").write_text("main")
# 搜索匹配测试
results = await mention_service.search_mention_files_in_index("thread_1", "user_1", "config")
assert len(results) == 1
assert results[0]["name"] == "agent_config.json"
assert results[0]["path"] == "/home/gem/user-data/workspace/agent_config.json"
assert results[0]["is_dir"] is False
# 大小写不敏感匹配
results_case = await mention_service.search_mention_files_in_index("thread_1", "user_1", "MAIN")
assert len(results_case) == 1
assert results_case[0]["name"] == "main.py"
@pytest.mark.asyncio
async def test_search_mention_directories_and_weighted_ranking(mock_sandbox_paths, fake_redis):
workspace = mock_sandbox_paths["workspace"]
# 1. 创建合格的子目录 "test"
test_dir = workspace / "test"
test_dir.mkdir(exist_ok=True)
# 2. 在子目录下创建一些包含关键字的文件
(test_dir / "test_auth.py").write_text("auth")
(test_dir / "conftest.py").write_text("conf") # 文件名不含 test但路径含 test
# 3. 搜索 "@test"
results = await mention_service.search_mention_files_in_index("thread_1", "user_1", "test")
# 4. 校验结果
# 必须包含 3 个项:目录 "test/",文件 "test_auth.py",文件 "conftest.py" (路径匹配兜底)
assert len(results) == 3
# 5. 校验置顶排序和 is_dir 属性
# 由于目录名字 "test" 与搜索词 "test" 100% 完全一致,得分为最高 (1000分),必须排在第 1 位
assert results[0]["name"] == "test"
assert results[0]["is_dir"] is True
assert results[0]["path"] == "/home/gem/user-data/workspace/test/"
# "test_auth.py" 文件名以 "test" 开头,为前缀匹配 (500分),必须排在第 2 位
assert results[1]["name"] == "test_auth.py"
assert results[1]["is_dir"] is False
# "conftest.py" 文件名不含 test为纯路径匹配兜底 (10分),必须排在最后
assert results[2]["name"] == "conftest.py"
assert results[2]["is_dir"] is False