//! 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 = "----OpenPxeTestBoundary1234"; 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 queue = 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 boot_log = openpxe_core::BootLog::load_or_default(dir.path()); let branding = openpxe_core::BrandingStore::load_or_default(dir.path()); let metrics = Metrics::new(); let state = AppState { iso_store, clients, queue, settings, hosts, boot_log, branding, metrics, smb: None, nfs, uploads: openpxe_http_api::uploads::UploadSessions::default(), 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("Queue Position 1")); assert!(s2.contains("Queue Position 2")); let queue1_id = s1 .lines() .find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/")) .unwrap() .to_string(); let queue2_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/{queue1_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":["{queue2_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 queue entry 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, "queue_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,"queue_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"); } // ─── Per-ISO password prompt ────────────────────────────────────────────── #[tokio::test] async fn iso_password_prompt_blocks_until_correct_token() { let (state, _dir) = build_state().await; let app = build_router(state); // Upload a synthetic Alpine ISO so we have a real boot entry id to // protect. Upload filename "fake-alpine.iso" -> id "fake-alpine", // boot entry id "fake-alpine-linux". 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); // 1. With NO password set, /boot/.ipxe returns the boot script // immediately and the lock indicator is NOT in the menu. let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe").await; let s = String::from_utf8(body).unwrap(); assert!(s.contains("kernel "), "expected boot script, got:\n{s}"); let (_, lm) = get(&app, "/boot/_linux_menu.ipxe").await; let lm = String::from_utf8(lm).unwrap(); assert!(lm.contains("fake-alpine-linux")); assert!( !lm.contains("fake-alpine-linux *["), "expected no lock marker in menu before password set:\n{lm}" ); // 2. Set a password. let res = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/isos/fake-alpine/password") .header("content-type", "application/json") .body(Body::from(r#"{"password":"hunter2"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::NO_CONTENT); // The menu now shows the lock marker (`*` prefix on the size box). let (_, lm) = get(&app, "/boot/_linux_menu.ipxe").await; let lm = String::from_utf8(lm).unwrap(); assert!( lm.contains("fake-alpine-linux *["), "expected lock marker in menu after password set:\n{lm}" ); // 3. Without a token, /boot/.ipxe now returns the password // PROMPT script (read --secret), not the boot script. let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe").await; let s = String::from_utf8(body).unwrap(); assert!( s.contains("read --secret password"), "expected prompt script with no token, got:\n{s}" ); assert!(!s.contains("kernel "), "should not include kernel line yet"); // 4. Wrong token -> "Wrong password." script that chains back to the entry. let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe?token=wrongpw").await; let s = String::from_utf8(body).unwrap(); assert!( s.contains("Wrong password."), "expected auth-fail script, got:\n{s}" ); assert!(s.contains("/boot/fake-alpine-linux.ipxe")); assert!(!s.contains("kernel ")); // Critical: the WRONG token must NEVER be echoed back in the script. assert!( !s.contains("wrongpw"), "wrong token must not appear in response" ); // 5. Correct token -> real boot script. let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe?token=hunter2").await; let s = String::from_utf8(body).unwrap(); assert!( s.contains("kernel "), "expected boot script with correct token, got:\n{s}" ); // Don't echo the password into the boot script either. assert!( !s.contains("hunter2"), "correct password must not leak into boot script" ); // 6. Clear the password (DELETE). let res = app .clone() .oneshot( Request::builder() .method("DELETE") .uri("/api/isos/fake-alpine/password") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::NO_CONTENT); // Boot is open again, no lock indicator. let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe").await; let s = String::from_utf8(body).unwrap(); assert!( s.contains("kernel "), "expected boot script after clear, got:\n{s}" ); let (_, lm) = get(&app, "/boot/_linux_menu.ipxe").await; let lm = String::from_utf8(lm).unwrap(); assert!(!lm.contains("fake-alpine-linux *[")); } #[tokio::test] async fn iso_password_set_then_clear_via_null_body() { let (state, _dir) = build_state().await; let app = build_router(state); // Upload + set + clear via `{"password": null}` (alternative to DELETE). 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); for body in [ r#"{"password":"x"}"#, r#"{"password":null}"#, r#"{"password":""}"#, ] { let res = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/isos/fake-alpine/password") .header("content-type", "application/json") .body(Body::from(body.to_string())) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::NO_CONTENT, "body={body}"); } // After the empty string, the entry should be unprotected. let (_, b) = get(&app, "/boot/fake-alpine-linux.ipxe").await; let s = String::from_utf8(b).unwrap(); assert!( s.contains("kernel "), "should be unprotected after empty pw, got:\n{s}" ); } #[tokio::test] async fn set_password_for_unknown_iso_returns_404() { let (state, _dir) = build_state().await; let app = build_router(state); let res = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/isos/does-not-exist/password") .header("content-type", "application/json") .body(Body::from(r#"{"password":"x"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn boot_log_records_entry_serve_with_mac() { // End-to-end: upload an ISO, fetch the entry's boot script with a // MAC query param, then GET /api/boot-log and assert the event is // there with the supplied mac. let (state, _dir) = build_state().await; let app = build_router(state); let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); let upload = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/isos") .header("content-type", ct) .body(Body::from(body)) .unwrap(), ) .await .unwrap(); assert_eq!(upload.status(), StatusCode::CREATED); // Fetch the per-entry script with ?mac=... let (s, _) = get(&app, "/boot/fake-alpine-linux.ipxe?mac=AA:BB:CC:00:00:09").await; assert_eq!(s, StatusCode::OK); // The boot log should now contain exactly one entry, with the // normalized MAC and our target id. let (s, body) = get(&app, "/api/boot-log").await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); let events = v["events"].as_array().expect("events"); assert_eq!(events.len(), 1); let ev = &events[0]; assert_eq!(ev["target_id"], "fake-alpine-linux"); assert_eq!(ev["mac"], "aa:bb:cc:00:00:09"); // normalized // Title should include the filename and entry title. let title = ev["target_title"].as_str().unwrap(); assert!(title.contains("fake-alpine.iso"), "title was {title}"); } #[tokio::test] async fn boot_log_endpoint_empty_when_no_boots() { let (state, _dir) = build_state().await; let app = build_router(state); let (s, body) = get(&app, "/api/boot-log").await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!(v["events"].as_array().unwrap().is_empty()); } #[tokio::test] async fn boot_log_does_not_record_reserved_menu_targets() { // Reserved targets (_local, _queue, …) are operator console actions, // not imaging events. The Hosts log skips them so it stays focused // on "what got installed where". let (state, _dir) = build_state().await; let app = build_router(state.clone()); // Bind a MAC to the _local shortcut and hit /boot.ipxe. let body = r#"{"mac":"aa:bb:cc:00:00:11","target":"_local","label":"q"}"#; let (s, _) = post_json(&app, "/api/hosts", body).await; assert_eq!(s, StatusCode::CREATED); let (s, _) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:11").await; assert_eq!(s, StatusCode::OK); let (_, body) = get(&app, "/api/boot-log").await; let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!( v["events"].as_array().unwrap().is_empty(), "reserved targets should not appear in boot log; got {v}" ); } #[tokio::test] async fn upload_rejects_non_iso_filename_with_clear_message() { // Sanity for the upload-logging path: a wrong extension should land // a 400 with the human message rather than silently being eaten by // the multipart loop. (No iso ends up in the store either.) let (state, _dir) = build_state().await; let app = build_router(state); let (ct, body) = multipart_iso_body("not-an-iso.txt", b"hello world"); let res = app .oneshot( Request::builder() .method("POST") .uri("/api/isos") .header("content-type", ct) .body(Body::from(body)) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::BAD_REQUEST); let body = axum::body::to_bytes(res.into_body(), usize::MAX) .await .unwrap(); let text = std::str::from_utf8(&body).unwrap(); assert!(text.contains("only .iso uploads accepted"), "got: {text}"); } #[tokio::test] async fn chunked_upload_writes_progressively_and_finishes_iso() { let (state, dir) = build_state().await; let app = build_router(state); let iso = fake_alpine_iso(); let start = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/uploads") .header("content-type", "application/json") .body(Body::from( r#"{"filename":"chunked-alpine.iso","size_bytes":65536}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(start.status(), StatusCode::CREATED); let body = axum::body::to_bytes(start.into_body(), usize::MAX) .await .unwrap(); let started: serde_json::Value = serde_json::from_slice(&body).unwrap(); let upload_id = started["upload_id"].as_str().unwrap(); let split = 8192usize; let first = app .clone() .oneshot( Request::builder() .method("PUT") .uri(format!("/api/uploads/{upload_id}")) .header("x-openpxe-upload-offset", "0") .body(Body::from(iso[..split].to_vec())) .unwrap(), ) .await .unwrap(); assert_eq!(first.status(), StatusCode::ACCEPTED); let body = axum::body::to_bytes(first.into_body(), usize::MAX) .await .unwrap(); let progress: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(progress["offset"].as_u64().unwrap(), split as u64); assert!(!progress["complete"].as_bool().unwrap()); assert!( dir.path().join("isos/chunked-alpine.partial").exists(), "chunked upload should leave a visible partial file while in progress" ); let final_chunk = app .clone() .oneshot( Request::builder() .method("PUT") .uri(format!("/api/uploads/{upload_id}")) .header("x-openpxe-upload-offset", split.to_string()) .header("x-openpxe-upload-complete", "true") .body(Body::from(iso[split..].to_vec())) .unwrap(), ) .await .unwrap(); assert_eq!(final_chunk.status(), StatusCode::CREATED); let body = axum::body::to_bytes(final_chunk.into_body(), usize::MAX) .await .unwrap(); let finished: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!(finished["complete"].as_bool().unwrap()); assert_eq!(finished["iso"]["id"], "chunked-alpine"); assert!(dir.path().join("isos/chunked-alpine.iso").exists()); assert!(!dir.path().join("isos/chunked-alpine.partial").exists()); } #[tokio::test] async fn chunked_upload_rejects_offset_mismatch_without_advancing() { let (state, _dir) = build_state().await; let app = build_router(state); let start = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/uploads") .header("content-type", "application/json") .body(Body::from( r#"{"filename":"offset-test.iso","size_bytes":16}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(start.status(), StatusCode::CREATED); let body = axum::body::to_bytes(start.into_body(), usize::MAX) .await .unwrap(); let started: serde_json::Value = serde_json::from_slice(&body).unwrap(); let upload_id = started["upload_id"].as_str().unwrap(); let mismatch = app .oneshot( Request::builder() .method("PUT") .uri(format!("/api/uploads/{upload_id}")) .header("x-openpxe-upload-offset", "8") .body(Body::from(vec![1, 2, 3, 4])) .unwrap(), ) .await .unwrap(); assert_eq!(mismatch.status(), StatusCode::CONFLICT); let body = axum::body::to_bytes(mismatch.into_body(), usize::MAX) .await .unwrap(); let text = String::from_utf8(body.to_vec()).unwrap(); assert!(text.contains("expected offset 0"), "got: {text}"); } #[tokio::test] async fn iso_category_switch_moves_entry_between_menus() { // OS (default): appears in Linux Installers submenu and not in Tools. // After flipping to Tools: gone from Linux, present under Tools. let (state, _dir) = build_state().await; let app = build_router(state); let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); let upload = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/isos") .header("content-type", ct) .body(Body::from(body)) .unwrap(), ) .await .unwrap(); assert_eq!(upload.status(), StatusCode::CREATED); let (_, linux_before) = get(&app, "/boot/_linux_menu.ipxe").await; let linux_before = String::from_utf8(linux_before).unwrap(); assert!( linux_before.contains("fake-alpine-linux"), "expected entry in Linux submenu (default OS):\n{linux_before}" ); let (_, tools_before) = get(&app, "/boot/_tools_menu.ipxe").await; let tools_before = String::from_utf8(tools_before).unwrap(); assert!( !tools_before.contains("fake-alpine-linux"), "OS-category ISO shouldn't appear in Tools yet:\n{tools_before}" ); // Flip to Tools. let (s, _) = put_json( &app, "/api/isos/fake-alpine/category", r#"{"category":"tools"}"#, ) .await; assert_eq!(s, StatusCode::OK); let (_, linux_after) = get(&app, "/boot/_linux_menu.ipxe").await; let linux_after = String::from_utf8(linux_after).unwrap(); assert!( !linux_after.contains("fake-alpine-linux"), "Tools-category ISO should NOT appear in Linux submenu:\n{linux_after}" ); let (_, tools_after) = get(&app, "/boot/_tools_menu.ipxe").await; let tools_after = String::from_utf8(tools_after).unwrap(); assert!( tools_after.contains("fake-alpine-linux"), "Tools-category ISO should appear in Tools submenu:\n{tools_after}" ); } #[tokio::test] async fn iso_category_unknown_value_returns_400() { let (state, _dir) = build_state().await; let app = build_router(state); 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(); let (s, body) = put_json( &app, "/api/isos/fake-alpine/category", r#"{"category":"gibberish"}"#, ) .await; assert_eq!(s, StatusCode::BAD_REQUEST); let text = std::str::from_utf8(&body).unwrap(); assert!(text.contains("unknown category"), "got: {text}"); } #[tokio::test] async fn iso_category_unknown_id_returns_404() { let (state, _dir) = build_state().await; let app = build_router(state); let (s, _) = put_json( &app, "/api/isos/does-not-exist/category", r#"{"category":"tools"}"#, ) .await; assert_eq!(s, StatusCode::NOT_FOUND); } #[tokio::test] async fn storage_disk_endpoint_reports_volume_stats() { let (state, _dir) = build_state().await; let app = build_router(state); let (s, body) = get(&app, "/api/storage/disk").await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); // statvfs always returns something on the tempdir filesystem; check // that the shape is sane (total >= used >= 0 and total >= available). assert!(v["total_bytes"].as_u64().unwrap() > 0, "got {v}"); let total = v["total_bytes"].as_u64().unwrap(); let avail = v["available_bytes"].as_u64().unwrap(); let used = v["used_bytes"].as_u64().unwrap(); assert!(total >= avail, "{v}"); assert!(total >= used, "{v}"); assert!(v["path"].as_str().unwrap().contains("isos"), "got {v}"); } #[tokio::test] async fn api_docs_lists_known_endpoints() { let (state, _dir) = build_state().await; let app = build_router(state); let (s, body) = get(&app, "/api/docs").await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); let groups = v["groups"].as_array().expect("groups array"); assert!(!groups.is_empty()); // Flatten the paths and confirm a handful of the routes that real // operators will look up are documented. let mut paths: Vec = Vec::new(); for g in groups { for ep in g["endpoints"].as_array().unwrap() { paths.push(ep["path"].as_str().unwrap().into()); } } for needle in [ "/api/isos", "/api/isos/:id/category", "/api/storage/disk", "/api/branding/logo", "/api/boot-log", "/metrics", ] { assert!( paths.iter().any(|p| p == needle), "expected {needle} in docs; got {paths:?}" ); } } #[tokio::test] async fn branding_clear_when_no_logo_is_no_content() { // No-op clear should still 204 — it's not an error to revert to // the default when there's nothing to revert from. let (state, _dir) = build_state().await; let app = build_router(state); let res = app .oneshot( Request::builder() .method("DELETE") .uri("/api/branding/logo") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::NO_CONTENT); } #[tokio::test] async fn status_exposes_custom_logo_flag() { let (state, _dir) = build_state().await; let app = build_router(state); let (s, body) = get(&app, "/api/status").await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!( v["custom_logo"].as_bool(), Some(false), "fresh state has no custom logo: {v}" ); } async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec) { let res = router .clone() .oneshot( Request::builder() .method("PUT") .uri(path) .header("content-type", "application/json") .body(Body::from(body.to_owned())) .unwrap(), ) .await .unwrap(); let status = res.status(); let bytes = axum::body::to_bytes(res.into_body(), usize::MAX) .await .unwrap() .to_vec(); (status, bytes) }