Merge pull request 'test(resource/broker): 补齐 scoped MCP 合成 PR 端到端回归' (#21) from codex/resource-broker-daily-dev into master
All checks were successful
Push & PR Check / check (push) Successful in 1m18s
All checks were successful
Push & PR Check / check (push) Successful in 1m18s
This commit is contained in:
commit
feb030431e
@ -494,8 +494,8 @@ modules:
|
|||||||
area: infra
|
area: infra
|
||||||
status: done
|
status: done
|
||||||
progress: 100
|
progress: 100
|
||||||
updated: 2026-09-23
|
updated: 2026-09-24
|
||||||
note: "R1–R8 按已确认设计逐卡实施;仅受保护本机凭证与有界 Gitea 代办,不接管生产部署凭证。"
|
note: "R1–R10 的合成路径已验证,含 scoped MCP 到模拟 Gitea 的 PR 全链路;不接管真实生产密钥或部署。"
|
||||||
position: [3000, 950]
|
position: [3000, 950]
|
||||||
|
|
||||||
edges:
|
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
|
### ✅ R9 — PR 审查修复:缓存失效、凭证删除与 readiness
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: M
|
- complexity: M
|
||||||
@ -140,6 +148,7 @@ progress: 100
|
|||||||
- R3:Windows Credential Manager 随机测试引用跨进程读取并清理;模拟存储的写、读、覆盖、删除与失败注入通过。
|
- R3:Windows Credential Manager 随机测试引用跨进程读取并清理;模拟存储的写、读、覆盖、删除与失败注入通过。
|
||||||
- R4–R6:旧值迁移、存储/数据库失败保留旧值、SQLite 迁移前备份及恢复演练、新建/更新/删除回归通过;模拟 Gitea PR 与 webhook 注册均只访问 `127.0.0.1`。
|
- R4–R6:旧值迁移、存储/数据库失败保留旧值、SQLite 迁移前备份及恢复演练、新建/更新/删除回归通过;模拟 Gitea PR 与 webhook 注册均只访问 `127.0.0.1`。
|
||||||
- R7–R8:内存 Enterprise fixture 经真实 loopback MCP HTTP/SSE 完成 `initialize → tools/list → tools/call`,readiness 不含凭证,跨项目参数被拒绝。
|
- 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/部署;现有备份若不含当前旧值会拒绝迁移并要求人工安排新备份,不会降级明文。
|
- 未验证:真实用户 DB/凭证迁移、真实 Gitea/CI/服务器、正式安装版 UI 人工终验和真实 PR/部署;现有备份若不含当前旧值会拒绝迁移并要求人工安排新备份,不会降级明文。
|
||||||
|
|
||||||
## 阶段退出标准
|
## 阶段退出标准
|
||||||
|
|||||||
@ -445,6 +445,179 @@ mod tests {
|
|||||||
server.abort();
|
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]
|
#[tokio::test]
|
||||||
async fn readiness_probe_does_not_satisfy_client_reload_evidence() {
|
async fn readiness_probe_does_not_satisfy_client_reload_evidence() {
|
||||||
let project_id = "reload-evidence-fixture";
|
let project_id = "reload-evidence-fixture";
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user