修复: 全库走查 P0+P1(df-mcp 数据丢失/evaluate 拆 + df-execute probe_pwsh 死缓存/超时/单源 + ai_self_review 注入隔离)

- df-mcp P0: update_idea/project/task 缺省回退 existing(治部分更新清空 title 丢数据)+ P1: evaluate_idea 拆只读(Low)+score_idea(Medium 写),read-only 不再改库
- df-execute P0: probe_pwsh 死缓存(两 OnceLock 合并 PWSH_CACHE 单源,治 Windows 永走 PS5)+ P1: probe_pwsh 3s 超时防挂起 + detect_shell 复用 shell.rs 单源(探测与执行对齐)
- df-nodes P1: ai_self_review prompt 注入隔离(truncate + XML 标签 <task_output> 数据/指令隔离 + system 声明)
- generate_image: SSRF 集成测试 + b64 OOM 估算纯函数测试(走查测试增强)
This commit is contained in:
lxy
2026-08-02 03:11:44 +08:00
parent dffc4e4851
commit fc249adf17
7 changed files with 1000 additions and 82 deletions
+207
View File
@@ -0,0 +1,207 @@
//! update_idea/update_project/update_task 部分更新回归测试
//!
//! 回归 P0 bug:LLM 客户端做部分更新(只传 description 不传 title)时,
//! 旧实现用 `arg_str_or(args, "title", "")` 取值,缺省 → 空串覆盖 existing
//! → title 被静默清空 → 数据丢失。
//!
//! 根本修:title/description(name)缺省回退 existing,而非空默认覆盖。
//! 本测试覆盖三个 handler 的「只传一个字段,另一字段保留 existing」语义。
use df_mcp::tools::{find, Ctx};
use df_storage::db::Database;
use serde_json::{json, Value};
/// 从 CallToolResult 取首个 text 块解析为 JSON。
fn result_json(res: &df_mcp::protocol::CallToolResult) -> Value {
assert!(
res.is_error != Some(true),
"工具调用失败(is_error=true): {:?}",
res.content
);
match res.content.first() {
Some(df_mcp::protocol::ContentBlock::Text { text }) => {
serde_json::from_str(text).expect("响应非合法 JSON")
}
other => panic!("预期 Text 块,实际: {other:?}"),
}
}
/// 取嵌套对象 record(title/name/description 等业务字段在其下)。
fn record_of(v: &Value, key: &str) -> Value {
v.get(key)
.cloned()
.unwrap_or_else(|| panic!("响应缺 `{key}` 字段: {v}"))
}
async fn setup() -> Ctx {
let db = Database::open_in_memory().await.expect("open_in_memory");
Ctx::new(std::sync::Arc::new(db))
}
async fn call(ctx: &Ctx, name: &str, args: Value) -> Value {
let spec = find(name).expect("工具已注册");
let res = (spec.handler)(ctx, args).await;
result_json(&res)
}
// ============================================================
// update_idea:只传 description,title 必须保留 existing
// ============================================================
#[tokio::test]
async fn update_idea_keeps_title_when_only_description_sent() {
let ctx = setup().await;
// 先建一条想法:title="原始标题"
let created = call(
&ctx,
"create_idea",
json!({ "title": "原始标题", "description": "原始描述" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
// LLM 只传 description(不传 title)—— 旧实现会把 title 清空为 ""
let updated = call(
&ctx,
"update_idea",
json!({ "id": id, "description": "新描述" }),
)
.await;
let idea = record_of(&updated, "idea");
assert_eq!(idea["title"].as_str(), Some("原始标题"), "title 应保留 existing,不被空默认清空");
assert_eq!(idea["description"].as_str(), Some("新描述"), "description 应更新为新值");
}
#[tokio::test]
async fn update_idea_keeps_description_when_only_title_sent() {
let ctx = setup().await;
let created = call(
&ctx,
"create_idea",
json!({ "title": "原标题", "description": "原描述" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
let updated = call(&ctx, "update_idea", json!({ "id": id, "title": "新标题" })).await;
let idea = record_of(&updated, "idea");
assert_eq!(idea["title"].as_str(), Some("新标题"));
assert_eq!(idea["description"].as_str(), Some("原描述"), "description 应保留 existing");
}
// ============================================================
// update_project:只传 description,name 必须保留 existing
// ============================================================
#[tokio::test]
async fn update_project_keeps_name_when_only_description_sent() {
let ctx = setup().await;
let created = call(
&ctx,
"create_project",
json!({ "name": "原始项目", "description": "原始描述" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
let updated = call(
&ctx,
"update_project",
json!({ "id": id, "description": "新描述" }),
)
.await;
let project = record_of(&updated, "project");
assert_eq!(project["name"].as_str(), Some("原始项目"), "name 应保留 existing");
assert_eq!(project["description"].as_str(), Some("新描述"));
// path/stack/idea_id 未传也应保留(existing 创建时为 None,这里间接保证不被改)
assert_eq!(project["path"].as_str(), None);
}
#[tokio::test]
async fn update_project_keeps_status_when_not_sent() {
// 状态字段缺省同样应保留 existing(旧实现默认 "planning" 会重置状态)
let ctx = setup().await;
let created = call(
&ctx,
"create_project",
json!({ "name": "P", "description": "D", "status": "in_progress" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
let updated = call(
&ctx,
"update_project",
json!({ "id": id, "description": "改描述" }),
)
.await;
let project = record_of(&updated, "project");
assert_eq!(
project["status"].as_str(),
Some("in_progress"),
"status 应保留 existing,不被默认 planning 重置"
);
}
// ============================================================
// update_task:只传 description,title/project_id 必须保留 existing
// ============================================================
#[tokio::test]
async fn update_task_keeps_title_and_project_when_only_description_sent() {
let ctx = setup().await;
let proj = call(
&ctx,
"create_project",
json!({ "name": "所属项目", "description": "d" }),
)
.await;
let project_id = proj["id"].as_str().expect("project id").to_owned();
let created = call(
&ctx,
"create_task",
json!({ "project_id": project_id, "title": "原始任务标题", "description": "原始描述" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
// 只传 description:旧实现 title 是必填会报错,description 缺省会清空(行为不一)
// 根本修后三者都应保留 existing(或更新为新值)
let updated = call(
&ctx,
"update_task",
json!({ "id": id, "description": "新描述" }),
)
.await;
let task = record_of(&updated, "task");
assert_eq!(task["title"].as_str(), Some("原始任务标题"), "title 应保留 existing");
assert_eq!(task["description"].as_str(), Some("新描述"));
assert_eq!(task["project_id"].as_str(), Some(project_id.as_str()), "project_id 应保留 existing");
// status 走状态机,update_task 不改也应保留
assert_eq!(task["status"].as_str(), Some("todo"));
}
#[tokio::test]
async fn update_task_keeps_description_when_only_title_sent() {
let ctx = setup().await;
let proj = call(
&ctx,
"create_project",
json!({ "name": "P2", "description": "d" }),
)
.await;
let project_id = proj["id"].as_str().expect("project id").to_owned();
let created = call(
&ctx,
"create_task",
json!({ "project_id": project_id, "title": "原标题", "description": "原描述" }),
)
.await;
let id = created["id"].as_str().expect("id").to_owned();
let updated = call(&ctx, "update_task", json!({ "id": id, "title": "新标题" })).await;
let task = record_of(&updated, "task");
assert_eq!(task["title"].as_str(), Some("新标题"));
assert_eq!(task["description"].as_str(), Some("原描述"), "description 应保留 existing");
}