//! Web API round-trip tests (P2 actions, P3 log windows, P4 raw content) — //! split from `tests.rs` at the 500-line cap. Shared helpers live there. use super::tests::{app_state, boot, call}; use crate::web::events::Event; use axum::http::StatusCode; // --- P2 ----------------------------------------------------------------------- #[tokio::test] async fn single_flight_rejects_duplicate_then_allows_after_finish() { let app = app_state(None); let id = crate::web::jobs::new_job(&app, "serve", "c1", Some("r1")).unwrap(); let dup = crate::web::jobs::new_job(&app, "serve", "c1", Some("r1")); assert!(matches!(dup, Err((StatusCode::CONFLICT, _)))); // a different recipe on the same cluster is fine let id2 = crate::web::jobs::new_job(&app, "serve", "c1", Some("r2")).unwrap(); assert_ne!(id, id2); // stop is a different kind: allowed even while serve r1 runs let _id3 = crate::web::jobs::new_job(&app, "stop", "c1", Some("r1")).unwrap(); crate::web::jobs::finish_job(&app, &id, true, "done".into()); // after r1's serve finished, a fresh serve r1 is allowed let id4 = crate::web::jobs::new_job(&app, "serve", "c1", Some("r1")).unwrap(); assert_ne!(id, id4); } #[tokio::test] async fn recipes_endpoint_lists_repo_recipes() { let (addr, _h) = boot().await; let res = call(addr, "/api/recipes").await; assert_eq!(res.status(), StatusCode::OK); let v: serde_json::Value = res.json().await.unwrap(); // Recipe data lives in git.kilork.org/sm121/recipes now; the launcher // repo only guarantees the endpoint shape, not any specific entry. assert!(v["recipes"].is_array()); } #[tokio::test] async fn serve_endpoint_registers_job_and_rejects_concurrent() { let (addr, _h) = boot().await; let body = serde_json::json!({ "cluster": "c1", "recipe": "r1", "dry_run": true }); let res = reqwest::Client::new() .post(format!("http://{addr}/api/serve")) .json(&body) .send() .await .unwrap(); assert_eq!(res.status(), StatusCode::OK); let v: serde_json::Value = res.json().await.unwrap(); let id = v["job"].as_str().unwrap().to_string(); // the dry-run job targets the fake config's clusters? No — "c1" doesn't // exist in the real config; the job will fail asynchronously (cluster // resolution error). Single-flight still holds while it runs. let res2 = reqwest::Client::new() .post(format!("http://{addr}/api/serve")) .json(&body) .send() .await .unwrap(); // Either 409 (still running) or 200 (already finished and released). assert!( matches!(res2.status(), StatusCode::CONFLICT | StatusCode::OK), "unexpected status {}", res2.status() ); let _ = id; } // --- P3: log windows ----------------------------------------------------------- #[tokio::test] async fn log_attach_get_detach_roundtrip() { let (addr, _h) = boot().await; let base = format!("http://{addr}"); let client = reqwest::Client::new(); // Attach on the fake cluster (node n0 at a refused port: the stream task // dies immediately, but the registry wiring is what's under test). let res = client .post(format!("{base}/api/logs")) .json( &serde_json::json!({ "cluster": "fake", "node": "n0", "recipe": "deepseek-v4-flash" }), ) .send() .await .unwrap(); assert_eq!(res.status(), 200, "attach ok"); let body: serde_json::Value = res.json().await.unwrap(); let attach_id = body["attach_id"].as_str().unwrap().to_string(); assert_eq!(attach_id, "l1"); assert!(body["history"].is_array()); // Idempotent: same key -> same attach. let res = client .post(format!("{base}/api/logs")) .json( &serde_json::json!({ "cluster": "fake", "node": "n0", "recipe": "deepseek-v4-flash" }), ) .send() .await .unwrap(); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["attach_id"], attach_id); // History endpoint (empty ring — the SSH stream failed on the refused port). let res = client .get(format!( "{base}/api/logs?cluster=fake&node=n0&recipe=deepseek-v4-flash" )) .send() .await .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["attach_id"], attach_id); assert!(body["history"].as_array().unwrap().is_empty()); // Unknown cluster -> 400. let res = client .post(format!("{base}/api/logs")) .json(&serde_json::json!({ "cluster": "nope", "node": "n0", "recipe": "r" })) .send() .await .unwrap(); assert_eq!(res.status(), 400); // Detach -> 204, then 404. let res = client .delete(format!("{base}/api/logs")) .json(&serde_json::json!({ "attach_id": attach_id })) .send() .await .unwrap(); assert_eq!(res.status(), 204); let res = client .delete(format!("{base}/api/logs")) .json(&serde_json::json!({ "attach_id": attach_id })) .send() .await .unwrap(); assert_eq!(res.status(), 404); } #[tokio::test] async fn log_event_serializes_with_type_tag() { let ev = Event::Log { attach: "l1".into(), cluster: "fake".into(), node: "n0".into(), recipe: "r".into(), rank: 0, line: "hello".into(), }; let json = serde_json::to_value(&ev).unwrap(); assert_eq!(json["type"], "log"); assert_eq!(json["attach"], "l1"); assert_eq!(json["line"], "hello"); } // --- P4: raw content ------------------------------------------------------------- #[tokio::test] async fn raw_endpoints_serve_config_recipe_effective() { let (addr, _h) = boot().await; let base = format!("http://{addr}"); let client = reqwest::Client::new(); // The active config file verbatim (cwd `config.toml` in tests). let res = client .get(format!("{base}/api/raw/config")) .send() .await .unwrap(); assert_eq!(res.status(), 200, "repo config.toml readable"); assert!( res.text().await.unwrap().contains("ssh_user"), "config verbatim" ); // Effective: merged config as JSON; the fake cluster is present and the // (absent) HA token never leaks. let res = client .get(format!("{base}/api/raw/effective")) .send() .await .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); let names: Vec<&str> = body["clusters"] .as_array() .unwrap() .iter() .map(|c| c["name"].as_str().unwrap()) .collect(); assert!(names.contains(&"fake"), "{names:?}"); assert_eq!(body["ssh_user"], "u"); assert!( body.get("home_assistant") .map(|v| v.is_null()) .unwrap_or(true) ); // Recipe: seed our own fixture (recipe data itself lives in // git.kilork.org/sm121/recipes). std::fs::create_dir_all("recipes/ztest-raw-fixture").unwrap(); std::fs::write( "recipes/ztest-raw-fixture/recipe.toml", "name = \"ztest-raw-fixture\"\nengine = \"vllm\"\n", ) .unwrap(); let res = client .get(format!("{base}/api/raw/recipe/ztest-raw-fixture")) .send() .await .unwrap(); let _ = std::fs::remove_dir_all("recipes/ztest-raw-fixture"); assert_eq!(res.status(), 200, "fixture recipe readable"); let text = res.text().await.unwrap(); assert!(text.contains("engine"), "recipe.toml verbatim"); // Unknown recipe 404s; path traversal is refused before any file access. assert_eq!( client .get(format!("{base}/api/raw/recipe/no-such-recipe")) .send() .await .unwrap() .status(), 404 ); assert_eq!( client .get(format!("{base}/api/raw/recipe/..%2F..%2Fetc")) .send() .await .unwrap() .status(), 400, "traversal name refused" ); }