api_tests.rs 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  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. /// The recipes panel's ⟳ button: a sync pass over the configured repos
  34. /// (none in the fake config → 0 roots, still ok) and a 409-free single
  35. /// in-flight guard.
  36. #[tokio::test]
  37. async fn repos_sync_endpoint_reports_repo_count() {
  38. let (addr, _h) = boot().await;
  39. let res = reqwest::Client::new()
  40. .post(format!("http://{addr}/api/repos/sync"))
  41. .send()
  42. .await
  43. .unwrap();
  44. assert_eq!(res.status(), StatusCode::OK);
  45. let v: serde_json::Value = res.json().await.unwrap();
  46. assert_eq!(v["ok"], true);
  47. assert!(v["repos"].is_number(), "repos count missing: {v}");
  48. }
  49. #[tokio::test]
  50. async fn serve_endpoint_registers_job_and_rejects_concurrent() {
  51. let (addr, _h) = boot().await;
  52. let body = serde_json::json!({ "cluster": "c1", "recipe": "r1", "dry_run": true });
  53. let res = reqwest::Client::new()
  54. .post(format!("http://{addr}/api/serve"))
  55. .json(&body)
  56. .send()
  57. .await
  58. .unwrap();
  59. assert_eq!(res.status(), StatusCode::OK);
  60. let v: serde_json::Value = res.json().await.unwrap();
  61. let id = v["job"].as_str().unwrap().to_string();
  62. // the dry-run job targets the fake config's clusters? No — "c1" doesn't
  63. // exist in the real config; the job will fail asynchronously (cluster
  64. // resolution error). Single-flight still holds while it runs.
  65. let res2 = reqwest::Client::new()
  66. .post(format!("http://{addr}/api/serve"))
  67. .json(&body)
  68. .send()
  69. .await
  70. .unwrap();
  71. // Either 409 (still running) or 200 (already finished and released).
  72. assert!(
  73. matches!(res2.status(), StatusCode::CONFLICT | StatusCode::OK),
  74. "unexpected status {}",
  75. res2.status()
  76. );
  77. let _ = id;
  78. }
  79. #[tokio::test]
  80. async fn bench_endpoint_registers_job() {
  81. let (addr, _h) = boot().await;
  82. let body = serde_json::json!({ "cluster": "c1", "recipe": "r1" });
  83. let res = reqwest::Client::new()
  84. .post(format!("http://{addr}/api/bench"))
  85. .json(&body)
  86. .send()
  87. .await
  88. .unwrap();
  89. assert_eq!(res.status(), StatusCode::OK);
  90. let v: serde_json::Value = res.json().await.unwrap();
  91. assert!(v["job"].is_string(), "job id missing: {v}");
  92. // the job fails asynchronously (no such recipe in the test config) —
  93. // only the registration path is under test here.
  94. }
  95. // --- P3: log windows -----------------------------------------------------------
  96. #[tokio::test]
  97. async fn log_attach_get_detach_roundtrip() {
  98. let (addr, _h) = boot().await;
  99. let base = format!("http://{addr}");
  100. let client = reqwest::Client::new();
  101. // Attach on the fake cluster (node n0 at a refused port: the stream task
  102. // dies immediately, but the registry wiring is what's under test).
  103. let res = client
  104. .post(format!("{base}/api/logs"))
  105. .json(
  106. &serde_json::json!({ "cluster": "fake", "node": "n0", "recipe": "deepseek-v4-flash" }),
  107. )
  108. .send()
  109. .await
  110. .unwrap();
  111. assert_eq!(res.status(), 200, "attach ok");
  112. let body: serde_json::Value = res.json().await.unwrap();
  113. let attach_id = body["attach_id"].as_str().unwrap().to_string();
  114. assert_eq!(attach_id, "l1");
  115. assert!(body["history"].is_array());
  116. // Idempotent: same key -> same attach.
  117. let res = client
  118. .post(format!("{base}/api/logs"))
  119. .json(
  120. &serde_json::json!({ "cluster": "fake", "node": "n0", "recipe": "deepseek-v4-flash" }),
  121. )
  122. .send()
  123. .await
  124. .unwrap();
  125. let body: serde_json::Value = res.json().await.unwrap();
  126. assert_eq!(body["attach_id"], attach_id);
  127. // History endpoint (empty ring — the SSH stream failed on the refused port).
  128. let res = client
  129. .get(format!(
  130. "{base}/api/logs?cluster=fake&node=n0&recipe=deepseek-v4-flash"
  131. ))
  132. .send()
  133. .await
  134. .unwrap();
  135. assert_eq!(res.status(), 200);
  136. let body: serde_json::Value = res.json().await.unwrap();
  137. assert_eq!(body["attach_id"], attach_id);
  138. assert!(body["history"].as_array().unwrap().is_empty());
  139. // Unknown cluster -> 400.
  140. let res = client
  141. .post(format!("{base}/api/logs"))
  142. .json(&serde_json::json!({ "cluster": "nope", "node": "n0", "recipe": "r" }))
  143. .send()
  144. .await
  145. .unwrap();
  146. assert_eq!(res.status(), 400);
  147. // Detach -> 204, then 404.
  148. let res = client
  149. .delete(format!("{base}/api/logs"))
  150. .json(&serde_json::json!({ "attach_id": attach_id }))
  151. .send()
  152. .await
  153. .unwrap();
  154. assert_eq!(res.status(), 204);
  155. let res = client
  156. .delete(format!("{base}/api/logs"))
  157. .json(&serde_json::json!({ "attach_id": attach_id }))
  158. .send()
  159. .await
  160. .unwrap();
  161. assert_eq!(res.status(), 404);
  162. }
  163. #[tokio::test]
  164. async fn log_event_serializes_with_type_tag() {
  165. let ev = Event::Log {
  166. attach: "l1".into(),
  167. cluster: "fake".into(),
  168. node: "n0".into(),
  169. recipe: "r".into(),
  170. rank: 0,
  171. line: "hello".into(),
  172. };
  173. let json = serde_json::to_value(&ev).unwrap();
  174. assert_eq!(json["type"], "log");
  175. assert_eq!(json["attach"], "l1");
  176. assert_eq!(json["line"], "hello");
  177. }
  178. // --- P4: raw content -------------------------------------------------------------
  179. #[tokio::test]
  180. async fn raw_endpoints_serve_config_recipe_effective() {
  181. let (addr, _h) = boot().await;
  182. let base = format!("http://{addr}");
  183. let client = reqwest::Client::new();
  184. // The active config file verbatim (cwd `config.toml` in tests).
  185. let res = client
  186. .get(format!("{base}/api/raw/config"))
  187. .send()
  188. .await
  189. .unwrap();
  190. assert_eq!(res.status(), 200, "repo config.toml readable");
  191. assert!(
  192. res.text().await.unwrap().contains("ssh_user"),
  193. "config verbatim"
  194. );
  195. // Effective: merged config as JSON; the fake cluster is present and the
  196. // (absent) HA token never leaks.
  197. let res = client
  198. .get(format!("{base}/api/raw/effective"))
  199. .send()
  200. .await
  201. .unwrap();
  202. assert_eq!(res.status(), 200);
  203. let body: serde_json::Value = res.json().await.unwrap();
  204. let names: Vec<&str> = body["clusters"]
  205. .as_array()
  206. .unwrap()
  207. .iter()
  208. .map(|c| c["name"].as_str().unwrap())
  209. .collect();
  210. assert!(names.contains(&"fake"), "{names:?}");
  211. assert_eq!(body["ssh_user"], "u");
  212. assert!(
  213. body.get("home_assistant")
  214. .map(|v| v.is_null())
  215. .unwrap_or(true)
  216. );
  217. // Recipe: seed our own fixture (recipe data itself lives in
  218. // git.kilork.org/sm121/recipes).
  219. std::fs::create_dir_all("recipes/ztest-raw-fixture").unwrap();
  220. std::fs::write(
  221. "recipes/ztest-raw-fixture/recipe.toml",
  222. "name = \"ztest-raw-fixture\"\nengine = \"vllm\"\n",
  223. )
  224. .unwrap();
  225. let res = client
  226. .get(format!("{base}/api/raw/recipe/ztest-raw-fixture"))
  227. .send()
  228. .await
  229. .unwrap();
  230. let _ = std::fs::remove_dir_all("recipes/ztest-raw-fixture");
  231. assert_eq!(res.status(), 200, "fixture recipe readable");
  232. let text = res.text().await.unwrap();
  233. assert!(text.contains("engine"), "recipe.toml verbatim");
  234. // Unknown recipe 404s; path traversal is refused before any file access.
  235. assert_eq!(
  236. client
  237. .get(format!("{base}/api/raw/recipe/no-such-recipe"))
  238. .send()
  239. .await
  240. .unwrap()
  241. .status(),
  242. 404
  243. );
  244. assert_eq!(
  245. client
  246. .get(format!("{base}/api/raw/recipe/..%2F..%2Fetc"))
  247. .send()
  248. .await
  249. .unwrap()
  250. .status(),
  251. 400,
  252. "traversal name refused"
  253. );
  254. }