Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
//! 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");
|
||||
}
|
||||
Reference in New Issue
Block a user