//! 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 Queued 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 openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore}; use openpxe_http_api::{build_router, AppState}; use openpxe_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 = DeploymentQueue::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 hosts = HostBindings::load_or_default(dir.path()); let metrics = Metrics::new(); let state = AppState { iso_store, clients, queue: gates, settings, hosts, metrics, 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 queued_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/queue/join?mac=aa:bb:cc:00:00:01").await; let (_, join2) = get(&app, "/api/queue/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/queue/poll/")) .unwrap().to_string(); let gate2_id = s2.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/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/queue/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","entry_ids":["{gate2_id}"]}}"#); let (s, b) = post_json(&app, "/api/queue/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 = r#"{"target":"fake-alpine-linux","entry_ids":[]}"#; post_json(&app, "/api/queue/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/queue/poll/"), "retry fallback missing"); // Bad target must be rejected. let (_, bad) = post_json(&app, "/api/queue/assign", r#"{"target":"does-not-exist","entry_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"), ("/assets/loader.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("OpenPXE 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("OpenPXE"), "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 windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() { // Synthesize an ISO with a Windows volume label + the sources/boot.wim // sentinel so introspection labels it WindowsPe with has_boot_wim. 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"WIN11_X64".to_vec(); let mut padded = label.clone(); padded.resize(32, b' '); buf[off + 40..off + 40 + 32].copy_from_slice(&padded); // Sprinkle the sources/boot.wim sentinel where the introspection // scanner will find it (anywhere in the first 64 MB). let sentinel = b"SOURCES\\BOOT.WIM"; buf.extend_from_slice(sentinel); let term = 17 * 2048; buf[term] = 0xFF; buf[term + 1..term + 6].copy_from_slice(b"CD001"); buf[term + 6] = 0x01; let (state, _dir) = build_state().await; let app = build_router(state); // Need windows_enabled for the Windows path to render in the menu. let res = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/settings") .header("content-type", "application/json") .body(Body::from( r#"{"boot_menu_timeout_secs":600,"timeout_action":"queued_deployment", "windows_enabled":false,"smb_host_override":"","extra_kernel_args":"", "default_local_hdd":true,"gate_wait_max_secs":0,"dns_server":""}"# .to_string(), )) .unwrap(), ) .await .unwrap(); // wimboot binary is bundled in this repo so windows_enabled=true should // not be rejected; we leave it false to keep the upload path agnostic. assert_eq!(res.status(), StatusCode::NO_CONTENT); let (ct, body) = multipart_iso_body("Win11_x64.iso", &buf); 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); let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap(); let meta: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(meta["introspection"]["family"], "windows_pe"); assert!( meta["introspection"]["has_boot_wim"].as_bool().unwrap(), "introspection should detect sources/boot.wim sentinel" ); // The boot entry should be a wimboot kind with the canonical 5-file // chain documented in the LinusTechTips iPXE-Windows guide. let entry = &meta["boot_entries"][0]; assert_eq!(entry["kind"]["kind"], "wimboot"); let files = entry["kind"]["files"].as_array().unwrap(); let names: Vec<&str> = files.iter().map(|f| f[0].as_str().unwrap()).collect(); assert!(names.contains(&"bootmgr")); assert!(names.contains(&"bootmgr.efi")); assert!(names.contains(&"bcd")); assert!(names.contains(&"boot.sdi")); assert!(names.contains(&"boot.wim")); // Render the entry script and verify: // 1. It uses wimboot // 2. All 5 files are referenced via `initrd --name` // 3. NO trust-store / driver / testsigning operations slip in let entry_id = entry["id"].as_str().unwrap(); let url = format!("/boot/{entry_id}.ipxe"); let (s, body) = get(&app, &url).await; assert_eq!(s, StatusCode::OK); let script = String::from_utf8(body).unwrap(); assert!(script.contains("kernel "), "missing kernel line:\n{script}"); assert!(script.contains("ipxe/wimboot"), "missing wimboot loader:\n{script}"); for tag in ["bootmgr", "bootmgr.efi", "bcd", "boot.sdi", "boot.wim"] { assert!( script.contains(&format!("initrd --name {tag}")), "missing `initrd --name {tag}` line:\n{script}" ); } // Hard guarantees we never want to see in any client-facing script. let lower = script.to_lowercase(); for forbidden in [ "bcdedit", "testsigning", "certutil", "test-signed", "httpdisk", "/set testsigning", ] { assert!( !lower.contains(forbidden), "forbidden trust-store operation `{forbidden}` in script:\n{script}" ); } } #[tokio::test] async fn host_binding_short_circuits_boot_menu() { let (state, _dir) = build_state().await; let app = build_router(state.clone()); // Pin a MAC to the reserved local-hdd boot shortcut. `_local` is a // built-in target so the upsert validator accepts it without // requiring a real ISO. let res = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/hosts") .header("content-type", "application/json") .body(Body::from( r#"{"mac":"AA:BB:CC:00:00:01","target":"_local","label":"toms-laptop"}"# .to_string(), )) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::CREATED); // Hit /boot.ipxe with the bound MAC and assert we get the // short-circuit chain instead of the menu. let (s1, b1) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01").await; assert_eq!(s1, StatusCode::OK); let body1 = String::from_utf8(b1).unwrap(); assert!( body1.contains("per-MAC binding"), "expected MAC short-circuit, got:\n{body1}" ); assert!(body1.contains("/boot/_local.ipxe")); // And a different MAC still gets the menu. let (s2, b2) = get(&app, "/boot.ipxe?mac=ff:ff:ff:ff:ff:ff").await; assert_eq!(s2, StatusCode::OK); let body2 = String::from_utf8(b2).unwrap(); assert!( body2.contains("menu") || body2.contains("Default"), "expected interactive menu, got:\n{body2}" ); } #[tokio::test] async fn metrics_endpoint_emits_prometheus_format() { let (state, _dir) = build_state().await; let app = build_router(state); // Drive a couple of paths so counters move off zero. let _ = get(&app, "/api/status").await; let _ = get(&app, "/boot.ipxe").await; let res = app .clone() .oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(res.status(), StatusCode::OK); let ct = res.headers().get(header::CONTENT_TYPE).unwrap().to_str().unwrap(); assert!( ct.starts_with("text/plain"), "wrong content-type: {ct}" ); let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap(); let body = String::from_utf8(body.to_vec()).unwrap(); // Spot-check the must-have metric families. for name in [ "openpxe_dhcp_replies_total", "openpxe_tftp_transfers_total", "openpxe_http_requests_total", "openpxe_iso_count", "openpxe_uptime_seconds", "openpxe_build_info", ] { assert!(body.contains(name), "missing metric {name} in:\n{body}"); } // Each name appears exactly once as a `# TYPE` declaration. for name in [ "openpxe_dhcp_replies_total", "openpxe_iso_count", ] { let count = body.matches(&format!("# TYPE {name}")).count(); assert_eq!(count, 1, "{name} TYPE line appears {count} times"); } } #[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"); }