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(),
cpu_pct: None,
rss_bytes: None,
}
}
}
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 services_route_lists_the_console_itself() {
let router = build_router(AppState::new(crate::detect::all_connectors()));
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 console = body
.iter()
.find(|s| s["id"] == "trusty-console")
.unwrap_or_else(|| {
panic!("the services response must carry the console's own row; got: {body:?}")
});
assert_eq!(console["display_name"], "Trusty Console");
assert_eq!(
console["status"], "running",
"the console can only answer this request while it is running"
);
assert_eq!(console["lifecycle"], "daemon");
assert_eq!(console["version"], env!("CARGO_PKG_VERSION"));
}
#[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());
assert!(
body["uptime_secs"].is_u64(),
"health must report uptime_secs: {body}"
);
}
#[tokio::test]
async fn health_uptime_is_monotonic() {
async fn read(router: axum::Router) -> u64 {
let req = Request::builder()
.uri("/health")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
let bytes = get_bytes(resp).await;
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("parse json");
body["uptime_secs"].as_u64().expect("uptime_secs")
}
let router = build_router(make_test_state());
let first = read(router.clone()).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let second = read(router).await;
assert!(
second >= first,
"uptime went backwards: {first} -> {second}"
);
}
#[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(),
cpu_pct: None,
rss_bytes: None,
}
}
}
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);
}
#[tokio::test]
async fn machine_history_route_cold_cache_returns_empty_200() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/console/machine-status/history")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(
resp.status(),
StatusCode::OK,
"an empty window is a 200, not a 503"
);
let bytes = resp
.into_body()
.collect()
.await
.expect("collect body")
.to_bytes();
let snap: crate::machine_history::HistorySnapshot =
serde_json::from_slice(&bytes).expect("parse HistorySnapshot");
assert!(snap.samples.is_empty());
assert!(snap.transitions.is_empty());
assert_eq!(
snap.sample_capacity,
trusty_common::host_metrics::history::HOST_HISTORY_CAPACITY
);
assert_eq!(
snap.sample_interval_secs,
trusty_common::host_metrics::history::HOST_SAMPLE_INTERVAL_SECS
);
assert!(snap.service_samples.is_empty());
assert_eq!(
snap.service_sample_capacity,
crate::machine_history::service_samples::SERVICE_HISTORY_CAPACITY
);
assert_eq!(
snap.schema_version,
crate::machine_history::MACHINE_HISTORY_SCHEMA_VERSION
);
}
#[tokio::test]
async fn machine_history_route_returns_recorded_samples() {
use trusty_common::console_metrics::{ServiceHealth, make_report};
use trusty_common::host_metrics::HostSampler;
let state = make_test_state();
let mut sampler = HostSampler::new();
state
.machine_history()
.record_sample(sampler.sample())
.await;
state
.machine_history()
.record_sample(sampler.sample())
.await;
for status in [ServiceHealth::Ok, ServiceHealth::Error] {
state
.machine_history()
.observe_services(&[make_report(
"trusty-search",
"Trusty Search",
"1.0.0",
status,
serde_json::json!({}),
1,
)])
.await;
}
let router = build_router(state);
let req = Request::builder()
.uri("/api/console/machine-status/history")
.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 snap: crate::machine_history::HistorySnapshot =
serde_json::from_slice(&bytes).expect("parse HistorySnapshot");
assert_eq!(snap.samples.len(), 2);
assert_eq!(
snap.transitions.len(),
1,
"only the ok→error change is logged, got {:?}",
snap.transitions
);
assert_eq!(
snap.transitions[0].to,
crate::machine_history::transitions::ServiceState::Down
);
}
#[tokio::test]
async fn machine_history_stream_route_is_an_event_stream() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/console/machine-status/stream")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/event-stream")
);
assert_eq!(
resp.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok()),
Some("no-cache")
);
assert_eq!(
resp.headers()
.get("x-accel-buffering")
.and_then(|v| v.to_str().ok()),
Some("no")
);
let mut body = resp.into_body();
let frame = body
.frame()
.await
.expect("a first frame")
.expect("frame ok")
.into_data()
.expect("data frame");
let text = String::from_utf8(frame.to_vec()).expect("utf8 frame");
assert!(
text.starts_with("event: history\ndata: {"),
"the stream opens with the current window, got {text:?}"
);
}
fn architect_link_for(addr: Option<&str>) -> serde_json::Value {
let _guard = crate::detect::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let data = tempfile::TempDir::new().expect("data tempdir");
if let Some(addr) = addr {
let dir = data.path().join("trusty-architect");
std::fs::create_dir_all(&dir).expect("architect data dir");
std::fs::write(dir.join("http_addr"), addr).expect("write http_addr");
}
unsafe {
std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, data.path());
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
let result = runtime.block_on(async {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/console/architect-dashboard")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
let status = resp.status();
let bytes = get_bytes(resp).await;
(status, bytes)
});
unsafe {
std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
}
let (status, bytes) = result;
assert_eq!(status, StatusCode::OK, "the link route always answers 200");
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
body.get("url").cloned().expect("body carries a url field")
}
#[test]
fn architect_link_present_when_dashboard_discovered_and_live() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("local addr");
let url = architect_link_for(Some(&addr.to_string()));
assert_eq!(url, serde_json::json!(format!("http://{addr}/")));
drop(listener);
}
#[test]
fn architect_link_absent_without_discovery_file() {
assert_eq!(architect_link_for(None), serde_json::Value::Null);
}
#[test]
fn architect_link_absent_when_dashboard_not_live() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("local addr");
drop(listener);
assert_eq!(
architect_link_for(Some(&addr.to_string())),
serde_json::Value::Null
);
}
#[test]
fn architect_link_refuses_non_localhost_url() {
for addr in [
"http://dashboard.invalid:7890/",
"192.0.2.1:7890",
"0.0.0.0:7890",
"http://127.0.0.1@dashboard.invalid:7890/",
] {
assert_eq!(
architect_link_for(Some(addr)),
serde_json::Value::Null,
"{addr} must not produce a link"
);
}
}