This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-web/tests/proxy.rs
T
naps62-yoloandClaude Opus 4.7 bc3237287f feat(maestro): maestro-web standalone binary + reverse-proxy (NAP-241)
New crate crates/maestro-web/: axum server that

* embeds the web/dist SPA bundle via include_dir! (graceful placeholder when
  pnpm build hasn't run yet — keeps fresh checkouts compiling)
* reverse-proxies /api/* HTTP to a co-located daemon (default
  http://127.0.0.1:7070) via reqwest, stripping hop-by-hop headers
* reverse-proxies /api/attach/:id and /api/events WebSockets via
  tokio_tungstenite::connect_async + a tokio task pumping both directions
* reads bind_addr / daemon_url / dev_proxy_target from the new [web]
  config section; env-var overrides MAESTRO_WEB_BIND_ADDR / _DAEMON_URL /
  _DEV_PROXY_TARGET

Integration tests spin up a mock daemon + maestro-web pair on random ports
and verify HTTP forward, SPA fallback, WS echo, and 502 on unreachable
daemon.

Adds a top-level Justfile (`just web` = pnpm install + pnpm build + cargo
build), crates/maestro-web/README.md (config + trust model + dev workflow),
and a docs/INSTALL.md section on optional web UI install.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:23:14 +00:00

152 lines
5.2 KiB
Rust

//! Integration tests for the maestro-web reverse-proxy.
//!
//! Spins up a tiny axum "daemon" on a random port, points maestro-web at it,
//! and exercises both the HTTP and WS proxy paths end-to-end.
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use axum::extract::ws::{Message as AxMessage, WebSocket, WebSocketUpgrade};
use axum::response::IntoResponse;
use axum::routing::{any, get};
use axum::{Json, Router};
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_mock_daemon() -> (SocketAddr, oneshot::Sender<()>) {
async fn list_sessions() -> Json<serde_json::Value> {
Json(serde_json::json!([{"id": "session-1"}]))
}
async fn web_info() -> Json<serde_json::Value> {
Json(serde_json::json!({"daemon_id": "mock", "hostname": "mock-host"}))
}
async fn echo_ws(ws: WebSocketUpgrade) -> impl IntoResponse {
ws.on_upgrade(echo_handler)
}
async fn echo_handler(mut socket: WebSocket) {
while let Some(Ok(msg)) = socket.next().await {
match msg {
AxMessage::Text(t) => {
let _ = socket.send(AxMessage::Text(format!("echo:{t}"))).await;
}
AxMessage::Binary(b) => {
let _ = socket.send(AxMessage::Binary(b)).await;
}
AxMessage::Close(_) => break,
_ => {}
}
}
}
let app = Router::new()
.route("/api/sessions", get(list_sessions))
.route("/api/web/info", get(web_info))
.route("/api/attach/:id", any(echo_ws));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
(addr, tx)
}
async fn spawn_web(daemon_url: String) -> (SocketAddr, oneshot::Sender<()>) {
let cfg = WebConfig {
bind_addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
daemon_url,
dev_proxy_target: None,
};
let router = maestro_web::router(&cfg);
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_addr, _d) = spawn_mock_daemon().await;
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_eq!(body[0]["id"], "session-1");
}
#[tokio::test]
async fn http_get_unrelated_path_falls_back_to_spa_index() {
let (daemon_addr, _d) = spawn_mock_daemon().await;
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_addr, _d) = spawn_mock_daemon().await;
let (web_addr, _w) = spawn_web(format!("http://{daemon_addr}")).await;
let url = format!("ws://{web_addr}/api/attach/abc");
let (mut ws, _) = tokio_tungstenite::connect_async(url).await.unwrap();
ws.send(WsMessage::Text("hello".into())).await.unwrap();
let echoed = tokio::time::timeout(Duration::from_secs(1), ws.next())
.await
.unwrap();
match echoed {
Some(Ok(WsMessage::Text(t))) => assert_eq!(t, "echo:hello"),
other => panic!("unexpected frame: {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");
}