use super::*;
use axum::body::Body;
use axum::http::header::CONTENT_TYPE;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use crate::connector::{ServiceInfo, ServiceStatus};
struct StubConnector {
id: &'static str,
display_name: &'static str,
status: ServiceStatus,
}
impl ServiceConnector for StubConnector {
fn id(&self) -> &'static str {
self.id
}
fn display_name(&self) -> &'static str {
self.display_name
}
fn detect(&self) -> ServiceInfo {
ServiceInfo {
id: self.id.to_string(),
display_name: self.display_name.to_string(),
status: self.status.clone(),
version: None,
url: None,
hint: None,
lifecycle: self.lifecycle(),
}
}
}
fn make_test_state() -> AppState {
AppState::new(vec![
Box::new(StubConnector {
id: "trusty-search",
display_name: "Trusty Search",
status: ServiceStatus::Running,
}),
Box::new(StubConnector {
id: "trusty-memory",
display_name: "Trusty Memory",
status: ServiceStatus::Available,
}),
Box::new(StubConnector {
id: "trusty-analyze",
display_name: "Trusty Analyze",
status: ServiceStatus::Absent,
}),
])
}
async fn get_bytes(resp: axum::http::Response<Body>) -> Vec<u8> {
resp.into_body()
.collect()
.await
.expect("collect body")
.to_bytes()
.to_vec()
}
#[tokio::test]
async fn test_services_route_returns_json() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/console/services")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let bytes = get_bytes(resp).await;
let body: Vec<serde_json::Value> = serde_json::from_slice(&bytes).expect("parse json");
assert_eq!(body.len(), 3);
assert_eq!(body[0]["id"], "trusty-search");
assert_eq!(body[0]["status"], "running");
assert_eq!(body[0]["display_name"], "Trusty Search");
assert_eq!(body[1]["id"], "trusty-memory");
assert_eq!(body[1]["status"], "available");
assert_eq!(body[2]["id"], "trusty-analyze");
assert_eq!(body[2]["status"], "absent");
}
#[tokio::test]
async fn test_services_route_orders_running_before_absent() {
let state = AppState::new(vec![
Box::new(StubConnector {
id: "trusty-analyze",
display_name: "Trusty Analyze",
status: ServiceStatus::Absent,
}),
Box::new(StubConnector {
id: "trusty-memory",
display_name: "Trusty Memory",
status: ServiceStatus::Available,
}),
Box::new(StubConnector {
id: "trusty-search",
display_name: "Trusty Search",
status: ServiceStatus::Running,
}),
]);
let router = build_router(state);
let req = Request::builder()
.uri("/api/console/services")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let bytes = get_bytes(resp).await;
let body: Vec<serde_json::Value> = serde_json::from_slice(&bytes).expect("parse json");
let ids: Vec<&str> = body.iter().map(|s| s["id"].as_str().unwrap()).collect();
assert_eq!(
ids,
vec!["trusty-search", "trusty-memory", "trusty-analyze"],
"running must lead, absent must trail; got {body:?}"
);
}
#[tokio::test]
async fn test_health_route() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/health")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let bytes = get_bytes(resp).await;
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("parse json");
assert_eq!(body["status"], "ok");
assert!(body["version"].is_string());
}
#[tokio::test]
async fn test_services_route_returns_degraded_with_hint() {
use crate::connector::ServiceInfo;
struct DegradedConnector;
impl ServiceConnector for DegradedConnector {
fn id(&self) -> &'static str {
"trusty-analyze"
}
fn display_name(&self) -> &'static str {
"Trusty Analyze"
}
fn detect(&self) -> ServiceInfo {
ServiceInfo {
id: "trusty-analyze".to_string(),
display_name: "Trusty Analyze".to_string(),
status: ServiceStatus::Degraded,
version: None,
url: None,
hint: Some("reachable but `console_metrics` tool not registered".to_string()),
lifecycle: self.lifecycle(),
}
}
}
let state = AppState::new(vec![Box::new(DegradedConnector)]);
let router = build_router(state);
let req = Request::builder()
.uri("/api/console/services")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let bytes = get_bytes(resp).await;
let body: Vec<serde_json::Value> = serde_json::from_slice(&bytes).expect("parse json");
assert_eq!(body.len(), 1);
assert_eq!(body[0]["status"], "degraded");
assert!(
body[0].get("hint").is_some(),
"degraded service must include hint field"
);
assert!(
body[0]["hint"]
.as_str()
.unwrap_or("")
.contains("console_metrics"),
"hint must mention console_metrics"
);
}
#[tokio::test]
async fn test_spa_root_returns_html() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
assert!(ct.contains("text/html"), "expected text/html, got: {ct}");
}
#[tokio::test]
async fn test_screensaver_paths_return_spa_shell() {
for uri in ["/ui/screensaver", "/screensaver"] {
let router = build_router(make_test_state());
let req = Request::builder()
.uri(uri)
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::OK, "{uri} must serve the shell");
let ct = resp
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
assert!(
ct.contains("text/html"),
"{uri}: expected text/html, got: {ct}"
);
}
}
struct PanicConnector;
impl ServiceConnector for PanicConnector {
fn id(&self) -> &'static str {
"panic-svc"
}
fn display_name(&self) -> &'static str {
"Panic Service"
}
fn detect(&self) -> ServiceInfo {
panic!("intentional test panic from PanicConnector");
}
}
#[tokio::test]
async fn test_services_handler_returns_500_on_panic() {
let state = AppState::new(vec![Box::new(PanicConnector)]);
let router = build_router(state);
let req = Request::builder()
.uri("/api/console/services")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn test_metrics_analyze_route_cold_cache_returns_503() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/console/metrics/analyze")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn test_api_proxy_unknown_service_returns_400() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/unknown/health")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_api_proxy_known_service_cold_cache_returns_503() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/review/health")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn test_api_proxy_mpm_is_in_allowlist_cold_cache_returns_503() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/mpm/health")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"/api/mpm/health must return 503 (mpm in allowlist, cache cold), not 400"
);
}
#[tokio::test]
async fn test_deprecated_proxy_alias_still_routes() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/proxy/review/health")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"/proxy/review/health must return 503 via deprecated alias, not 404"
);
}
#[tokio::test]
async fn test_deprecated_proxy_alias_unknown_key_returns_400() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/proxy/unknown/health")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_proxy_mpm_is_in_allowlist_cold_cache_returns_503() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/proxy/mpm/health")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"/proxy/mpm/health must return 503 (mpm in allowlist, cache cold), not 400"
);
}
#[tokio::test]
async fn test_api_proxy_console_key_returns_400() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/console/hijack")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"/api/console/<unregistered-path> must return 400 from the console guard, not 404"
);
}
#[tokio::test]
async fn test_metrics_memory_route_cold_cache_returns_503() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/console/metrics/memory")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn test_metrics_search_route_cold_cache_returns_503() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/console/metrics/search")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn test_metrics_review_route_cold_cache_returns_503() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/console/metrics/review")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn test_metrics_mpm_route_cold_cache_returns_503() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/console/metrics/mpm")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn test_analyze_indexes_absent_binary_returns_503() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/console/metrics/analyze/indexes")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_ne!(
resp.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"indexes route must not 500 when binary absent"
);
}
#[tokio::test]
async fn test_analyze_visualize_handler_no_index_returns_json_error() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/console/metrics/analyze/visualize")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"missing index param must return 400"
);
let bytes = get_bytes(resp).await;
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("parse json");
assert!(
body.get("error").is_some(),
"expected error field, got: {body}"
);
}
#[tokio::test]
async fn test_services_route_handle_degraded_overlay() {
let state = AppState::new(vec![
Box::new(StubConnector {
id: "trusty-search",
display_name: "Trusty Search",
status: ServiceStatus::Running,
}),
Box::new(StubConnector {
id: "trusty-analyze",
display_name: "Trusty Analyze",
status: ServiceStatus::Absent,
}),
]);
{
let handles = state.mcp_handles();
let search_handle = handles
.get("trusty-search")
.expect("search handle must exist");
search_handle.prime_degraded_for_test().await;
}
let router = build_router(state);
let req = Request::builder()
.uri("/api/console/services")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let bytes = get_bytes(resp).await;
let body: Vec<serde_json::Value> = serde_json::from_slice(&bytes).expect("parse json");
assert_eq!(body.len(), 2);
let search = body
.iter()
.find(|s| s["id"] == "trusty-search")
.expect("search entry");
assert_eq!(
search["status"], "degraded",
"Running service whose handle is Degraded must report degraded, got: {search}"
);
let hint = search["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"degraded service must include a non-empty hint"
);
assert!(
hint.contains("console_metrics"),
"hint must mention console_metrics, got: {hint}"
);
let analyze = body
.iter()
.find(|s| s["id"] == "trusty-analyze")
.expect("analyze entry");
assert_eq!(
analyze["status"], "absent",
"Absent service must not be overridden to degraded"
);
}
#[tokio::test]
#[cfg(unix)]
async fn test_analyze_indexes_tool_unavailable_returns_degraded_hint() {
let state = make_test_state();
{
let analyze_handle = state.analyze_handle();
analyze_handle
.prime_connected_missing_tool_for_test("list_analyze_indexes")
.await;
}
let router = build_router(state);
let req = Request::builder()
.uri("/api/console/metrics/analyze/indexes")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"missing tool must return 503 SERVICE_UNAVAILABLE, not 502 BAD_GATEWAY"
);
let bytes = get_bytes(resp).await;
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("parse json body");
assert_eq!(
body["status"], "degraded",
"response body must have status=degraded, got: {body}"
);
let hint = body["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"response body must include a non-empty hint, got: {body}"
);
assert!(
hint.contains("list_analyze_indexes"),
"hint must mention the missing tool name, got: {hint}"
);
}
#[tokio::test]
async fn write_route_rejects_cross_origin() {
let router = build_router(make_test_state());
let req = Request::builder()
.method("POST")
.uri("/api/console/sessions")
.header("origin", "http://evil.example.com")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({"repo_url":"https://x/y","ref":"main","task":"t"}).to_string(),
))
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"cross-origin write must be rejected with 403"
);
}
#[tokio::test]
async fn write_route_allows_loopback_origin() {
let router = build_router(make_test_state());
let req = Request::builder()
.method("DELETE")
.uri("/api/console/sessions/abc")
.header("origin", "http://127.0.0.1:7788")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_ne!(
resp.status(),
StatusCode::FORBIDDEN,
"loopback-origin write must pass the same-origin guard"
);
}
#[tokio::test]
async fn write_route_allows_missing_origin() {
let router = build_router(make_test_state());
let req = Request::builder()
.method("POST")
.uri("/api/console/sessions/abc/stop")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_ne!(
resp.status(),
StatusCode::FORBIDDEN,
"missing-Origin write must pass the same-origin guard"
);
}
#[tokio::test]
async fn read_route_allows_cross_origin() {
let router = build_router(make_test_state());
let req = Request::builder()
.method("GET")
.uri("/api/console/sessions")
.header("origin", "http://evil.example.com")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_ne!(
resp.status(),
StatusCode::FORBIDDEN,
"cross-origin GET (read) must not be blocked by the write guard"
);
}
#[tokio::test]
async fn proxy_route_rejects_cross_origin_write() {
let router = build_router(make_test_state());
let req = Request::builder()
.method("POST")
.uri("/api/search/admin/stop")
.header("origin", "http://evil.example.com")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"cross-origin write through the reverse proxy must be rejected with 403"
);
}
#[tokio::test]
async fn proxy_route_allows_cross_origin_read() {
let router = build_router(make_test_state());
let req = Request::builder()
.method("GET")
.uri("/api/search/status")
.header("origin", "http://evil.example.com")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_ne!(
resp.status(),
StatusCode::FORBIDDEN,
"cross-origin GET through the reverse proxy must not be blocked by the write guard"
);
}
#[tokio::test]
async fn proxy_route_allows_self_origin_write() {
let self_origins = crate::routes::origin_guard::SelfOrigins::from_bind_addrs(&[
"127.0.0.1:7788".parse().expect("addr"),
"100.64.1.2:7788".parse().expect("addr"),
]);
let router = build_router_with_self_origins(make_test_state(), self_origins);
let session_req = Request::builder()
.method("POST")
.uri("/api/console/sessions")
.header("origin", "http://100.64.1.2:7788")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({"repo_url":"https://x/y","ref":"main","task":"t"}).to_string(),
))
.expect("request");
let session_resp = router.clone().oneshot(session_req).await.expect("response");
assert_ne!(
session_resp.status(),
StatusCode::FORBIDDEN,
"self-origin (Tailscale bind addr) session write must pass the guard"
);
let proxy_req = Request::builder()
.method("POST")
.uri("/api/search/admin/stop")
.header("origin", "http://100.64.1.2:7788")
.body(Body::empty())
.expect("request");
let proxy_resp = router.oneshot(proxy_req).await.expect("response");
assert_ne!(
proxy_resp.status(),
StatusCode::FORBIDDEN,
"self-origin (Tailscale bind addr) proxied write must pass the guard"
);
}
#[tokio::test]
async fn proxy_route_rejects_other_tailnet_host() {
let self_origins =
crate::routes::origin_guard::SelfOrigins::from_bind_addrs(&["100.64.1.2:7788"
.parse()
.expect("addr")]);
let router = build_router_with_self_origins(make_test_state(), self_origins);
let req = Request::builder()
.method("POST")
.uri("/api/search/admin/stop")
.header("origin", "http://100.64.9.9:7788")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"a non-self, non-loopback tailnet host must still be rejected — the allowlist must \
not blanket-trust the whole CGNAT range"
);
}
#[tokio::test]
async fn machine_status_route_cold_cache_returns_503() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/console/machine-status")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn machine_status_route_warm_cache_returns_json() {
use trusty_common::console_metrics::machine_status::MachineStatus;
use trusty_common::console_metrics::{ServiceHealth, make_report};
use trusty_common::host_metrics::HostSampler;
let state = make_test_state();
state
.host_metrics_cache()
.set(HostSampler::new().sample())
.await;
state
.metrics_cache()
.set(make_report(
"trusty-analyze",
"Analyze",
"0.7.0",
ServiceHealth::Ok,
serde_json::json!({ "files": 1 }),
1,
))
.await;
let router = build_router(state);
let req = Request::builder()
.uri("/api/console/machine-status")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp
.into_body()
.collect()
.await
.expect("collect body")
.to_bytes();
let status: MachineStatus = serde_json::from_slice(&bytes).expect("parse MachineStatus");
assert_eq!(status.services.total, 1);
assert_eq!(status.services.ok, 1);
assert!(status.host.cpu.logical_cores >= 1);
}