|
|
@@ -8,7 +8,7 @@ use axum::http::StatusCode;
|
|
|
use std::collections::HashMap;
|
|
|
use std::net::SocketAddr;
|
|
|
|
|
|
-async fn call(addr: SocketAddr, path: &str) -> reqwest::Response {
|
|
|
+pub(crate) async fn call(addr: SocketAddr, path: &str) -> reqwest::Response {
|
|
|
reqwest::Client::new()
|
|
|
.get(format!("http://{addr}{path}"))
|
|
|
.send()
|
|
|
@@ -49,7 +49,7 @@ pub(crate) fn fake_config() -> Config {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-fn app_state(token: Option<&str>) -> AppState {
|
|
|
+pub(crate) fn app_state(token: Option<&str>) -> AppState {
|
|
|
app_state_with(fake_config(), token)
|
|
|
}
|
|
|
|
|
|
@@ -65,7 +65,7 @@ fn app_state_with(config: Config, token: Option<&str>) -> AppState {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-async fn boot() -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandle<()>>) {
|
|
|
+pub(crate) async fn boot() -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandle<()>>) {
|
|
|
boot_with(fake_config()).await
|
|
|
}
|
|
|
|
|
|
@@ -170,70 +170,7 @@ async fn snapshot_serializes_with_type_tag() {
|
|
|
assert!(json["snapshot"].is_object());
|
|
|
}
|
|
|
|
|
|
-// --- 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();
|
|
|
- let arr = v["recipes"].as_array().unwrap();
|
|
|
- assert!(!arr.is_empty(), "repo recipes/ should be discoverable");
|
|
|
- let names: Vec<&str> = arr.iter().map(|r| r["name"].as_str().unwrap()).collect();
|
|
|
- assert!(
|
|
|
- names.contains(&"qwen38-27b-nvfp4-unsloth"),
|
|
|
- "got: {names:?}"
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-#[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;
|
|
|
-}
|
|
|
+// (P2/P3/P4 API round-trip tests moved to api_tests.rs — 500-line cap)
|
|
|
|
|
|
// --- UI (ui.html) -------------------------------------------------------------
|
|
|
// The embedded page is vanilla JS with no build step; these tests run the
|
|
|
@@ -359,169 +296,3 @@ async fn ui_playwright_checks() {
|
|
|
);
|
|
|
eprintln!("{stdout}");
|
|
|
}
|
|
|
-
|
|
|
-// --- 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: a real recipe from the repo (tests run from the crate root).
|
|
|
- let res = client
|
|
|
- .get(format!("{base}/api/raw/recipe/deepseek-v4-flash"))
|
|
|
- .send()
|
|
|
- .await
|
|
|
- .unwrap();
|
|
|
- assert_eq!(res.status(), 200, "repo 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"
|
|
|
- );
|
|
|
-}
|