| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241 |
- //! 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"
- );
- }
|