tests.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. //! Web module tests: bus semantics, token gate, and a full server boot on an
  2. //! ephemeral port with a fake (unreachable) node.
  3. use crate::config::{Cluster, Config, Node};
  4. use crate::web::events::{Event, build_snapshot};
  5. use crate::web::{AppState, router};
  6. use std::collections::HashMap;
  7. use std::net::SocketAddr;
  8. pub(crate) async fn call(addr: SocketAddr, path: &str) -> reqwest::Response {
  9. reqwest::Client::new()
  10. .get(format!("http://{addr}{path}"))
  11. .send()
  12. .await
  13. .unwrap()
  14. }
  15. /// Config with one node at 127.0.0.1:2222 — connection refused instantly,
  16. /// machine-independent (no real SSH, no hardware).
  17. pub(crate) fn fake_config() -> Config {
  18. Config {
  19. ssh_user: "u".into(),
  20. ssh_key_path: "/nonexistent/key".into(),
  21. clusters: vec![Cluster {
  22. name: "fake".into(),
  23. master_ib_ip: None,
  24. nodes: vec![Node {
  25. name: "n0".into(),
  26. ssh_ip: "127.0.0.1:2222".into(),
  27. ib_ip: None,
  28. rank: 0,
  29. host_key: None,
  30. entity: None,
  31. }],
  32. extra_entities: vec![],
  33. }],
  34. nccl: HashMap::new(),
  35. hf_cache_dir: None,
  36. init_port: 25000,
  37. before_serve: HashMap::new(),
  38. exec_aliases: HashMap::new(),
  39. home_assistant: None,
  40. mikrotik: None,
  41. global_mods_dir: None,
  42. drop_caches: true,
  43. mount_aliases: HashMap::new(),
  44. recipe_sources: Vec::new(),
  45. docker_image_sources: Vec::new(),
  46. cluster_sources: Vec::new(),
  47. registry: None,
  48. }
  49. }
  50. pub(crate) fn app_state(token: Option<&str>) -> AppState {
  51. app_state_with(fake_config(), token)
  52. }
  53. fn app_state_with(config: Config, token: Option<&str>) -> AppState {
  54. let (tx, _) = tokio::sync::broadcast::channel(16);
  55. AppState {
  56. config: std::sync::Arc::new(config),
  57. bus: tx,
  58. token: token.map(String::from),
  59. last: std::sync::Arc::new(std::sync::Mutex::new(None)),
  60. jobs: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
  61. log_attaches: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
  62. }
  63. }
  64. pub(crate) async fn boot() -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandle<()>>) {
  65. boot_with(fake_config()).await
  66. }
  67. async fn boot_with(config: Config) -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandle<()>>) {
  68. boot_app(app_state_with(config, None)).await
  69. }
  70. async fn boot_app(app: AppState) -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandle<()>>) {
  71. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
  72. let addr = listener.local_addr().unwrap();
  73. let handle = tokio::spawn(async move {
  74. axum::serve(listener, router(app)).await.unwrap();
  75. });
  76. (addr, std::sync::Arc::new(handle))
  77. }
  78. #[tokio::test]
  79. async fn api_state_reports_unreachable_node() {
  80. let (addr, handle) = boot().await;
  81. let body: serde_json::Value = reqwest::get(format!("http://{addr}/api/state"))
  82. .await
  83. .unwrap()
  84. .json()
  85. .await
  86. .unwrap();
  87. assert_eq!(body["clusters"].as_array().unwrap().len(), 1);
  88. assert_eq!(body["clusters"][0]["name"], "fake");
  89. assert_eq!(body["clusters"][0]["nodes"][0]["reachable"], false);
  90. assert_eq!(body["clusters"][0]["free"], 0);
  91. handle.abort();
  92. }
  93. /// The recipes panel is mounted under /recipes: the page, its own SSE
  94. /// stream and the /r/ viewer all live behind the prefix.
  95. #[tokio::test]
  96. async fn recipes_panel_is_mounted_under_prefix() {
  97. let (addr, handle) = boot().await;
  98. let page = reqwest::get(format!("http://{addr}/recipes"))
  99. .await
  100. .unwrap()
  101. .text()
  102. .await
  103. .unwrap();
  104. // its own live stream resolves behind the prefix (headers arrive at once)
  105. let sse = reqwest::get(format!("http://{addr}/recipes/events"))
  106. .await
  107. .unwrap();
  108. assert_eq!(sse.status(), 200);
  109. assert!(
  110. page.contains("auth(\"/recipes/events\")"),
  111. "prefix not applied"
  112. );
  113. assert!(!page.contains("auth(\"/events\")"));
  114. // the dashboard's own routes still resolve
  115. let ui = reqwest::get(format!("http://{addr}/"))
  116. .await
  117. .unwrap()
  118. .text()
  119. .await
  120. .unwrap();
  121. assert!(ui.contains("recwin") || ui.contains("Recipes"));
  122. handle.abort();
  123. }
  124. #[tokio::test]
  125. async fn token_gate_enforces_when_set() {
  126. let app = app_state(Some("s3cret"));
  127. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
  128. let addr = listener.local_addr().unwrap();
  129. tokio::spawn(async move {
  130. axum::serve(listener, router(app)).await.unwrap();
  131. });
  132. let base = format!("http://{addr}/api/state");
  133. // No token → 401.
  134. assert_eq!(reqwest::get(&base).await.unwrap().status(), 401);
  135. // Wrong token → 401.
  136. assert_eq!(
  137. reqwest::get(format!("{base}?token=wrong"))
  138. .await
  139. .unwrap()
  140. .status(),
  141. 401
  142. );
  143. // Right token (query form — what SSE clients use) → 200.
  144. assert_eq!(
  145. reqwest::get(format!("{base}?token=s3cret"))
  146. .await
  147. .unwrap()
  148. .status(),
  149. 200
  150. );
  151. }
  152. #[test]
  153. fn jobs_persist_and_mark_interrupted() {
  154. use crate::web::jobs;
  155. let app = app_state(None);
  156. let dir = std::env::temp_dir().join(format!("sm121-jobs-test-{}", std::process::id()));
  157. std::fs::create_dir_all(&dir).unwrap();
  158. let path = dir.join("jobs.json");
  159. // a registry snapshot with a running job: the process died mid-flight
  160. std::fs::write(
  161. &path,
  162. r#"[{"id":"j9","kind":"serve","cluster":"c","recipe":"r","status":"running","message":"started","started":1},
  163. {"id":"j8","kind":"power","cluster":"c","recipe":null,"status":"ok","message":"on","started":2}]"#,
  164. )
  165. .unwrap();
  166. jobs::load_from(Some(&path), &app);
  167. let jobs = app.jobs.lock().unwrap().clone();
  168. std::fs::remove_dir_all(&dir).unwrap();
  169. assert_eq!(jobs.len(), 2);
  170. let running = jobs.iter().find(|j| j.id == "j9").unwrap();
  171. assert_eq!(
  172. running.status,
  173. jobs::JobStatus::Failed,
  174. "running -> failed on boot"
  175. );
  176. assert!(
  177. running.message.contains("runlock"),
  178. "the ledger hint: {}",
  179. running.message
  180. );
  181. let done = jobs.iter().find(|j| j.id == "j8").unwrap();
  182. assert_eq!(done.status, jobs::JobStatus::Ok);
  183. }
  184. #[test]
  185. fn stale_from_snapshot_picks_unreachable_nodes() {
  186. let app = app_state(None);
  187. use crate::web::api::stale_from_snapshot;
  188. let node = |name: &str, ip: &str, reachable: bool| crate::web::events::NodeSnap {
  189. name: name.into(),
  190. ssh_ip: ip.into(),
  191. reachable,
  192. occupied: false,
  193. containers: Vec::new(),
  194. rank: 0,
  195. ha: None,
  196. };
  197. let snap = crate::web::events::Snapshot {
  198. ts: 1,
  199. clusters: vec![crate::web::events::ClusterSnap {
  200. name: "c".into(),
  201. up: false,
  202. switch_off: false,
  203. soft_off: Vec::new(),
  204. free: 0,
  205. nodes: vec![node("a", "1.1.1.1", true), node("b", "1.1.1.2", false)],
  206. }],
  207. jobs: Vec::new(),
  208. topo: Default::default(),
  209. attaches: Vec::new(),
  210. };
  211. *app.last.lock().unwrap() = Some(snap);
  212. assert_eq!(stale_from_snapshot(&app, "c"), vec!["b".to_string()]);
  213. assert!(stale_from_snapshot(&app, "other").is_empty());
  214. }
  215. #[tokio::test]
  216. async fn token_gate_covers_recipes_panel() {
  217. let app = app_state(Some("s3cret"));
  218. *app.last.lock().unwrap() = Some(crate::web::events::Snapshot {
  219. ts: 1,
  220. clusters: Vec::new(),
  221. jobs: Vec::new(),
  222. topo: Default::default(),
  223. attaches: Vec::new(),
  224. });
  225. let (addr, handle) = boot_app(app).await;
  226. let base = format!("http://{addr}");
  227. // bare requests are rejected on every panel route...
  228. for path in ["/recipes", "/recipes/events"] {
  229. let st = reqwest::get(format!("{base}{path}"))
  230. .await
  231. .unwrap()
  232. .status();
  233. assert_eq!(st, 401, "{path} must be gated");
  234. }
  235. // ...and the token (query form, what the iframe and SSE use) opens them
  236. let page = reqwest::get(format!("{base}/recipes?token=s3cret"))
  237. .await
  238. .unwrap()
  239. .text()
  240. .await
  241. .unwrap();
  242. assert!(
  243. page.contains("auth(\"/recipes/events\")"),
  244. "page wires the token"
  245. );
  246. let sse = reqwest::get(format!("{base}/recipes/events?token=s3cret"))
  247. .await
  248. .unwrap();
  249. assert_eq!(sse.status(), 200);
  250. handle.abort();
  251. }
  252. #[tokio::test]
  253. async fn sse_replays_cached_snapshot_on_connect() {
  254. use tokio::io::{AsyncReadExt, AsyncWriteExt};
  255. // Nothing is broadcast after this point: the frame must come from the
  256. // cached snapshot, otherwise a fresh client waits a whole poll tick.
  257. let app = app_state(None);
  258. *app.last.lock().unwrap() = Some(crate::web::events::Snapshot {
  259. ts: 1,
  260. clusters: Vec::new(),
  261. jobs: Vec::new(),
  262. topo: Default::default(),
  263. attaches: Vec::new(),
  264. });
  265. let (addr, handle) = boot_app(app).await;
  266. let mut sock = tokio::net::TcpStream::connect(addr).await.unwrap();
  267. sock.write_all(b"GET /events HTTP/1.1\r\nHost: x\r\n\r\n")
  268. .await
  269. .unwrap();
  270. let mut buf = vec![0u8; 4096];
  271. let n = sock.read(&mut buf).await.unwrap();
  272. let text = String::from_utf8_lossy(&buf[..n]);
  273. assert!(
  274. text.contains("\"type\":\"state\""),
  275. "no replayed frame: {text}"
  276. );
  277. handle.abort();
  278. }
  279. #[tokio::test]
  280. async fn bus_delivers_state_events() {
  281. let (tx, mut rx) = tokio::sync::broadcast::channel::<Event>(8);
  282. let snap = crate::web::events::Snapshot {
  283. ts: 1,
  284. clusters: Vec::new(),
  285. jobs: Vec::new(),
  286. topo: Default::default(),
  287. attaches: Vec::new(),
  288. };
  289. tx.send(Event::State {
  290. ts: snap.ts,
  291. snapshot: snap.clone(),
  292. })
  293. .unwrap();
  294. let got = rx.recv().await.unwrap();
  295. match got {
  296. Event::State { ts, snapshot } => {
  297. assert_eq!(ts, 1);
  298. assert!(snapshot.clusters.is_empty());
  299. }
  300. Event::Job { .. } => panic!("unexpected job event"),
  301. Event::Log { .. } => panic!("unexpected log event"),
  302. }
  303. }
  304. #[tokio::test]
  305. async fn snapshot_serializes_with_type_tag() {
  306. let ev = Event::State {
  307. ts: 7,
  308. snapshot: build_snapshot(
  309. &fake_config(),
  310. &crate::web::probe::Fleet {
  311. nodes: std::collections::HashMap::new(),
  312. switch_off: std::collections::HashMap::new(),
  313. soft_off: std::collections::HashMap::new(),
  314. ha: std::collections::HashMap::new(),
  315. },
  316. &[],
  317. &[],
  318. &std::collections::HashMap::new(),
  319. ),
  320. };
  321. let json = serde_json::to_value(&ev).unwrap();
  322. assert_eq!(json["type"], "state");
  323. assert_eq!(json["ts"], 7);
  324. assert!(json["snapshot"].is_object());
  325. }
  326. // (P2/P3/P4 API round-trip tests moved to api_tests.rs — 500-line cap)
  327. /// Click-through UI tests: boots the real server on an ephemeral port and
  328. /// drives the embedded ui.html with Playwright (deno, npm-free).
  329. /// Skipped when deno or the chromium binary is unavailable.
  330. #[tokio::test]
  331. async fn ui_playwright_checks() {
  332. if std::process::Command::new("deno")
  333. .arg("--version")
  334. .output()
  335. .is_err()
  336. {
  337. eprintln!("SKIP ui_playwright_checks: deno not available");
  338. return;
  339. }
  340. let (addr, _h) = boot_with(playwright_config()).await;
  341. // tokio::process (not std::process::output — that would block this
  342. // runtime's only thread while the axum server runs on it → deadlock).
  343. let out = tokio::process::Command::new("deno")
  344. .arg("run")
  345. .arg("-A")
  346. .arg("src/web/ui.playwright.mjs")
  347. .env("SM121_WEB_URL", format!("http://{addr}"))
  348. .output()
  349. .await
  350. .unwrap();
  351. let stdout = String::from_utf8_lossy(&out.stdout);
  352. let stderr = String::from_utf8_lossy(&out.stderr);
  353. if stdout.contains("SKIP") {
  354. eprintln!("SKIP ui_playwright_checks:\n{stdout}");
  355. return;
  356. }
  357. assert!(
  358. out.status.success(),
  359. "playwright UI checks failed:\n{stdout}\n{stderr}"
  360. );
  361. eprintln!("{stdout}");
  362. }
  363. /// Two offline clusters (both nodes on refused loopback ports) — the
  364. /// fleet the Playwright UI checks render: chip strip with two chips,
  365. /// down-cluster action bar, type-the-name power flow.
  366. fn playwright_config() -> Config {
  367. let mut cfg = fake_config();
  368. cfg.clusters.push(Cluster {
  369. name: "fake2".into(),
  370. master_ib_ip: None,
  371. nodes: vec![Node {
  372. name: "n1".into(),
  373. ssh_ip: "127.0.0.1:2223".into(),
  374. ib_ip: None,
  375. rank: 0,
  376. host_key: None,
  377. entity: None,
  378. }],
  379. extra_entities: vec![],
  380. });
  381. cfg
  382. }