use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
fn app() -> axum::Router {
ocserve_http::router(ocserve_http::AppState::new())
}
async fn body_json(resp: axum::response::Response) -> serde_json::Value {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
}
async fn req(
app: &axum::Router,
method: &str,
uri: &str,
body: Option<serde_json::Value>,
) -> axum::response::Response {
let mut b = Request::builder().method(method).uri(uri);
let body = match body {
Some(v) => {
b = b.header("content-type", "application/json");
Body::from(serde_json::to_vec(&v).unwrap())
}
None => Body::empty(),
};
app.clone().oneshot(b.body(body).unwrap()).await.unwrap()
}
#[tokio::test]
async fn shells_shape_matches_freeze() {
let app = app();
let resp = req(&app, "GET", "/pty/shells", None).await;
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await;
let arr = v.as_array().expect("array");
assert!(!arr.is_empty());
for item in arr {
assert!(item["path"].is_string());
assert!(item["name"].is_string());
assert!(item["acceptable"].is_boolean());
if item["name"] == "fish" {
assert_eq!(item["acceptable"], serde_json::json!(false));
}
}
}
#[tokio::test]
async fn create_get_update_delete_lifecycle() {
let app = app();
let resp = req(
&app,
"POST",
"/pty",
Some(serde_json::json!({"command": "/bin/sh"})),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let info = body_json(resp).await;
let id = info["id"].as_str().unwrap().to_string();
assert!(id.starts_with("pty_"));
assert_eq!(info["status"], "running");
assert_eq!(info["command"], "/bin/sh");
assert_eq!(info["args"], serde_json::json!(["-l"]));
assert_eq!(
info["title"].as_str().unwrap(),
format!("Terminal {}", &id[id.len() - 4..])
);
assert!(info["pid"].as_u64().unwrap() > 0);
let resp = req(&app, "GET", &format!("/pty/{id}"), None).await;
assert_eq!(resp.status(), StatusCode::OK);
let resp = req(&app, "GET", "/pty", None).await;
let list = body_json(resp).await;
assert!(list.as_array().unwrap().iter().any(|i| i["id"] == id));
let resp = req(
&app,
"PUT",
&format!("/pty/{id}"),
Some(serde_json::json!({"title": "Renamed"})),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let upd = body_json(resp).await;
assert_eq!(upd["title"], "Renamed");
let resp = req(
&app,
"PATCH",
&format!("/pty/{id}"),
Some(serde_json::json!({"title": "x"})),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let resp = req(&app, "DELETE", &format!("/pty/{id}"), None).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_json(resp).await, serde_json::json!(true));
let resp = req(&app, "GET", &format!("/pty/{id}"), None).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let v = body_json(resp).await;
assert_eq!(v["_tag"], "PtyNotFoundError");
assert_eq!(v["ptyID"], id);
assert_eq!(v["message"], format!("PTY session not found: {id}"));
}
#[tokio::test]
async fn malformed_id_gets_params_envelope() {
let app = app();
let resp = req(&app, "GET", "/pty/bad", None).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let v = body_json(resp).await;
assert_eq!(v["name"], "BadRequest");
assert_eq!(
v["data"]["message"],
"Expected a string starting with \"pty\", got \"bad\"\n at [\"ptyID\"]"
);
}
#[tokio::test]
async fn connect_token_gates() {
let app = app();
let resp = req(
&app,
"POST",
"/pty",
Some(serde_json::json!({"command": "/bin/sh"})),
)
.await;
let id = body_json(resp).await["id"].as_str().unwrap().to_string();
let resp = req(&app, "POST", &format!("/pty/{id}/connect-token"), None).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let v = body_json(resp).await;
assert_eq!(v["_tag"], "PtyForbiddenError");
assert_eq!(v["message"], "Invalid PTY connect token request");
let r = Request::builder()
.method("POST")
.uri(format!("/pty/{id}/connect-token"))
.header("x-opencode-ticket", "1")
.header("Origin", "http://localhost:5173")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(r).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await;
assert!(v["ticket"].as_str().unwrap().contains('-'));
assert_eq!(v["expires_in"], 60);
let r = Request::builder()
.method("POST")
.uri("/pty/pty_0fa1e3f770014jHIfQNh0Lhiu3/connect-token")
.header("x-opencode-ticket", "1")
.header("Origin", "http://localhost:5173")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(r).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
assert_eq!(body_json(resp).await["_tag"], "PtyNotFoundError");
}
#[tokio::test]
async fn echo_roundtrip_through_real_pty() {
let app = app();
let resp = req(
&app,
"POST",
"/pty",
Some(serde_json::json!({"command": "/bin/sh", "title": "t"})),
)
.await;
let id = body_json(resp).await["id"].as_str().unwrap().to_string();
let st = ocserve_http::AppState::new();
let _ = st; let r = Request::builder()
.method("POST")
.uri(format!("/pty/{id}/connect-token"))
.header("x-opencode-ticket", "1")
.header("Origin", "http://localhost:5173")
.body(Body::empty())
.unwrap();
assert_eq!(
app.clone().oneshot(r).await.unwrap().status(),
StatusCode::OK
);
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
let resp = req(&app, "GET", &format!("/pty/{id}"), None).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn v2_pty_group_has_real_effects() {
let app = app();
let resp = req(
&app,
"POST",
"/api/pty",
Some(serde_json::json!({"command": "/bin/sh"})),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let id = body_json(resp).await["data"]["id"]
.as_str()
.unwrap()
.to_string();
assert!(id.starts_with("pty_"));
let resp = req(&app, "GET", &format!("/api/pty/{id}"), None).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_json(resp).await["data"]["status"], "running");
let resp = req(
&app,
"PUT",
&format!("/api/pty/{id}"),
Some(serde_json::json!({"title": "V2 Renamed"})),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let resp = req(&app, "GET", &format!("/api/pty/{id}"), None).await;
assert_eq!(body_json(resp).await["data"]["title"], "V2 Renamed");
let resp = req(&app, "DELETE", &format!("/api/pty/{id}"), None).await;
assert_eq!(resp.status(), StatusCode::OK);
let resp = req(&app, "GET", &format!("/api/pty/{id}"), None).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let resp = req(
&app,
"POST",
"/api/pty",
Some(serde_json::json!({"command": "/bin/sh"})),
)
.await;
let id2 = body_json(resp).await["data"]["id"]
.as_str()
.unwrap()
.to_string();
let resp = req(&app, "POST", &format!("/api/pty/{id2}/connect-token"), None).await;
assert_eq!(resp.status(), StatusCode::OK);
assert!(body_json(resp).await["data"]["ticket"].is_string());
let _ = req(&app, "DELETE", &format!("/api/pty/{id2}"), None).await;
}