Files
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

304 lines
10 KiB
Rust

//! Integration tests for McpConnectionTestService.
//!
//! Tests from test-plan §2 (Connection Test):
//! - CT-3: Command not found (ENOENT)
//! - CT-4: URL not reachable
//! - CT-5: Needs OAuth authentication (401)
//! - CT-6: Timeout
//! - SSE auth probe (M-33 coverage)
use std::collections::HashMap;
use std::time::Duration;
use nomifun_mcp::McpConnectionTestService;
use nomifun_mcp::McpServerTransport;
fn make_service() -> McpConnectionTestService {
McpConnectionTestService::new(reqwest::Client::new())
}
fn make_service_with_timeout(timeout: Duration) -> McpConnectionTestService {
McpConnectionTestService::new(reqwest::Client::new()).with_timeout(timeout)
}
// ---------------------------------------------------------------------------
// CT-3: Command not found (ENOENT)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn stdio_nonexistent_command_returns_not_found_error() {
let svc = make_service();
let transport = McpServerTransport::Stdio {
command: "nonexistent-mcp-cmd-xyz-12345".into(),
args: vec![],
env: HashMap::new(),
};
let result = svc.test_connection("test-server", &transport).await;
assert!(!result.success);
let error = result.error.as_deref().unwrap();
assert!(
error.contains("Command not found"),
"expected 'Command not found' in: {error}"
);
assert!(result.tools.is_none());
assert!(result.needs_auth.is_none());
}
// ---------------------------------------------------------------------------
// CT-4: URL not reachable
// ---------------------------------------------------------------------------
#[tokio::test]
async fn http_unreachable_url_returns_connection_error() {
let svc = make_service_with_timeout(Duration::from_secs(5));
let transport = McpServerTransport::Http {
url: "http://127.0.0.1:1/mcp-unreachable".into(),
headers: HashMap::new(),
};
let result = svc.test_connection("test-http", &transport).await;
assert!(!result.success);
let error = result.error.as_deref().unwrap();
assert!(
error.contains("Connection failed"),
"expected connection failure in: {error}"
);
}
#[tokio::test]
async fn sse_unreachable_url_returns_connection_error() {
let svc = make_service_with_timeout(Duration::from_secs(5));
let transport = McpServerTransport::Sse {
url: "http://127.0.0.1:1/sse-unreachable".into(),
headers: HashMap::new(),
};
let result = svc.test_connection("test-sse", &transport).await;
assert!(!result.success);
let error = result.error.as_deref().unwrap();
assert!(
error.contains("Connection failed"),
"expected connection failure in: {error}"
);
}
// ---------------------------------------------------------------------------
// CT-5: HTTP 401 Unauthorized -> needsAuth
// ---------------------------------------------------------------------------
#[tokio::test]
async fn http_401_returns_needs_auth() {
// Spin up a mock server that returns 401 with WWW-Authenticate
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server_handle = tokio::spawn(async move {
let app = axum::Router::new().route(
"/mcp",
axum::routing::post(|| async {
(
axum::http::StatusCode::UNAUTHORIZED,
[(axum::http::header::WWW_AUTHENTICATE, "Bearer realm=\"mcp-server\"")],
"",
)
}),
);
axum::serve(listener, app).await.unwrap();
});
let svc = make_service();
let transport = McpServerTransport::Http {
url: format!("http://{}/mcp", addr),
headers: HashMap::new(),
};
let result = svc.test_connection("auth-server", &transport).await;
assert!(!result.success);
assert_eq!(result.needs_auth, Some(true));
assert!(result.auth_method.is_some());
assert!(result.www_authenticate.is_some());
assert!(result.error.is_none());
server_handle.abort();
}
#[tokio::test]
async fn sse_401_returns_needs_auth() {
// Spin up a mock server that returns 401 for GET
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server_handle = tokio::spawn(async move {
let app = axum::Router::new().route(
"/sse",
axum::routing::get(|| async {
(
axum::http::StatusCode::UNAUTHORIZED,
[(axum::http::header::WWW_AUTHENTICATE, "Bearer realm=\"mcp-sse\"")],
"",
)
}),
);
axum::serve(listener, app).await.unwrap();
});
let svc = make_service();
let transport = McpServerTransport::Sse {
url: format!("http://{}/sse", addr),
headers: HashMap::new(),
};
let result = svc.test_connection("sse-auth", &transport).await;
assert!(!result.success);
assert_eq!(result.needs_auth, Some(true));
assert!(result.www_authenticate.is_some());
server_handle.abort();
}
// ---------------------------------------------------------------------------
// CT-6: Timeout
// ---------------------------------------------------------------------------
#[tokio::test]
async fn stdio_timeout_returns_timeout_error() {
// Use `sleep` which produces no stdout — our protocol read will block
let svc = make_service_with_timeout(Duration::from_secs(1));
let transport = McpServerTransport::Stdio {
command: "sleep".into(),
args: vec!["60".into()],
env: HashMap::new(),
};
let result = svc.test_connection("timeout-server", &transport).await;
assert!(!result.success);
let error = result.error.as_deref().unwrap();
assert!(error.contains("timed out"), "expected timeout in: {error}");
}
// ---------------------------------------------------------------------------
// HTTP non-success status
// ---------------------------------------------------------------------------
#[tokio::test]
async fn http_500_returns_error_with_status() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server_handle = tokio::spawn(async move {
let app = axum::Router::new().route(
"/mcp",
axum::routing::post(|| async { axum::http::StatusCode::INTERNAL_SERVER_ERROR }),
);
axum::serve(listener, app).await.unwrap();
});
let svc = make_service();
let transport = McpServerTransport::Http {
url: format!("http://{}/mcp", addr),
headers: HashMap::new(),
};
let result = svc.test_connection("error-server", &transport).await;
assert!(!result.success);
let error = result.error.as_deref().unwrap();
assert!(error.contains("500"), "expected HTTP 500 in: {error}");
server_handle.abort();
}
// ---------------------------------------------------------------------------
// HTTP transport with custom headers
// ---------------------------------------------------------------------------
#[tokio::test]
async fn http_custom_headers_are_sent() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server_handle = tokio::spawn(async move {
let app = axum::Router::new().route(
"/mcp",
axum::routing::post(|headers: axum::http::HeaderMap| async move {
// Verify the custom header was received
if headers.get("x-api-key").and_then(|v| v.to_str().ok()) == Some("secret") {
// Return a valid initialize response
axum::Json(serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"serverInfo": { "name": "test", "version": "1.0" }
}
}))
} else {
// Return error if header missing
axum::Json(serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -1, "message": "Missing API key" }
}))
}
}),
);
axum::serve(listener, app).await.unwrap();
});
let svc = make_service();
let mut headers = HashMap::new();
headers.insert("X-Api-Key".into(), "secret".into());
let transport = McpServerTransport::Http {
url: format!("http://{}/mcp", addr),
headers,
};
let result = svc.test_connection("header-server", &transport).await;
// The server returns a valid initialize response for request id=1,
// but the subsequent tools/list (id=2) will also hit the same handler.
// Either way, the first request should succeed (no initialize error).
// The tools/list might succeed or fail depending on how the mock handles id=2.
// For this test, we just verify the custom header was sent (no "Missing API key" error).
if let Some(ref error) = result.error {
assert!(
!error.contains("Missing API key"),
"Custom header should have been sent"
);
}
server_handle.abort();
}
// ---------------------------------------------------------------------------
// Stdio with args and env
// ---------------------------------------------------------------------------
#[tokio::test]
async fn stdio_with_args_spawns_correctly() {
// Use echo as a simple command that exits immediately
// Since echo doesn't speak MCP, we expect a protocol error (not a spawn error)
let svc = make_service_with_timeout(Duration::from_secs(3));
let transport = McpServerTransport::Stdio {
command: "echo".into(),
args: vec!["hello".into()],
env: HashMap::new(),
};
let result = svc.test_connection("echo-server", &transport).await;
// echo outputs "hello\n" then exits — not valid JSON-RPC
assert!(!result.success);
let error = result.error.as_deref().unwrap();
// Should be a protocol error, not a spawn error
assert!(!error.contains("Command not found"), "echo should be found");
}