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 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()
}
async fn 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 new_session(app: &axum::Router) -> String {
let r = req(
app,
"POST",
"/session",
Some(serde_json::json!({"title": "v2test"})),
)
.await;
assert_eq!(r.status(), StatusCode::OK);
json(r).await["id"].as_str().unwrap().to_string()
}
async fn assert_json(app: &axum::Router, method: &str, uri: &str) -> serde_json::Value {
let r = req(app, method, uri, None).await;
let ct = r
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
assert_eq!(
r.status(),
StatusCode::OK,
"{method} {uri} -> {}",
r.status()
);
assert!(
ct.contains("application/json"),
"{method} {uri} served {ct}, expected JSON (the TUI break class)"
);
json(r).await
}
#[tokio::test]
async fn doc_route_serves_self_describing_contract() {
let app = app();
let v = assert_json(&app, "GET", "/doc").await;
assert_eq!(v["openapi"], "3.1.0");
assert_eq!(v["paths"].as_object().unwrap().len(), 162);
}
#[tokio::test]
async fn session_diff_crash_path_returns_json_array() {
let app = app();
let sid = new_session(&app).await;
let v = assert_json(&app, "GET", &format!("/session/{sid}/diff")).await;
assert!(v.is_array());
}
#[tokio::test]
async fn api_location_group_envelopes() {
let app = app();
for route in [
"/api/agent",
"/api/model",
"/api/provider",
"/api/command",
"/api/skill",
"/api/reference",
"/api/integration",
] {
let v = assert_json(&app, "GET", route).await;
assert!(v.get("location").is_some(), "{route} missing location");
assert!(v.get("data").is_some(), "{route} missing data");
}
}
#[tokio::test]
async fn api_session_read_group() {
let app = app();
let sid = new_session(&app).await;
assert_json(&app, "GET", "/api/session").await;
assert_json(&app, "GET", &format!("/api/session/{sid}")).await;
let msgs = assert_json(&app, "GET", &format!("/api/session/{sid}/message")).await;
assert!(msgs.get("data").is_some());
}
#[tokio::test]
async fn api_session_create_shares_v1_path() {
let app = app();
let v = assert_json_post(&app, "/api/session", serde_json::json!({"agent": "build"})).await;
let id = v["data"]["id"].as_str().unwrap();
assert!(id.starts_with("ses"));
let got = assert_json(&app, "GET", &format!("/session/{id}")).await;
assert_eq!(got["id"], id);
}
async fn assert_json_post(
app: &axum::Router,
uri: &str,
body: serde_json::Value,
) -> serde_json::Value {
let r = req(app, "POST", uri, Some(body)).await;
assert_eq!(r.status(), StatusCode::OK, "POST {uri} -> {}", r.status());
json(r).await
}
#[tokio::test]
async fn api_fs_find_is_location_envelope() {
let app = app();
let v = assert_json(&app, "GET", "/api/fs/find?query=main").await;
assert!(v.get("location").is_some());
assert!(v["data"].is_array());
}
#[tokio::test]
async fn v1_skill_is_bare_array() {
let app = app();
let v = assert_json(&app, "GET", "/skill").await;
assert!(
v.is_array(),
"v1 /skill must be a bare array, not the v2 envelope"
);
}
#[tokio::test]
async fn vcs_apply_missing_patch_payload_error() {
let app = app();
let r = req(&app, "POST", "/vcs/apply", Some(serde_json::json!({}))).await;
assert_eq!(r.status(), StatusCode::BAD_REQUEST);
let v = json(r).await;
assert_eq!(v["name"], "BadRequest");
assert!(v["data"]["message"].as_str().unwrap().contains("patch"));
}
#[tokio::test]
async fn share_returns_session_record() {
let app = app();
let sid = new_session(&app).await;
let v = assert_json_post(
&app,
&format!("/session/{sid}/share"),
serde_json::json!({}),
)
.await;
assert_eq!(v["id"], sid);
let d = assert_json(&app, "DELETE", &format!("/session/{sid}/share")).await;
assert_eq!(d["id"], sid);
}
#[tokio::test]
async fn upgrade_missing_target_payload_error() {
let app = app();
let r = req(&app, "POST", "/global/upgrade", Some(serde_json::json!({}))).await;
assert_eq!(r.status(), StatusCode::BAD_REQUEST);
let v = json(r).await;
assert_eq!(v["name"], "BadRequest");
}
#[tokio::test]
async fn sync_start_true() {
let app = app();
let v = assert_json_post(&app, "/sync/start", serde_json::json!({})).await;
assert_eq!(v, serde_json::json!(true));
}
#[tokio::test]
async fn experimental_routes_json() {
let app = app();
assert_json(&app, "GET", "/experimental/workspace/adapter").await;
assert_json(&app, "GET", "/experimental/worktree").await;
assert_json(&app, "GET", "/experimental/console/orgs").await;
assert_json(&app, "GET", "/api/permission/saved").await;
assert_json(&app, "GET", "/api/question/request").await;
}
#[tokio::test]
async fn api_prefix_never_serves_html() {
let app = app();
let sid = new_session(&app).await;
for uri in [
format!("/api/session/{sid}"),
"/api/agent".to_string(),
"/api/permission/saved".to_string(),
] {
let r = req(&app, "GET", &uri, None).await;
let ct = r
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(!ct.contains("text/html"), "{uri} served HTML: {ct}");
}
}
#[tokio::test]
async fn permission_create_evaluates_ruleset() {
use ocserve_http::{AppState, LlmRegistry, Payloads, Wires};
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("p.db");
let blobs =
std::sync::Arc::new(ocserve_store::BlobStore::new(dir.path().join("blobs")).unwrap());
let writer = std::sync::Arc::new(ocserve_store::Writer::spawn(db.clone()).unwrap());
let llm = LlmRegistry {
endpoints: Default::default(),
pricing: Default::default(),
limits: Default::default(),
default_model: ("p".into(), "m".into()),
systems: Default::default(),
default_agent: "build".into(),
};
let payloads = Payloads {
agent: vec![
serde_json::json!({"name":"build","permission":[{"permission":"*","pattern":"*","action":"allow"}]}),
serde_json::json!({"name":"locked","permission":[{"permission":"edit","pattern":"*","action":"deny"}]}),
],
..Payloads::default()
};
let st = AppState::with_wiring(
None,
payloads,
Wires {
db,
blobs,
writer,
llm,
},
);
std::mem::forget(dir);
let app = ocserve_http::router(st);
let r = req(&app, "POST", "/session", Some(serde_json::json!({}))).await;
let sid = json(r).await["id"].as_str().unwrap().to_string();
let r = req(
&app,
"POST",
&format!("/api/session/{sid}/permission"),
Some(serde_json::json!({"action":"read","resources":["src/x.rs"],"save":[]})),
)
.await;
assert_eq!(r.status(), StatusCode::OK);
assert_eq!(json(r).await["data"]["effect"], "allow");
let r = req(
&app,
"POST",
&format!("/api/session/{sid}/permission"),
Some(serde_json::json!({"action":"edit","resources":["/etc/passwd"],"save":[],"agent":"locked"})),
)
.await;
assert_eq!(r.status(), StatusCode::OK);
assert_eq!(
json(r).await["data"]["effect"],
"deny",
"a deny rule must yield deny, not the old hardcoded allow"
);
let r = req(
&app,
"POST",
&format!("/api/session/{sid}/permission"),
Some(serde_json::json!({"action":"webfetch","resources":["x"],"save":[],"agent":"asky"})),
)
.await;
let r2 = req(
&app,
"POST",
&format!("/api/session/{sid}/permission"),
Some(serde_json::json!({"action":"unknown_tool","resources":["*"],"save":[],"agent":"locked"})),
)
.await;
let _ = r;
assert!(
json(r2).await["data"]["id"]
.as_str()
.unwrap()
.starts_with("per")
);
}
#[tokio::test]
async fn v2_unknown_session_write_404s_and_cursors_are_shaped() {
let app = app();
let bogus = "ses_00000000000000000000000000";
let r = req(
&app,
"POST",
&format!("/api/session/{bogus}/agent"),
Some(serde_json::json!({"agent": "build"})),
)
.await;
assert_eq!(r.status(), StatusCode::NOT_FOUND);
let j = json(r).await;
assert_eq!(j["_tag"], "SessionNotFoundError");
assert_eq!(j["sessionID"], bogus);
let r = req(
&app,
"POST",
&format!("/api/session/{bogus}/model"),
Some(serde_json::json!({"model":{"id":"x","providerID":"y"}})),
)
.await;
assert_eq!(r.status(), StatusCode::NOT_FOUND);
let r = req(&app, "GET", "/api/session?limit=2&order=desc", None).await;
let j = json(r).await;
assert!(
j["cursor"].get("previous").is_some(),
"cursor.previous key: {j}"
);
assert!(j["cursor"].get("next").is_some(), "cursor.next key: {j}");
}