f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
323 lines
15 KiB
Rust
323 lines
15 KiB
Rust
//! End-to-end smoke test for the Remote capability front door (`/mcp`).
|
|
//!
|
|
//! Proves the integration P0 delivers: the MCP endpoint is mounted in the FULL
|
|
//! app router, is gated by the per-companion access token, and projects the
|
|
//! gateway Registry's Remote surface. MCP protocol correctness itself is covered
|
|
//! by rmcp's own tests; here we verify wiring + auth + the surface projection.
|
|
//!
|
|
//! The full rmcp Parts→companion_id round-trip (resolved companion_id flowing
|
|
//! through the MCP `tools/call` dispatch) is proven below by
|
|
//! `mcp_tools_call_binds_companion`, which drives a real Streamable-HTTP
|
|
//! handshake (initialize → notifications/initialized → tools/call for
|
|
//! `nomi_whoami`) and asserts the resolved companion_id appears in the JSON-RPC
|
|
//! result. The REST test additionally proves the same binding via the `/v1`
|
|
//! adapter (same dispatch + CallerCtx path).
|
|
|
|
mod common;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode, header};
|
|
use tower::ServiceExt;
|
|
|
|
use nomifun_gateway::{Registry, Surface};
|
|
|
|
/// `/mcp` is mounted in the full app and rejects callers without a valid
|
|
/// per-companion token; a minted token passes the gate and reaches the MCP service.
|
|
#[tokio::test]
|
|
async fn mcp_endpoint_is_mounted_and_token_gated() {
|
|
let (app, services) = common::build_app().await;
|
|
|
|
let init_body = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": "2025-03-26",
|
|
"capabilities": {},
|
|
"clientInfo": { "name": "smoke", "version": "1.0" }
|
|
}
|
|
});
|
|
let make_req = |token: Option<&str>| {
|
|
let mut b = Request::builder()
|
|
.method("POST")
|
|
.uri("/mcp")
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.header(header::ACCEPT, "application/json, text/event-stream");
|
|
if let Some(t) = token {
|
|
b = b.header(header::AUTHORIZATION, format!("Bearer {t}"));
|
|
}
|
|
b.body(Body::from(serde_json::to_vec(&init_body).unwrap())).unwrap()
|
|
};
|
|
|
|
let companion_id = "smoke-companion";
|
|
let token = "smoke-companion-token";
|
|
|
|
// No token → 401 (the front door is closed before reaching the MCP service).
|
|
let resp = app.clone().oneshot(make_req(None)).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "/mcp must reject missing token");
|
|
|
|
// Wrong token → 401.
|
|
let resp = app.clone().oneshot(make_req(Some("not-the-token"))).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "/mcp must reject a bad token");
|
|
|
|
// Mint a per-companion token via the shared validator (same Arc the router
|
|
// holds) and the request now passes the gate and reaches the MCP service (NOT 401).
|
|
services
|
|
.companion_token_validator
|
|
.insert_token(companion_id.to_string(), nomifun_auth::token_sha256_hex(token));
|
|
let resp = app.clone().oneshot(make_req(Some(token))).await.unwrap();
|
|
assert_ne!(
|
|
resp.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"a valid per-companion token must pass the gate (got {})",
|
|
resp.status()
|
|
);
|
|
|
|
// Revocation closes it again.
|
|
services.companion_token_validator.remove_token(companion_id);
|
|
let resp = app.oneshot(make_req(Some(token))).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "revoked token must be rejected");
|
|
}
|
|
|
|
/// LOAD-BEARING companion-binding proof over the real MCP transport: drive a
|
|
/// full Streamable-HTTP handshake through the mounted `/mcp` service
|
|
/// (initialize → notifications/initialized → tools/call for `nomi_whoami`) and
|
|
/// assert the resolved companion_id ("smoke-companion") appears in the JSON-RPC
|
|
/// result. This converts the source-verified rmcp `Parts`→`RemoteCompanion`→
|
|
/// `CallerCtx.companion_id` path into a permanent regression guard: if an
|
|
/// rmcp/transport change ever broke the `http::request::Parts` downcast in
|
|
/// `handler.rs::call_tool`, this test would catch it (the result would show a
|
|
/// null companion_id instead of "smoke-companion").
|
|
#[tokio::test]
|
|
async fn mcp_tools_call_binds_companion() {
|
|
let (app, services) = common::build_app().await;
|
|
|
|
let companion_id = "smoke-companion";
|
|
let token = "smoke-companion-token";
|
|
services
|
|
.companion_token_validator
|
|
.insert_token(companion_id.to_string(), nomifun_auth::token_sha256_hex(token));
|
|
|
|
// rmcp Streamable-HTTP requires the POST Accept header to advertise BOTH
|
|
// application/json and text/event-stream; responses come back as SSE.
|
|
let post = |session_id: Option<&str>, body: serde_json::Value| {
|
|
let mut b = Request::builder()
|
|
.method("POST")
|
|
.uri("/mcp")
|
|
.header(header::HOST, "127.0.0.1")
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.header(header::ACCEPT, "application/json, text/event-stream")
|
|
.header(header::AUTHORIZATION, format!("Bearer {token}"));
|
|
if let Some(sid) = session_id {
|
|
b = b.header("mcp-session-id", sid);
|
|
}
|
|
b.body(Body::from(serde_json::to_vec(&body).unwrap())).unwrap()
|
|
};
|
|
|
|
// Read the whole (terminating) SSE body and pull the JSON-RPC payload out of
|
|
// the first non-empty `data:` line. The stream is prefixed with an SSE
|
|
// "priming" event whose data is empty (used for client reconnection), so we
|
|
// skip empty payloads. The transport closes the request-wise stream once the
|
|
// response is delivered, so `to_bytes` returns.
|
|
async fn read_sse_json(resp: axum::response::Response) -> serde_json::Value {
|
|
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
|
let text = String::from_utf8_lossy(&body);
|
|
let data = text
|
|
.lines()
|
|
.filter_map(|l| l.strip_prefix("data:").map(str::trim))
|
|
.find(|d| !d.is_empty())
|
|
.unwrap_or_else(|| panic!("SSE body had no non-empty data: line; got: {text}"));
|
|
serde_json::from_str(data).unwrap_or_else(|e| panic!("data line not JSON ({e}): {data}"))
|
|
}
|
|
|
|
// 1) initialize → captures the Mcp-Session-Id response header.
|
|
let init = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": "2025-03-26",
|
|
"capabilities": {},
|
|
"clientInfo": { "name": "smoke", "version": "1.0" }
|
|
}
|
|
});
|
|
let resp = app.clone().oneshot(post(None, init)).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK, "initialize should succeed");
|
|
let session_id = resp
|
|
.headers()
|
|
.get("mcp-session-id")
|
|
.and_then(|v| v.to_str().ok())
|
|
.expect("initialize must return an Mcp-Session-Id header")
|
|
.to_string();
|
|
// Drain the initialize SSE response so the session worker advances.
|
|
let init_result = read_sse_json(resp).await;
|
|
assert_eq!(init_result["id"], 1, "initialize response echoes id 1");
|
|
|
|
// 2) notifications/initialized → 202 Accepted (no body of interest).
|
|
let initialized = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"method": "notifications/initialized"
|
|
});
|
|
let resp = app.clone().oneshot(post(Some(&session_id), initialized)).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::ACCEPTED, "initialized notification should be accepted");
|
|
|
|
// 3) tools/call nomi_whoami → the result must echo the bound companion_id.
|
|
let call = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 2,
|
|
"method": "tools/call",
|
|
"params": { "name": "nomi_whoami", "arguments": {} }
|
|
});
|
|
let resp = app.clone().oneshot(post(Some(&session_id), call)).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK, "tools/call should succeed");
|
|
let rpc = read_sse_json(resp).await;
|
|
let payload = serde_json::to_string(&rpc).unwrap();
|
|
assert!(
|
|
payload.contains(companion_id),
|
|
"nomi_whoami result over /mcp must echo the bound companion_id '{companion_id}'; \
|
|
this proves Parts→RemoteCompanion→CallerCtx.companion_id reached MCP dispatch. Got: {payload}"
|
|
);
|
|
}
|
|
|
|
/// The REST /v1 adapter is mounted in the full app, token-gated, and serves the
|
|
/// registry-generated catalog + OpenAPI.
|
|
#[tokio::test]
|
|
async fn rest_v1_endpoint_is_mounted_and_gated() {
|
|
let (app, services) = common::build_app().await;
|
|
|
|
// No token → 401 on a /v1 call.
|
|
let no_tok = Request::builder().method("GET").uri("/v1/tools").body(Body::empty()).unwrap();
|
|
let resp = app.clone().oneshot(no_tok).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "/v1 must reject missing token");
|
|
|
|
let companion_id = "smoke-companion";
|
|
let token = "smoke-companion-token";
|
|
services
|
|
.companion_token_validator
|
|
.insert_token(companion_id.to_string(), nomifun_auth::token_sha256_hex(token));
|
|
|
|
let with_tok = |method: &str, uri: &str| {
|
|
Request::builder()
|
|
.method(method)
|
|
.uri(uri)
|
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
|
.body(Body::empty())
|
|
.unwrap()
|
|
};
|
|
|
|
// GET /v1/tools → 200 + non-empty catalog.
|
|
let resp = app.clone().oneshot(with_tok("GET", "/v1/tools")).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
let full_count = v["tools"].as_array().map(|a| a.len()).unwrap_or(0);
|
|
assert!(full_count > 0, "catalog must be non-empty");
|
|
|
|
// P5: ?profile=agent → a strictly narrower curated catalog.
|
|
let resp = app.clone().oneshot(with_tok("GET", "/v1/tools?profile=agent")).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
let agent_count = v["tools"].as_array().map(|a| a.len()).unwrap_or(0);
|
|
assert!(agent_count > 0 && agent_count < full_count, "agent profile must be a non-empty strict subset");
|
|
|
|
// GET /v1/openapi.json → 200 + an OpenAPI doc.
|
|
let resp = app.clone().oneshot(with_tok("GET", "/v1/openapi.json")).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(v["openapi"], "3.1.0");
|
|
assert!(v["paths"].as_object().map(|p| !p.is_empty()).unwrap_or(false));
|
|
|
|
// POST a read-tool with the token → 200 (passes the gate, dispatches).
|
|
let call = Request::builder()
|
|
.method("POST")
|
|
.uri("/v1/tools/nomi_list_conversations")
|
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap();
|
|
let resp = app.clone().oneshot(call).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK, "read tool call should succeed (got {})", resp.status());
|
|
|
|
// LOAD-BEARING companion-binding proof: the per-companion token resolves to
|
|
// `smoke-companion`, and that companion_id must reach dispatch. `nomi_whoami`
|
|
// (Read cap) echoes the resolved companion_id back, so the response body must
|
|
// contain "smoke-companion" — proving the token → companion_id → CallerCtx →
|
|
// tool dispatch round-trip end-to-end through the mounted /v1 adapter.
|
|
let whoami = Request::builder()
|
|
.method("POST")
|
|
.uri("/v1/tools/nomi_whoami")
|
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap();
|
|
let resp = app.oneshot(whoami).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK, "nomi_whoami call should succeed (got {})", resp.status());
|
|
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
|
let text = String::from_utf8_lossy(&body);
|
|
assert!(
|
|
text.contains(companion_id),
|
|
"nomi_whoami response must echo the resolved companion_id '{companion_id}' (got: {text})"
|
|
);
|
|
}
|
|
|
|
/// The SSE streaming endpoint dispatches through the registry and terminates
|
|
/// with a `__result__` frame (verified here with a non-streaming tool; the
|
|
/// streaming path is exercised the same way, emitting deltas before it).
|
|
#[tokio::test]
|
|
async fn rest_v1_stream_endpoint_emits_result_frame() {
|
|
let (app, services) = common::build_app().await;
|
|
let token = "smoke-companion-token";
|
|
services
|
|
.companion_token_validator
|
|
.insert_token("smoke-companion".to_string(), nomifun_auth::token_sha256_hex(token));
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/v1/tools/nomi_list_conversations/stream")
|
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let ct = resp.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("");
|
|
assert!(ct.starts_with("text/event-stream"), "must be an SSE stream, got {ct}");
|
|
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
|
let text = String::from_utf8_lossy(&body);
|
|
assert!(text.contains("__result__"), "SSE stream must end with a __result__ frame; got: {text}");
|
|
}
|
|
|
|
/// The Remote surface projects a non-empty, correctly-gated subset of the
|
|
/// registry: Destructive tools are visible (Confirm) but Channel hides them,
|
|
/// and Remote is a subset of the all-permissive Desktop surface.
|
|
#[test]
|
|
fn remote_surface_projection_is_correct() {
|
|
let remote: Vec<&str> = Registry::global().tool_specs(Surface::Remote).iter().map(|s| s.name).collect();
|
|
let desktop: Vec<&str> = Registry::global().tool_specs(Surface::Desktop).iter().map(|s| s.name).collect();
|
|
let channel: Vec<&str> = Registry::global().tool_specs(Surface::Channel).iter().map(|s| s.name).collect();
|
|
|
|
assert!(!remote.is_empty(), "Remote surface must expose tools");
|
|
|
|
// P1: the headline agent-delegation caps are exposed to external callers.
|
|
assert!(remote.contains(&"nomi_agent_run"), "nomi_agent_run must be on the Remote surface");
|
|
assert!(remote.contains(&"nomi_agent_result"), "nomi_agent_result must be on the Remote surface");
|
|
|
|
// Remote ⊆ Desktop (Desktop is the most permissive surface).
|
|
for name in &remote {
|
|
assert!(desktop.contains(name), "Remote tool '{name}' must also be visible on Desktop");
|
|
}
|
|
|
|
// A Destructive tool: listed on Remote (Confirm) and Desktop, hidden on Channel (Deny).
|
|
assert!(desktop.contains(&"nomi_delete_conversation"));
|
|
assert!(
|
|
remote.contains(&"nomi_delete_conversation"),
|
|
"Destructive tools are Confirm (visible) on the Remote surface"
|
|
);
|
|
assert!(
|
|
!channel.contains(&"nomi_delete_conversation"),
|
|
"Destructive tools are hard-denied (hidden) on the Channel surface"
|
|
);
|
|
}
|