f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
304 lines
10 KiB
Rust
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");
|
|
}
|