//! Integration tests for the maestro-web reverse-proxy. //! //! Points maestro-web at a `MockDaemon` and exercises both the HTTP and WS //! proxy paths end-to-end. use maestro_testing::MockDaemon; use std::net::{Ipv4Addr, SocketAddr}; use std::time::Duration; use futures_util::{SinkExt, StreamExt}; use maestro_web::config::WebConfig; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio_tungstenite::tungstenite::Message as WsMessage; async fn spawn_web(daemon_url: String) -> (SocketAddr, oneshot::Sender<()>) { let cfg = WebConfig { bind_addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), daemon_url, }; let router = maestro_web::router(&cfg).expect("router"); let listener = TcpListener::bind(cfg.bind_addr).await.unwrap(); let addr = listener.local_addr().unwrap(); let (tx, rx) = oneshot::channel::<()>(); tokio::spawn(async move { let _ = axum::serve(listener, router) .with_graceful_shutdown(async move { let _ = rx.await; }) .await; }); (addr, tx) } #[tokio::test] async fn http_get_api_path_forwards_to_daemon() { let daemon = MockDaemon::start().await; let daemon_addr = daemon.addr(); let (web_addr, _w) = spawn_web(format!("http://{daemon_addr}")).await; let body: serde_json::Value = reqwest::get(format!("http://{web_addr}/api/sessions")) .await .unwrap() .json() .await .unwrap(); assert!(body.as_array().expect("array").is_empty()); } #[tokio::test] async fn http_get_unrelated_path_falls_back_to_spa_index() { let daemon = MockDaemon::start().await; let daemon_addr = daemon.addr(); let (web_addr, _w) = spawn_web(format!("http://{daemon_addr}")).await; // The bundle is empty in test builds — the placeholder index.html serves. let resp = reqwest::get(format!("http://{web_addr}/some/route")) .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::OK); let ct = resp .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert!(ct.starts_with("text/html"), "ct={ct}"); } #[tokio::test] async fn ws_api_attach_forwards_to_daemon_and_echoes() { let daemon = MockDaemon::start().await; let id = uuid::Uuid::from_u128(7); daemon.add_session(maestro_testing::session(id, "/home/mock", "bash")); let daemon_addr = daemon.addr(); let (web_addr, _w) = spawn_web(format!("http://{daemon_addr}")).await; let url = format!("ws://{web_addr}/api/attach/{id}"); let (mut ws, _) = tokio_tungstenite::connect_async(url).await.unwrap(); ws.send(WsMessage::Binary(b"hello".to_vec())).await.unwrap(); let echoed = tokio::time::timeout(Duration::from_secs(1), ws.next()) .await .unwrap(); match echoed { Some(Ok(WsMessage::Binary(b))) => assert_eq!(b, b"hello"), other => panic!("unexpected frame: {other:?}"), } } /// The client is upgraded before the upstream connection is attempted, so an /// upstream rejection has to come back as a close code. This is the end-to-end /// counterpart to `upstream_close_reason`, which had only unit tests: the /// daemon's own message must reach the browser rather than a generic failure. #[tokio::test] async fn ws_attach_to_an_unknown_session_closes_with_the_daemon_reason() { let daemon = MockDaemon::start().await; let (web_addr, _w) = spawn_web(format!("http://{}", daemon.addr())).await; let url = format!("ws://{web_addr}/api/attach/{}", uuid::Uuid::new_v4()); let (mut ws, _) = tokio_tungstenite::connect_async(url) .await .expect("upgrade"); let frame = tokio::time::timeout(Duration::from_secs(2), ws.next()) .await .expect("close frame"); match frame { Some(Ok(WsMessage::Close(Some(cf)))) => { assert_eq!(u16::from(cf.code), 4502); assert!( cf.reason.contains("no such session"), "expected the daemon's message, got {:?}", cf.reason ); } other => panic!("expected a Close frame with a reason, got {other:?}"), } } #[tokio::test] async fn proxy_returns_502_when_daemon_unreachable() { // Bind a port, immediately drop the listener, hand the address to the // proxy. Connections to it will be refused. let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await .unwrap(); let dead_addr = listener.local_addr().unwrap(); drop(listener); let (web_addr, _w) = spawn_web(format!("http://{dead_addr}")).await; let resp = reqwest::get(format!("http://{web_addr}/api/sessions")) .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::BAD_GATEWAY); let body: serde_json::Value = resp.json().await.unwrap(); assert_eq!(body["error"]["code"], "proxy_failed"); }