Compare commits

..

2 Commits

Author SHA1 Message Date
feb030431e 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
2026-09-24 08:31:22 +08:00
lanrtop
5f23cf85b1 test(resource/broker): 补齐 scoped MCP 合成 PR 端到端回归
All checks were successful
Push & PR Check / check (pull_request) Successful in 1m7s
Rules-Applied: R03,R13
2026-09-24 08:22:38 +09:00
3 changed files with 184 additions and 2 deletions

View File

@ -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:

View File

@ -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/部署;现有备份若不含当前旧值会拒绝迁移并要求人工安排新备份,不会降级明文。
## 阶段退出标准 ## 阶段退出标准

View File

@ -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";