api_tests.rs 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. //! Web API round-trip tests (P2 actions, P3 log windows, P4 raw content) —
  2. //! split from `tests.rs` at the 500-line cap. Shared helpers live there.
  3. use super::tests::{app_state, boot, call};
  4. use crate::web::events::Event;
  5. use axum::http::StatusCode;
  6. // --- P2 -----------------------------------------------------------------------
  7. #[tokio::test]
  8. async fn single_flight_rejects_duplicate_then_allows_after_finish() {
  9. let app = app_state(None);
  10. let id = crate::web::jobs::new_job(&app, "serve", "c1", Some("r1")).unwrap();
  11. let dup = crate::web::jobs::new_job(&app, "serve", "c1", Some("r1"));
  12. assert!(matches!(dup, Err((StatusCode::CONFLICT, _))));
  13. // a different recipe on the same cluster is fine
  14. let id2 = crate::web::jobs::new_job(&app, "serve", "c1", Some("r2")).unwrap();
  15. assert_ne!(id, id2);
  16. // stop is a different kind: allowed even while serve r1 runs
  17. let _id3 = crate::web::jobs::new_job(&app, "stop", "c1", Some("r1")).unwrap();
  18. crate::web::jobs::finish_job(&app, &id, true, "done".into());
  19. // after r1's serve finished, a fresh serve r1 is allowed
  20. let id4 = crate::web::jobs::new_job(&app, "serve", "c1", Some("r1")).unwrap();
  21. assert_ne!(id, id4);
  22. }
  23. #[tokio::test]
  24. async fn recipes_endpoint_lists_repo_recipes() {
  25. let (addr, _h) = boot().await;
  26. let res = call(addr, "/api/recipes").await;
  27. assert_eq!(res.status(), StatusCode::OK);
  28. let v: serde_json::Value = res.json().await.unwrap();
  29. // Recipe data lives in git.kilork.org/sm121/recipes now; the launcher
  30. // repo only guarantees the endpoint shape, not any specific entry.
  31. assert!(v["recipes"].is_array());
  32. }
  33. #[tokio::test]
  34. async fn serve_endpoint_registers_job_and_rejects_concurrent() {
  35. let (addr, _h) = boot().await;
  36. let body = serde_json::json!({ "cluster": "c1", "recipe": "r1", "dry_run": true });
  37. let res = reqwest::Client::new()
  38. .post(format!("http://{addr}/api/serve"))
  39. .json(&body)
  40. .send()
  41. .await
  42. .unwrap();
  43. assert_eq!(res.status(), StatusCode::OK);
  44. let v: serde_json::Value = res.json().await.unwrap();
  45. let id = v["job"].as_str().unwrap().to_string();
  46. // the dry-run job targets the fake config's clusters? No — "c1" doesn't
  47. // exist in the real config; the job will fail asynchronously (cluster
  48. // resolution error). Single-flight still holds while it runs.
  49. let res2 = reqwest::Client::new()
  50. .post(format!("http://{addr}/api/serve"))
  51. .json(&body)
  52. .send()
  53. .await
  54. .unwrap();
  55. // Either 409 (still running) or 200 (already finished and released).
  56. assert!(
  57. matches!(res2.status(), StatusCode::CONFLICT | StatusCode::OK),
  58. "unexpected status {}",
  59. res2.status()
  60. );
  61. let _ = id;
  62. }
  63. // --- P3: log windows -----------------------------------------------------------
  64. #[tokio::test]
  65. async fn log_attach_get_detach_roundtrip() {
  66. let (addr, _h) = boot().await;
  67. let base = format!("http://{addr}");
  68. let client = reqwest::Client::new();
  69. // Attach on the fake cluster (node n0 at a refused port: the stream task
  70. // dies immediately, but the registry wiring is what's under test).
  71. let res = client
  72. .post(format!("{base}/api/logs"))
  73. .json(
  74. &serde_json::json!({ "cluster": "fake", "node": "n0", "recipe": "deepseek-v4-flash" }),
  75. )
  76. .send()
  77. .await
  78. .unwrap();
  79. assert_eq!(res.status(), 200, "attach ok");
  80. let body: serde_json::Value = res.json().await.unwrap();
  81. let attach_id = body["attach_id"].as_str().unwrap().to_string();
  82. assert_eq!(attach_id, "l1");
  83. assert!(body["history"].is_array());
  84. // Idempotent: same key -> same attach.
  85. let res = client
  86. .post(format!("{base}/api/logs"))
  87. .json(
  88. &serde_json::json!({ "cluster": "fake", "node": "n0", "recipe": "deepseek-v4-flash" }),
  89. )
  90. .send()
  91. .await
  92. .unwrap();
  93. let body: serde_json::Value = res.json().await.unwrap();
  94. assert_eq!(body["attach_id"], attach_id);
  95. // History endpoint (empty ring — the SSH stream failed on the refused port).
  96. let res = client
  97. .get(format!(
  98. "{base}/api/logs?cluster=fake&node=n0&recipe=deepseek-v4-flash"
  99. ))
  100. .send()
  101. .await
  102. .unwrap();
  103. assert_eq!(res.status(), 200);
  104. let body: serde_json::Value = res.json().await.unwrap();
  105. assert_eq!(body["attach_id"], attach_id);
  106. assert!(body["history"].as_array().unwrap().is_empty());
  107. // Unknown cluster -> 400.
  108. let res = client
  109. .post(format!("{base}/api/logs"))
  110. .json(&serde_json::json!({ "cluster": "nope", "node": "n0", "recipe": "r" }))
  111. .send()
  112. .await
  113. .unwrap();
  114. assert_eq!(res.status(), 400);
  115. // Detach -> 204, then 404.
  116. let res = client
  117. .delete(format!("{base}/api/logs"))
  118. .json(&serde_json::json!({ "attach_id": attach_id }))
  119. .send()
  120. .await
  121. .unwrap();
  122. assert_eq!(res.status(), 204);
  123. let res = client
  124. .delete(format!("{base}/api/logs"))
  125. .json(&serde_json::json!({ "attach_id": attach_id }))
  126. .send()
  127. .await
  128. .unwrap();
  129. assert_eq!(res.status(), 404);
  130. }
  131. #[tokio::test]
  132. async fn log_event_serializes_with_type_tag() {
  133. let ev = Event::Log {
  134. attach: "l1".into(),
  135. cluster: "fake".into(),
  136. node: "n0".into(),
  137. recipe: "r".into(),
  138. rank: 0,
  139. line: "hello".into(),
  140. };
  141. let json = serde_json::to_value(&ev).unwrap();
  142. assert_eq!(json["type"], "log");
  143. assert_eq!(json["attach"], "l1");
  144. assert_eq!(json["line"], "hello");
  145. }
  146. // --- P4: raw content -------------------------------------------------------------
  147. #[tokio::test]
  148. async fn raw_endpoints_serve_config_recipe_effective() {
  149. let (addr, _h) = boot().await;
  150. let base = format!("http://{addr}");
  151. let client = reqwest::Client::new();
  152. // The active config file verbatim (cwd `config.toml` in tests).
  153. let res = client
  154. .get(format!("{base}/api/raw/config"))
  155. .send()
  156. .await
  157. .unwrap();
  158. assert_eq!(res.status(), 200, "repo config.toml readable");
  159. assert!(
  160. res.text().await.unwrap().contains("ssh_user"),
  161. "config verbatim"
  162. );
  163. // Effective: merged config as JSON; the fake cluster is present and the
  164. // (absent) HA token never leaks.
  165. let res = client
  166. .get(format!("{base}/api/raw/effective"))
  167. .send()
  168. .await
  169. .unwrap();
  170. assert_eq!(res.status(), 200);
  171. let body: serde_json::Value = res.json().await.unwrap();
  172. let names: Vec<&str> = body["clusters"]
  173. .as_array()
  174. .unwrap()
  175. .iter()
  176. .map(|c| c["name"].as_str().unwrap())
  177. .collect();
  178. assert!(names.contains(&"fake"), "{names:?}");
  179. assert_eq!(body["ssh_user"], "u");
  180. assert!(
  181. body.get("home_assistant")
  182. .map(|v| v.is_null())
  183. .unwrap_or(true)
  184. );
  185. // Recipe: seed our own fixture (recipe data itself lives in
  186. // git.kilork.org/sm121/recipes).
  187. std::fs::create_dir_all("recipes/ztest-raw-fixture").unwrap();
  188. std::fs::write(
  189. "recipes/ztest-raw-fixture/recipe.toml",
  190. "name = \"ztest-raw-fixture\"\nengine = \"vllm\"\n",
  191. )
  192. .unwrap();
  193. let res = client
  194. .get(format!("{base}/api/raw/recipe/ztest-raw-fixture"))
  195. .send()
  196. .await
  197. .unwrap();
  198. let _ = std::fs::remove_dir_all("recipes/ztest-raw-fixture");
  199. assert_eq!(res.status(), 200, "fixture recipe readable");
  200. let text = res.text().await.unwrap();
  201. assert!(text.contains("engine"), "recipe.toml verbatim");
  202. // Unknown recipe 404s; path traversal is refused before any file access.
  203. assert_eq!(
  204. client
  205. .get(format!("{base}/api/raw/recipe/no-such-recipe"))
  206. .send()
  207. .await
  208. .unwrap()
  209. .status(),
  210. 404
  211. );
  212. assert_eq!(
  213. client
  214. .get(format!("{base}/api/raw/recipe/..%2F..%2Fetc"))
  215. .send()
  216. .await
  217. .unwrap()
  218. .status(),
  219. 400,
  220. "traversal name refused"
  221. );
  222. }