//! End-to-end HTTP integration test. //! //! Spins up the real axum router against a temp ISO store + settings store, //! then walks an imaginary iPXE client through: dashboard status → upload //! ISO → fetch top-level boot menu → fetch per-entry script → Range-GET the //! ISO. Also drives the Gated Deployment flow end-to-end: two clients join, //! operator assigns, both polls return the chain script with retry fallback. //! //! This is the closest we can get to "real PXE client" without QEMU; the //! TFTP leg is separately unit-tested in `crates/tftp`. Between the two, //! every HTTP endpoint a real client touches is covered by a test. use axum::body::Body; use axum::http::{header, Request, StatusCode}; use pxeforge_core::{ClientRegistry, GateQueue, LogBus, SettingsStore}; use pxeforge_http_api::{build_router, AppState}; use pxeforge_iso_store::{IsoStore, NfsManager}; use tempfile::tempdir; use tower::ServiceExt; /// Build a tiny valid ISO9660 blob with volume label "ALPINE-TEST" so /// introspection identifies it as Alpine. fn fake_alpine_iso() -> Vec { let mut buf = vec![0u8; 32 * 2048]; let off = 16 * 2048; buf[off] = 0x01; buf[off + 1..off + 6].copy_from_slice(b"CD001"); buf[off + 6] = 0x01; let label = b"ALPINE-TEST".to_vec(); let mut padded = label.clone(); padded.resize(32, b' '); buf[off + 40..off + 40 + 32].copy_from_slice(&padded); let term = 17 * 2048; buf[term] = 0xFF; buf[term + 1..term + 6].copy_from_slice(b"CD001"); buf[term + 6] = 0x01; buf } fn multipart_iso_body(filename: &str, bytes: &[u8]) -> (String, Vec) { let boundary = "----PxeForgeTestBoundary1234"; let mut body = Vec::new(); body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); body.extend_from_slice( format!( "Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n" ).as_bytes(), ); body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n"); body.extend_from_slice(bytes); body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); let ct = format!("multipart/form-data; boundary={boundary}"); (ct, body) } async fn get(router: &axum::Router, path: &str) -> (StatusCode, Vec) { let res = router .clone() .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) .await .unwrap(); let status = res.status(); let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec(); (status, body) } async fn post_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec) { let res = router .clone() .oneshot( Request::builder() .method("POST") .uri(path) .header("content-type", "application/json") .body(Body::from(body.to_owned())) .unwrap(), ) .await .unwrap(); let status = res.status(); let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec(); (status, body) } async fn build_state() -> (AppState, tempfile::TempDir) { let dir = tempdir().unwrap(); let iso_store = IsoStore::new(dir.path().join("isos")); iso_store.ensure_dirs().await.unwrap(); let clients = ClientRegistry::new(); let gates = GateQueue::new(); let settings = SettingsStore::load_or_default(dir.path()); let nfs = NfsManager::new(dir.path(), iso_store.clone()); iso_store.set_nfs_root(nfs.mount_root()); let log_bus = LogBus::new(64); let state = AppState { iso_store, clients, gates, settings, smb: None, nfs, log_bus, started_at: time::OffsetDateTime::now_utc(), public_base_url: "http://127.0.0.1".into(), nic_name: "lo".into(), subnet_mask: "255.0.0.0".into(), gateway: "127.0.0.1".into(), }; (state, dir) } #[tokio::test] async fn health_and_ready_endpoints() { let (state, _dir) = build_state().await; let app = build_router(state); let (s, b) = get(&app, "/healthz").await; assert_eq!(s, StatusCode::OK); assert_eq!(b, b"ok\n"); // /readyz should 503 when no iPXE binaries bundled at test time — this // actually depends on whether CI has fetched them. Accept either. let (s2, _) = get(&app, "/readyz").await; assert!( s2 == StatusCode::OK || s2 == StatusCode::SERVICE_UNAVAILABLE, "unexpected readyz status: {s2}" ); } #[tokio::test] async fn upload_introspects_and_generates_boot_entry() { let (state, _dir) = build_state().await; let app = build_router(state.clone()); let iso = fake_alpine_iso(); let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso); let res = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/isos") .header("content-type", ct) .body(Body::from(body)) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::CREATED, "upload failed"); // Confirm the ISO shows up in the menu. let (_, menu) = get(&app, "/boot.ipxe").await; let menu = String::from_utf8(menu).unwrap(); assert!(menu.contains("Linux Installers"), "menu missing Linux submenu:\n{menu}"); let (_, linux) = get(&app, "/boot/_linux_menu.ipxe").await; let linux = String::from_utf8(linux).unwrap(); assert!(linux.contains("fake-alpine-linux"), "linux submenu missing entry:\n{linux}"); assert!(linux.contains("[ 0 MB]") || linux.contains("[ 0 MB]"), "size label missing in {linux}"); // Per-entry boot script should include kernel + initrd URLs + boot. let (_, entry) = get(&app, "/boot/fake-alpine-linux.ipxe").await; let entry = String::from_utf8(entry).unwrap(); assert!(entry.contains("kernel http://127.0.0.1/iso/fake-alpine/boot/vmlinuz-lts")); assert!(entry.contains("initrd http://127.0.0.1/iso/fake-alpine/boot/initramfs-lts")); assert!(entry.contains("boot || goto failed")); } #[tokio::test] async fn iso_range_request_slices_correctly() { let (state, _dir) = build_state().await; let app = build_router(state.clone()); let iso = fake_alpine_iso(); let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso); app.clone() .oneshot( Request::builder() .method("POST").uri("/api/isos") .header("content-type", ct) .body(Body::from(body)).unwrap()).await.unwrap(); // Range bytes=0x8000-0x8005 should return the PVD signature byte. let res = app .clone() .oneshot( Request::builder() .uri("/iso/fake-alpine.iso") .header(header::RANGE, "bytes=32768-32773") .body(Body::empty()).unwrap()) .await.unwrap(); assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT); let slice = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap(); assert_eq!(slice[0], 0x01); // PVD type assert_eq!(&slice[1..6], b"CD001"); } #[tokio::test] async fn gated_deployment_full_flow() { let (state, _dir) = build_state().await; let app = build_router(state.clone()); // Upload an ISO so the target exists. let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); app.clone() .oneshot(Request::builder().method("POST").uri("/api/isos") .header("content-type", ct).body(Body::from(body)).unwrap()) .await.unwrap(); // Two clients join. let (_, join1) = get(&app, "/api/gate/join?mac=aa:bb:cc:00:00:01").await; let (_, join2) = get(&app, "/api/gate/join?mac=aa:bb:cc:00:00:02").await; let s1 = String::from_utf8(join1).unwrap(); let s2 = String::from_utf8(join2).unwrap(); assert!(s1.contains("Gate Position 1")); assert!(s2.contains("Gate Position 2")); let gate1_id = s1.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/gate/poll/")) .unwrap().to_string(); let gate2_id = s2.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/gate/poll/")) .unwrap().to_string(); // Kick off a long-poll for client 1 in the background. Then assign. let app2 = app.clone(); let poll_future = tokio::spawn(async move { let uri = format!("/api/gate/poll/{gate1_id}"); get(&app2, &uri).await }); // Give the poll a moment to register its notify subscription. tokio::time::sleep(std::time::Duration::from_millis(50)).await; // Operator assigns. let body = format!(r#"{{"target":"fake-alpine-linux","gate_ids":["{gate2_id}"]}}"#); let (s, b) = post_json(&app, "/api/gate/assign", &body).await; assert_eq!(s, StatusCode::OK); let assign_json = String::from_utf8(b).unwrap(); assert!(assign_json.contains(r#""assigned":1"#), "assign response: {assign_json}"); // Now assign to gate 1 too so the background poll wakes. let body = format!(r#"{{"target":"fake-alpine-linux","gate_ids":[]}}"#); post_json(&app, "/api/gate/assign", &body).await; let (poll_status, poll_body) = poll_future.await.unwrap(); assert_eq!(poll_status, StatusCode::OK); let poll_s = String::from_utf8(poll_body).unwrap(); assert!( poll_s.contains("chain http://127.0.0.1/boot/fake-alpine-linux.ipxe"), "poll response should chain the boot script:\n{poll_s}" ); // Retry-on-error fallback must be present. assert!(poll_s.contains("|| chain http://127.0.0.1/api/gate/poll/"), "retry fallback missing"); // Bad target must be rejected. let (_, bad) = post_json(&app, "/api/gate/assign", r#"{"target":"does-not-exist","gate_ids":[]}"#).await; let bad_s = String::from_utf8(bad).unwrap(); assert!(bad_s.contains(r#""ok":false"#), "expected rejection: {bad_s}"); } #[tokio::test] async fn settings_put_persists_across_reads() { let (state, _dir) = build_state().await; let app = build_router(state); let body = serde_json::json!({ "boot_menu_timeout_secs": 42, "timeout_action": "local_hdd", "windows_enabled": false, "smb_host_override": "", "extra_kernel_args": "console=ttyS0", "default_local_hdd": true, "gate_wait_max_secs": 0 }).to_string(); let res = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/settings") .header("content-type", "application/json") .body(Body::from(body)) .unwrap()) .await .unwrap(); assert_eq!(res.status(), StatusCode::NO_CONTENT); let (_, g) = get(&app, "/api/settings").await; let got: serde_json::Value = serde_json::from_slice(&g).unwrap(); assert_eq!(got["boot_menu_timeout_secs"], 42); assert_eq!(got["timeout_action"], "local_hdd"); assert_eq!(got["extra_kernel_args"], "console=ttyS0"); // And the menu should now use the new timeout. let (_, menu) = get(&app, "/boot.ipxe").await; let menu = String::from_utf8(menu).unwrap(); assert!(menu.contains("--timeout 42000"), "menu should reflect 42s timeout:\n{menu}"); assert!(menu.contains("--default local"), "menu should default to local:\n{menu}"); } #[tokio::test] async fn reboot_and_firmware_exit_in_tools_menu() { let (state, _dir) = build_state().await; let app = build_router(state); let (_, tools) = get(&app, "/boot/_tools_menu.ipxe").await; let tools = String::from_utf8(tools).unwrap(); assert!(tools.contains("Reboot Computer"), "tools menu missing Reboot item:\n{tools}"); assert!(tools.contains("Exit and continue BIOS boot"), "tools menu missing firmware-exit item:\n{tools}"); assert!(tools.contains("&& reboot"), "reboot command not wired:\n{tools}"); assert!(tools.contains("&& exit 0"), "firmware exit command not wired:\n{tools}"); } #[tokio::test] async fn ui_assets_served_offline() { let (state, _dir) = build_state().await; let app = build_router(state); for (path, ct) in [ ("/", "text/html"), ("/assets/app.js", "application/javascript"), ("/assets/app.css", "text/css"), ("/assets/logo.svg", "image/svg+xml"), ] { let res = app .clone() .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) .await.unwrap(); assert_eq!(res.status(), StatusCode::OK, "{path} not 200"); let got = res.headers() .get(header::CONTENT_TYPE).unwrap() .to_str().unwrap(); assert!(got.starts_with(ct), "{path} ct={got}, expected {ct}"); } } #[tokio::test] async fn no_external_urls_in_generated_ipxe() { // Sanity check that nothing we serve points off-server. let (state, _dir) = build_state().await; let app = build_router(state); for path in ["/boot.ipxe", "/boot/_tools_menu.ipxe", "/boot/_linux_menu.ipxe", "/boot/_shell.ipxe", "/boot/_nic.ipxe", "/boot/_local.ipxe"] { let (_, body) = get(&app, path).await; let s = String::from_utf8(body).unwrap(); // The only URLs we should emit are relative to our own public_base_url. for url in ["github.com", "googleapis", "cdn.", "cdnjs", "unpkg", "jsdelivr"] { assert!(!s.contains(url), "{path} references external host {url}:\n{s}"); } // Confirm URLs are all ours. for line in s.lines() { if let Some(idx) = line.find("http://") { let rest = &line[idx..]; assert!(rest.starts_with("http://127.0.0.1"), "{path} references non-public-base URL: {line}"); } } } } // ── Phase 4 integration tests ──────────────────────────────────────────── #[tokio::test] async fn nfs_add_with_bad_export_is_rejected() { // Validation must happen before we shell out to /bin/mount — // otherwise the operator sees opaque kernel errors instead of a // clear "your export must start with /" hint. let (state, _dir) = build_state().await; let app = build_router(state); let (s, b) = post_json( &app, "/api/nfs", r#"{"server":"10.0.0.5","export":"isos","version":"v41","read_only":true}"#, ) .await; assert_eq!(s, StatusCode::BAD_REQUEST); let msg = String::from_utf8_lossy(&b); assert!(msg.contains("export"), "expected validation hint, got: {msg}"); } #[tokio::test] async fn nfs_list_starts_empty() { let (state, _dir) = build_state().await; let app = build_router(state); let (s, b) = get(&app, "/api/nfs").await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); assert_eq!(v["mounts"].as_array().unwrap().len(), 0); } #[tokio::test] async fn terminal_help_and_status_round_trip() { let (state, _dir) = build_state().await; let app = build_router(state); // Empty command -> help banner. let (s, b) = post_json(&app, "/api/terminal", r#"{"command":""}"#).await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); assert!(v["output"].as_str().unwrap().contains("PXEForge terminal")); // status -> contains the version banner. let (s, b) = post_json(&app, "/api/terminal", r#"{"command":"status"}"#).await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); let out = v["output"].as_str().unwrap(); assert!(out.starts_with("PXEForge"), "unexpected status output: {out}"); assert!(out.contains("isos:"), "status missing iso line: {out}"); // Unknown command -> ok=false plus help hint. let (_, b) = post_json(&app, "/api/terminal", r#"{"command":"frobnicate"}"#).await; let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); assert_eq!(v["ok"], false); assert!(v["output"].as_str().unwrap().contains("unknown command")); } #[tokio::test] async fn log_recent_returns_buffered_lines() { // The terminal command we issued seeds the log bus, so a follow-up // /api/log/recent must surface those lines as JSON. let (state, _dir) = build_state().await; let app = build_router(state); let _ = post_json(&app, "/api/terminal", r#"{"command":"version"}"#).await; let (s, b) = get(&app, "/api/log/recent").await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); let lines = v["lines"].as_array().expect("lines array"); assert!(!lines.is_empty(), "log buffer should have at least one line"); // Every entry should have the canonical timestamp/level/target/message. for l in lines { for k in ["timestamp", "level", "target", "message"] { assert!(l.get(k).is_some(), "missing field {k} in log line: {l}"); } } } #[tokio::test] async fn network_endpoint_exposes_dns_round_trip() { let (state, _dir) = build_state().await; let app = build_router(state); // GET starts blank. let (_, b) = get(&app, "/api/network").await; let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); assert_eq!(v["dns_server"], ""); assert_eq!(v["nic_name"], "lo"); // PUT updates only the DNS field. let res = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/network") .header("content-type", "application/json") .body(Body::from(r#"{"dns_server":"10.0.0.1"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::NO_CONTENT); let (_, b) = get(&app, "/api/network").await; let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); assert_eq!(v["dns_server"], "10.0.0.1"); }