huskies: merge 1180 story Sled↔gateway goes WS-only: remove the deprecated HTTP fallback paths
This commit is contained in:
@@ -18,8 +18,7 @@ use std::sync::Arc;
|
||||
pub use crate::service::gateway::{
|
||||
GatewayConfig, GatewayState as GatewayStateType, GatewayStatusEvent, ProjectEntry,
|
||||
broadcast_status_event, fetch_all_project_pipeline_statuses, format_aggregate_status_compact,
|
||||
spawn_gateway_broadcaster_forwarder, spawn_gateway_notification_poller,
|
||||
subscribe_status_events,
|
||||
spawn_gateway_broadcaster_forwarder, subscribe_status_events,
|
||||
};
|
||||
|
||||
/// Build the complete gateway route tree.
|
||||
|
||||
+167
-415
@@ -96,219 +96,6 @@ async fn generate_token_creates_pending_token() {
|
||||
assert!(tokens.contains_key(token));
|
||||
}
|
||||
|
||||
// ── Notification poller integration tests ────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_notification_poller_continues_when_one_project_unreachable() {
|
||||
use crate::chat::{ChatTransport, MessageId};
|
||||
use crate::service::events::StoredEvent;
|
||||
use async_trait::async_trait;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
type CallLog = Arc<std::sync::Mutex<Vec<String>>>;
|
||||
|
||||
struct MockTransport {
|
||||
calls: CallLog,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatTransport for MockTransport {
|
||||
async fn send_message(
|
||||
&self,
|
||||
_room_id: &str,
|
||||
plain: &str,
|
||||
_html: &str,
|
||||
) -> Result<MessageId, String> {
|
||||
self.calls.lock().unwrap().push(plain.to_string());
|
||||
Ok("id".to_string())
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
_room_id: &str,
|
||||
_id: &str,
|
||||
_plain: &str,
|
||||
_html: &str,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let transport = Arc::new(MockTransport {
|
||||
calls: Arc::clone(&calls),
|
||||
});
|
||||
|
||||
let event = vec![StoredEvent::StoryBlocked {
|
||||
story_id: "10_story_ok".to_string(),
|
||||
story_name: String::new(),
|
||||
reason: "retry limit".to_string(),
|
||||
timestamp_ms: 500,
|
||||
}];
|
||||
let event_body = serde_json::to_vec(&event).unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let good_port = listener.local_addr().unwrap().port();
|
||||
let good_url = format!("http://127.0.0.1:{good_port}");
|
||||
tokio::spawn(async move {
|
||||
for _ in 0..4 {
|
||||
if let Ok((mut stream, _)) = listener.accept().await {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
let header = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
event_body.len()
|
||||
);
|
||||
let _ = stream.write_all(header.as_bytes()).await;
|
||||
let _ = stream.write_all(&event_body).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
let bad_url = "http://127.0.0.1:1".to_string();
|
||||
|
||||
let mut project_urls = BTreeMap::new();
|
||||
project_urls.insert("good-project".to_string(), good_url);
|
||||
project_urls.insert("unreachable-project".to_string(), bad_url);
|
||||
|
||||
gateway::spawn_gateway_notification_poller(
|
||||
transport as Arc<dyn crate::chat::ChatTransport>,
|
||||
vec!["!room:example.org".to_string()],
|
||||
project_urls,
|
||||
1,
|
||||
);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
|
||||
let messages = calls.lock().unwrap();
|
||||
assert!(
|
||||
!messages.is_empty(),
|
||||
"Expected notifications from the reachable project; got none"
|
||||
);
|
||||
let has_good = messages
|
||||
.iter()
|
||||
.any(|m| m.contains("[good-project]") && m.contains("#10"));
|
||||
assert!(
|
||||
has_good,
|
||||
"Expected a notification from [good-project]; got: {messages:?}"
|
||||
);
|
||||
let has_bad = messages.iter().any(|m| m.contains("[unreachable-project]"));
|
||||
assert!(
|
||||
!has_bad,
|
||||
"Unreachable project must not produce notifications; got: {messages:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_notification_poller_sends_only_to_configured_gateway_rooms() {
|
||||
use crate::chat::{ChatTransport, MessageId};
|
||||
use crate::service::events::StoredEvent;
|
||||
use async_trait::async_trait;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
type RoomLog = Arc<std::sync::Mutex<Vec<String>>>;
|
||||
|
||||
struct RoomCapture {
|
||||
rooms: RoomLog,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatTransport for RoomCapture {
|
||||
async fn send_message(
|
||||
&self,
|
||||
room_id: &str,
|
||||
_plain: &str,
|
||||
_html: &str,
|
||||
) -> Result<MessageId, String> {
|
||||
self.rooms.lock().unwrap().push(room_id.to_string());
|
||||
Ok("id".to_string())
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
_room_id: &str,
|
||||
_id: &str,
|
||||
_plain: &str,
|
||||
_html: &str,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let rooms: RoomLog = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let transport = Arc::new(RoomCapture {
|
||||
rooms: Arc::clone(&rooms),
|
||||
});
|
||||
|
||||
let event = vec![StoredEvent::MergeFailure {
|
||||
story_id: "5_story_x".to_string(),
|
||||
story_name: String::new(),
|
||||
reason: "conflict".to_string(),
|
||||
timestamp_ms: 300,
|
||||
}];
|
||||
let event_body = serde_json::to_vec(&event).unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let url = format!("http://127.0.0.1:{port}");
|
||||
tokio::spawn(async move {
|
||||
for _ in 0..4 {
|
||||
if let Ok((mut stream, _)) = listener.accept().await {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
let header = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
event_body.len()
|
||||
);
|
||||
let _ = stream.write_all(header.as_bytes()).await;
|
||||
let _ = stream.write_all(&event_body).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
const GATEWAY_ROOM: &str = "!gateway:example.org";
|
||||
#[allow(dead_code)]
|
||||
const PER_PROJECT_ROOM: &str = "!project:example.org";
|
||||
|
||||
let mut project_urls = BTreeMap::new();
|
||||
project_urls.insert("myproj".to_string(), url);
|
||||
|
||||
gateway::spawn_gateway_notification_poller(
|
||||
transport as Arc<dyn crate::chat::ChatTransport>,
|
||||
vec![GATEWAY_ROOM.to_string()],
|
||||
project_urls,
|
||||
1,
|
||||
);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
|
||||
let room_calls = rooms.lock().unwrap();
|
||||
assert!(
|
||||
!room_calls.is_empty(),
|
||||
"Expected at least one notification; got none"
|
||||
);
|
||||
for room in room_calls.iter() {
|
||||
assert_eq!(
|
||||
room, GATEWAY_ROOM,
|
||||
"Notification must only go to the gateway room, not {room}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!room_calls.iter().any(|r| r == PER_PROJECT_ROOM),
|
||||
"Per-project room must not receive gateway aggregated notifications"
|
||||
);
|
||||
}
|
||||
|
||||
// ── init_project integration tests ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
@@ -413,42 +200,25 @@ async fn init_project_duplicate_name_returns_error() {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
/// story 1180: MCP proxying is now WS-uplink-only, so this test registers a
|
||||
/// mock sled connection (rather than a mockito/raw-TCP HTTP mock) to verify
|
||||
/// `init_project` scaffolding followed by an MCP `tools/call` for
|
||||
/// `wizard_status` routed over the live uplink.
|
||||
#[tokio::test]
|
||||
async fn init_project_then_wizard_status_integration() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let mock_port = listener.local_addr().unwrap().port();
|
||||
let mock_url = format!("http://127.0.0.1:{mock_port}");
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Ok((mut stream, _)) = listener.accept().await {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "{\"steps\":[{\"id\":\"scaffold\",\"title\":\"Scaffold\",\"status\":\"confirmed\"}],\"completed\":false}"
|
||||
}]
|
||||
}
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap();
|
||||
let header = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body_bytes.len()
|
||||
);
|
||||
let _ = stream.write_all(header.as_bytes()).await;
|
||||
let _ = stream.write_all(&body_bytes).await;
|
||||
}
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
use crate::service::gateway::ProjectEntry;
|
||||
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert("mock-project".into(), ProjectEntry::with_url(mock_url));
|
||||
projects.insert(
|
||||
"mock-project".into(),
|
||||
ProjectEntry {
|
||||
url: None,
|
||||
auth_token: Some("secret".into()),
|
||||
ssh_port: None,
|
||||
host_path: None,
|
||||
expected_node_id: None,
|
||||
},
|
||||
);
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
@@ -457,6 +227,18 @@ async fn init_project_then_wizard_status_integration() {
|
||||
let config_dir = tempfile::tempdir().unwrap();
|
||||
let state = Arc::new(GatewayState::new(config, config_dir.path().to_path_buf(), 3000).unwrap());
|
||||
|
||||
let conn = spawn_mock_sled(|_body| {
|
||||
serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "{\"steps\":[{\"id\":\"scaffold\",\"title\":\"Scaffold\",\"status\":\"confirmed\"}],\"completed\":false}"
|
||||
}]
|
||||
})
|
||||
});
|
||||
state
|
||||
.register_sled_connection("mock-project".to_string(), conn)
|
||||
.await;
|
||||
|
||||
let project_dir = tempfile::tempdir().unwrap();
|
||||
let result =
|
||||
gateway::init_project(&state, project_dir.path().to_str().unwrap(), None, None).await;
|
||||
@@ -466,8 +248,7 @@ async fn init_project_then_wizard_status_integration() {
|
||||
let wizard_path = project_dir.path().join(".huskies/wizard_state.json");
|
||||
assert!(wizard_path.exists());
|
||||
|
||||
// Proxy call to the mock server.
|
||||
let active_url = state.active_url().await.unwrap();
|
||||
// Proxy call over the sled-uplink WS.
|
||||
let proxy_body = serde_json::to_vec(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
@@ -475,7 +256,7 @@ async fn init_project_then_wizard_status_integration() {
|
||||
"params": { "name": "wizard_status", "arguments": {} }
|
||||
}))
|
||||
.unwrap();
|
||||
let proxy_resp = gateway::io::proxy_mcp_call(&state.client, &active_url, &proxy_body).await;
|
||||
let proxy_resp = state.proxy_active_mcp(&proxy_body).await;
|
||||
assert!(proxy_resp.is_ok());
|
||||
|
||||
let resp_json: serde_json::Value = serde_json::from_slice(&proxy_resp.unwrap()).unwrap();
|
||||
@@ -560,155 +341,6 @@ async fn aggregate_pipeline_status_integration_healthy_and_unreachable() {
|
||||
assert!(broken.get("error").is_some());
|
||||
}
|
||||
|
||||
// ── Multi-project notification poller integration ────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_notification_poller_delivers_events_from_two_projects_with_project_tags() {
|
||||
use crate::chat::{ChatTransport, MessageId};
|
||||
use crate::service::events::StoredEvent;
|
||||
use async_trait::async_trait;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
type CallLog = Arc<std::sync::Mutex<Vec<(String, String, String)>>>;
|
||||
|
||||
struct MockTransport {
|
||||
calls: CallLog,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatTransport for MockTransport {
|
||||
async fn send_message(
|
||||
&self,
|
||||
room_id: &str,
|
||||
plain: &str,
|
||||
html: &str,
|
||||
) -> Result<MessageId, String> {
|
||||
self.calls.lock().unwrap().push((
|
||||
room_id.to_string(),
|
||||
plain.to_string(),
|
||||
html.to_string(),
|
||||
));
|
||||
Ok("mock-id".to_string())
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
_room_id: &str,
|
||||
_id: &str,
|
||||
_plain: &str,
|
||||
_html: &str,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let transport = Arc::new(MockTransport {
|
||||
calls: Arc::clone(&calls),
|
||||
});
|
||||
|
||||
let alpha_events = vec![StoredEvent::StageTransition {
|
||||
story_id: "1_story_alpha".to_string(),
|
||||
story_name: String::new(),
|
||||
from_stage: "2_current".to_string(),
|
||||
to_stage: "3_qa".to_string(),
|
||||
timestamp_ms: 100,
|
||||
}];
|
||||
let alpha_body = serde_json::to_vec(&alpha_events).unwrap();
|
||||
let alpha_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let alpha_port = alpha_listener.local_addr().unwrap().port();
|
||||
let alpha_url = format!("http://127.0.0.1:{alpha_port}");
|
||||
tokio::spawn(async move {
|
||||
for _ in 0..4 {
|
||||
if let Ok((mut stream, _)) = alpha_listener.accept().await {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
let header = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
alpha_body.len()
|
||||
);
|
||||
let _ = stream.write_all(header.as_bytes()).await;
|
||||
let _ = stream.write_all(&alpha_body).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let beta_events = vec![StoredEvent::MergeFailure {
|
||||
story_id: "2_story_beta".to_string(),
|
||||
story_name: String::new(),
|
||||
reason: "merge conflict in lib.rs".to_string(),
|
||||
timestamp_ms: 200,
|
||||
}];
|
||||
let beta_body = serde_json::to_vec(&beta_events).unwrap();
|
||||
let beta_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let beta_port = beta_listener.local_addr().unwrap().port();
|
||||
let beta_url = format!("http://127.0.0.1:{beta_port}");
|
||||
tokio::spawn(async move {
|
||||
for _ in 0..4 {
|
||||
if let Ok((mut stream, _)) = beta_listener.accept().await {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
let header = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
beta_body.len()
|
||||
);
|
||||
let _ = stream.write_all(header.as_bytes()).await;
|
||||
let _ = stream.write_all(&beta_body).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
let mut project_urls = BTreeMap::new();
|
||||
project_urls.insert("alpha".to_string(), alpha_url);
|
||||
project_urls.insert("beta".to_string(), beta_url);
|
||||
|
||||
gateway::spawn_gateway_notification_poller(
|
||||
transport as Arc<dyn crate::chat::ChatTransport>,
|
||||
vec!["!room:example.org".to_string()],
|
||||
project_urls,
|
||||
1,
|
||||
);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert!(
|
||||
!calls.is_empty(),
|
||||
"Expected at least one notification; got none"
|
||||
);
|
||||
|
||||
let plains: Vec<&str> = calls.iter().map(|(_, p, _)| p.as_str()).collect();
|
||||
|
||||
let alpha_notification = plains
|
||||
.iter()
|
||||
.any(|p| p.contains("[alpha]") && p.contains("1"));
|
||||
let beta_notification = plains
|
||||
.iter()
|
||||
.any(|p| p.contains("[beta]") && p.contains("merge conflict"));
|
||||
|
||||
assert!(
|
||||
alpha_notification,
|
||||
"Expected a notification from [alpha] containing story ID '1'; got: {plains:?}"
|
||||
);
|
||||
assert!(
|
||||
beta_notification,
|
||||
"Expected a notification from [beta] containing 'merge conflict'; got: {plains:?}"
|
||||
);
|
||||
|
||||
for (room_id, _, _) in calls.iter() {
|
||||
assert_eq!(
|
||||
room_id, "!room:example.org",
|
||||
"All notifications must go to the gateway room"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gateway broadcaster forwarder tests ─────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1046,26 +678,26 @@ async fn gateway_mcp_sse_proxy_streams_progress_and_final_response() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Non-SSE `tools/call` requests must be routed over the live sled-uplink WS
|
||||
/// (story 1180: the gateway no longer falls back to HTTP for MCP proxying)
|
||||
/// and return a plain `application/json` body.
|
||||
#[tokio::test]
|
||||
async fn gateway_mcp_post_without_sse_returns_plain_json() {
|
||||
let mut mock_sled = mockito::Server::new_async().await;
|
||||
|
||||
let json_resp = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"result": { "content": [{ "type": "text", "text": "done" }] }
|
||||
});
|
||||
|
||||
let _mock = mock_sled
|
||||
.mock("POST", "/mcp")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(serde_json::to_string(&json_resp).unwrap())
|
||||
.create_async()
|
||||
.await;
|
||||
use crate::service::gateway::ProjectEntry;
|
||||
|
||||
// WS-only project entry — no URL, proving the response comes from the
|
||||
// sled-uplink connection and not an HTTP fallback.
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert("sled".to_string(), ProjectEntry::with_url(mock_sled.url()));
|
||||
projects.insert(
|
||||
"sled".to_string(),
|
||||
ProjectEntry {
|
||||
url: None,
|
||||
auth_token: Some("secret".into()),
|
||||
ssh_port: None,
|
||||
host_path: None,
|
||||
expected_node_id: None,
|
||||
},
|
||||
);
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
@@ -1073,6 +705,13 @@ async fn gateway_mcp_post_without_sse_returns_plain_json() {
|
||||
};
|
||||
let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap());
|
||||
|
||||
let conn = spawn_mock_sled(
|
||||
|_body| serde_json::json!({ "content": [{ "type": "text", "text": "done" }] }),
|
||||
);
|
||||
state
|
||||
.register_sled_connection("sled".to_string(), conn)
|
||||
.await;
|
||||
|
||||
let app = poem::Route::new()
|
||||
.at("/mcp", poem::post(gateway_mcp_post_handler))
|
||||
.data(state.clone());
|
||||
@@ -1106,9 +745,122 @@ async fn gateway_mcp_post_without_sse_returns_plain_json() {
|
||||
|
||||
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||
assert_eq!(body["id"], 2);
|
||||
assert_eq!(
|
||||
body["result"]["content"][0]["text"], "done",
|
||||
"Expected result in plain JSON response, routed over the sled-uplink WS"
|
||||
);
|
||||
}
|
||||
|
||||
/// story 1180 AC1/AC3/AC4: when a project has no live sled-uplink connection,
|
||||
/// `proxy_active_mcp` must fail fast with an actionable error naming the
|
||||
/// sled — no HTTP fallback attempt, no hang.
|
||||
#[tokio::test]
|
||||
async fn proxy_active_mcp_with_no_live_connection_fails_fast_naming_sled() {
|
||||
use crate::service::gateway::ProjectEntry;
|
||||
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert(
|
||||
"offline-sled".to_string(),
|
||||
ProjectEntry {
|
||||
url: None,
|
||||
auth_token: Some("secret".into()),
|
||||
ssh_port: None,
|
||||
host_path: None,
|
||||
expected_node_id: None,
|
||||
},
|
||||
);
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
release_channels: BTreeMap::new(),
|
||||
};
|
||||
let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap());
|
||||
|
||||
// No sled connection is ever registered — the uplink is down.
|
||||
let body = serde_json::to_vec(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let err = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
state.proxy_active_mcp(&body),
|
||||
)
|
||||
.await
|
||||
.expect("proxy_active_mcp must fail fast, not hang, when the sled is disconnected")
|
||||
.expect_err("must return an error when no live uplink connection exists");
|
||||
|
||||
assert!(
|
||||
body.get("result").is_some(),
|
||||
"Expected result in plain JSON response"
|
||||
err.contains("offline-sled"),
|
||||
"error must name the disconnected sled; got: {err}"
|
||||
);
|
||||
assert!(
|
||||
!err.to_lowercase().contains("http"),
|
||||
"error must not mention HTTP fallback; got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bot chat commands (e.g. `tools/call` proxied through the gateway MCP HTTP
|
||||
/// route) against a disconnected sled must surface an immediate JSON-RPC
|
||||
/// error response rather than hanging — story 1180 AC4.
|
||||
#[tokio::test]
|
||||
async fn gateway_mcp_post_against_disconnected_sled_returns_error_response_fast() {
|
||||
use crate::service::gateway::ProjectEntry;
|
||||
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert(
|
||||
"offline-sled".to_string(),
|
||||
ProjectEntry {
|
||||
url: None,
|
||||
auth_token: Some("secret".into()),
|
||||
ssh_port: None,
|
||||
host_path: None,
|
||||
expected_node_id: None,
|
||||
},
|
||||
);
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
release_channels: BTreeMap::new(),
|
||||
};
|
||||
let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap());
|
||||
|
||||
let app = poem::Route::new()
|
||||
.at("/mcp", poem::post(gateway_mcp_post_handler))
|
||||
.data(state.clone());
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
|
||||
let rpc_body = serde_json::to_vec(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": { "name": "get_pipeline_status", "arguments": {} }
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let resp = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
cli.post("/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.body(rpc_body)
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
.expect("request against a disconnected sled must fail fast, not hang");
|
||||
|
||||
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||
assert_eq!(body["id"], 3);
|
||||
assert!(
|
||||
body.get("error").is_some(),
|
||||
"Expected a JSON-RPC error for a disconnected sled; got: {body}"
|
||||
);
|
||||
let msg = body["error"]["message"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
msg.contains("offline-sled"),
|
||||
"error message must name the sled; got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user