test(resource/broker): 补齐 scoped MCP 合成 PR 端到端回归
All checks were successful
Push & PR Check / check (pull_request) Successful in 1m7s
All checks were successful
Push & PR Check / check (pull_request) Successful in 1m7s
Rules-Applied: R03,R13
This commit is contained in:
parent
af0adef4f2
commit
5f23cf85b1
@ -494,8 +494,8 @@ modules:
|
||||
area: infra
|
||||
status: done
|
||||
progress: 100
|
||||
updated: 2026-09-23
|
||||
note: "R1–R8 按已确认设计逐卡实施;仅受保护本机凭证与有界 Gitea 代办,不接管生产部署凭证。"
|
||||
updated: 2026-09-24
|
||||
note: "R1–R10 的合成路径已验证,含 scoped MCP 到模拟 Gitea 的 PR 全链路;不接管真实生产密钥或部署。"
|
||||
position: [3000, 950]
|
||||
|
||||
edges:
|
||||
|
||||
@ -68,6 +68,14 @@ progress: 100
|
||||
|
||||
## 任务拆解
|
||||
|
||||
### ✅ R10 — scoped MCP 合成 PR 端到端回归
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_server.rs
|
||||
- depends: R4, R7, R8
|
||||
- acceptance: "agent_verify: [cargo test] scoped_mcp_create_pull_request_uses_synthetic_credential → 真实 127.0.0.1 MCP initialize/tools/list/tools/call 经项目作用域到模拟 Gitea;请求使用合成受保护凭据,结果仅含 PR 标识、不含 token,跨项目参数拒绝;测试后合成凭据引用被清理。[CLI] cargo test、git diff --check 通过。human_verify: 无(仅合成测试,不接触真实仓库)。"
|
||||
- retrospective: "【复盘】scoped MCP 合成 PR 端到端回归|任务类型: MCP工具|复杂度: M|实际轮数: 2轮(预估 2 轮)|是否返工: 是|返工原因: 初版模拟服务无接入超时且清理守卫建立偏晚|有效策略: 内存数据库 + 随机 Windows 凭据引用 + 127.0.0.1 mock,复用真实 MCP 路由并在 Drop 中清理|遗留风险: 该串联测试仅在 Windows 执行;真实 Gitea 试点仍需单独授权|auto_applied_rules: R13|rule_effectiveness: R13=有效"
|
||||
|
||||
### ✅ R9 — PR 审查修复:缓存失效、凭证删除与 readiness
|
||||
- status: done
|
||||
- complexity: M
|
||||
@ -140,6 +148,7 @@ progress: 100
|
||||
- R3:Windows Credential Manager 随机测试引用跨进程读取并清理;模拟存储的写、读、覆盖、删除与失败注入通过。
|
||||
- R4–R6:旧值迁移、存储/数据库失败保留旧值、SQLite 迁移前备份及恢复演练、新建/更新/删除回归通过;模拟 Gitea PR 与 webhook 注册均只访问 `127.0.0.1`。
|
||||
- R7–R8:内存 Enterprise fixture 经真实 loopback MCP HTTP/SSE 完成 `initialize → tools/list → tools/call`,readiness 不含凭证,跨项目参数被拒绝。
|
||||
- R10:Windows 合成项目经真实 scoped MCP `initialize → tools/list → tools/call(create_pull_request)` 到 `127.0.0.1` 模拟 Gitea;代办内部使用受保护存储的合成 token,Agent 结果仅含 PR 信息,跨项目参数被拒绝,测试后凭据引用清理;全量 Rust 测试通过(211 passed、1 ignored)。
|
||||
- 未验证:真实用户 DB/凭证迁移、真实 Gitea/CI/服务器、正式安装版 UI 人工终验和真实 PR/部署;现有备份若不含当前旧值会拒绝迁移并要求人工安排新备份,不会降级明文。
|
||||
|
||||
## 阶段退出标准
|
||||
|
||||
@ -445,6 +445,179 @@ mod tests {
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn scoped_mcp_create_pull_request_uses_synthetic_credential() {
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
|
||||
struct SyntheticInstanceGuard(Option<String>);
|
||||
impl Drop for SyntheticInstanceGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(instance_id) = self.0.take() {
|
||||
let _ = crate::commands::gitea::gitea_delete_instance(instance_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db::init_test_pool();
|
||||
let project_id = format!("synthetic-broker-{}", uuid::Uuid::new_v4());
|
||||
let profile_id = format!("{project_id}-profile");
|
||||
let token = "qa-only-synthetic-mcp-token";
|
||||
let remote = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let remote_address = remote.local_addr().unwrap();
|
||||
let instance = crate::commands::gitea::gitea_save_instance(
|
||||
crate::commands::gitea::SaveGiteaInstanceInput {
|
||||
name: "synthetic MCP PR".into(),
|
||||
url: format!("http://{remote_address}"),
|
||||
access_token: token.into(),
|
||||
is_default: Some(false),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut instance_guard = SyntheticInstanceGuard(Some(instance.id.clone()));
|
||||
let reference = {
|
||||
let conn = db::pool().get().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT access_token_ref FROM gitea_instances WHERE id = ?1",
|
||||
[&instance.id],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
{
|
||||
let conn = db::pool().get().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_profiles (id, name) VALUES (?1, 'Synthetic MCP PR')",
|
||||
[&profile_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_workspaces (id, profile_id) VALUES (?1, ?2)",
|
||||
rusqlite::params![project_id, profile_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name) VALUES (?1, ?2, ?3, 'owner', 'repo')",
|
||||
rusqlite::params![uuid::Uuid::new_v4().to_string(), instance.id, project_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(mcp_bind_addr(0))
|
||||
.await
|
||||
.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let sessions: Sessions = Arc::new(Mutex::new(HashMap::new()));
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, mcp_router(sessions)).await.unwrap();
|
||||
});
|
||||
let url = format!("http://127.0.0.1:{port}/mcp/projects/{project_id}");
|
||||
let call = |method: &str, params: Value| -> Value {
|
||||
ureq::post(&url)
|
||||
.send_json(json!({"jsonrpc":"2.0","id":1,"method":method,"params":params}))
|
||||
.unwrap()
|
||||
.into_json()
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let init = call("initialize", json!({}));
|
||||
assert_eq!(init["result"]["lianJing"]["project_id"], project_id);
|
||||
let catalog = call("tools/list", json!({}));
|
||||
assert!(catalog["result"]["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "create_pull_request"));
|
||||
let readiness = call(
|
||||
"tools/call",
|
||||
json!({"name":"get_project_resource_status","arguments":{}}),
|
||||
);
|
||||
assert_eq!(
|
||||
readiness["result"]["structuredContent"]["gitea_pr"]["state"],
|
||||
"configured"
|
||||
);
|
||||
assert!(!readiness.to_string().contains(token));
|
||||
let denied = call(
|
||||
"tools/call",
|
||||
json!({"name":"create_pull_request","arguments":{"project_id":"other-project","head":"feature","base":"main","title":"synthetic"}}),
|
||||
);
|
||||
assert_eq!(
|
||||
denied["result"]["structuredContent"]["code"],
|
||||
"scope_violation"
|
||||
);
|
||||
|
||||
remote.set_nonblocking(true).unwrap();
|
||||
let mock = std::thread::spawn(move || {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
let (mut stream, _) = loop {
|
||||
match remote.accept() {
|
||||
Ok(accepted) => break accepted,
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::WouldBlock
|
||||
&& std::time::Instant::now() < deadline =>
|
||||
{
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
Err(error) => panic!("模拟 Gitea 未收到请求: {error}"),
|
||||
}
|
||||
};
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
assert!(line.starts_with("POST /api/v1/repos/owner/repo/pulls "));
|
||||
let mut authorized = false;
|
||||
let mut content_length = 0;
|
||||
loop {
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
if line == "\r\n" || line.is_empty() {
|
||||
break;
|
||||
}
|
||||
if line.eq_ignore_ascii_case("authorization: token qa-only-synthetic-mcp-token\r\n")
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
|
||||
content_length = value.trim().parse().unwrap();
|
||||
}
|
||||
}
|
||||
assert!(authorized, "模拟 Gitea 未收到受保护存储中的合成 token");
|
||||
let mut body = vec![0; content_length];
|
||||
reader.read_exact(&mut body).unwrap();
|
||||
let request: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(request["head"], "feature");
|
||||
let response =
|
||||
r#"{"number":42,"html_url":"https://example.invalid/pr/42","title":"synthetic"}"#;
|
||||
write!(stream, "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", response.len(), response).unwrap();
|
||||
});
|
||||
let created = call(
|
||||
"tools/call",
|
||||
json!({"name":"create_pull_request","arguments":{"head":"feature","base":"main","title":"synthetic"}}),
|
||||
);
|
||||
mock.join().unwrap();
|
||||
assert_ne!(created["result"]["isError"], true);
|
||||
let result = created["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(result).unwrap()["pr_number"],
|
||||
42
|
||||
);
|
||||
assert!(!created.to_string().contains(token));
|
||||
assert!(!created.to_string().contains("access_token"));
|
||||
server.abort();
|
||||
|
||||
crate::commands::gitea::gitea_delete_instance(instance.id).unwrap();
|
||||
instance_guard.0 = None;
|
||||
assert!(crate::credential_store::CredentialStore::get(
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
&reference,
|
||||
)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_probe_does_not_satisfy_client_reload_evidence() {
|
||||
let project_id = "reload-evidence-fixture";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user