//! 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, NfsShareManager, SftpShareManager, SmbShareManager}; use tempfile::tempdir; use tower::ServiceExt; /// Build a tiny Alpine-shaped ISO9660 image: volume label "ALPINE-TEST" /// plus the real `/boot/vmlinuz-lts` + `/boot/initramfs-lts` tree. /// v0.7.4's probe-based introspection verifies those paths exist before /// emitting a kernel boot entry — a label-only blob no longer counts. fn fake_alpine_iso() -> Vec { openpxe_iso_store::iso_fs::testiso::TestIsoBuilder::new("ALPINE-TEST") .el_torito(true) .file("/boot/vmlinuz-lts", b"fake-kernel-bytes") .file("/boot/initramfs-lts", b"fake-initramfs-bytes") .build() } 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 smb_shares = SmbShareManager::new(dir.path(), iso_store.clone()); let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone()); let sftp_shares = SftpShareManager::new(dir.path(), iso_store.clone()); let unattended = openpxe_iso_store::UnattendedStore::new(dir.path().join("unattended")); unattended.ensure_dir().await.unwrap(); 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 admin = openpxe_core::AdminStore::load_or_default(dir.path()); let sso = openpxe_core::SsoStore::load_or_default(dir.path()); let notify = openpxe_core::NotifyStore::load_or_default(dir.path()); let api_key = openpxe_core::ApiKeyStore::load_or_init(dir.path()); let sessions = openpxe_http_api::auth::SessionStore::default(); let metrics = Metrics::new(); let state = AppState { iso_store, clients, queue, settings, hosts, boot_log, boot_rules: openpxe_core::BootRulesStore::load_or_default(dir.path()), boot_tokens: openpxe_core::BootTokens::new(), branding, pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(), admin, sessions, api_key, sso, saml: openpxe_http_api::saml_routes::SamlRuntime::default(), notify, metrics, smb: None, smb_shares, nfs_shares, sftp_shares, unattended, 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(), nic_link: String::new(), subnet_mask: "255.0.0.0".into(), gateway: "127.0.0.1".into(), }; (state, dir) } #[tokio::test] async fn api_key_authenticates_gated_endpoints() { // v0.8.0: the x-api-key header authenticates /api/* like an operator // session. The middleware only enforces once an admin is configured // (before that everything is open), so bootstrap one first. let (state, _dir) = build_state().await; state .admin .bootstrap("admin", "correct-horse-battery-staple") .unwrap(); let key = state.api_key.current(); let app = build_router(state); // No credentials → 401. let res = app .clone() .oneshot( Request::builder() .uri("/api/isos") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "no auth must 401"); // Wrong key → 401. let res = app .clone() .oneshot( Request::builder() .uri("/api/isos") .header("x-api-key", "not-the-key") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "wrong key must 401"); // Correct key → 200 (operator-equivalent access). let res = app .oneshot( Request::builder() .uri("/api/isos") .header("x-api-key", key) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::OK, "valid key must authenticate"); } #[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 ──────────────────────────────────────────── // v0.4.65: kernel-mount NFS replaced with userspace SMB via smbclient. #[tokio::test] async fn smb_share_add_with_missing_server_is_rejected() { // Validation must run before we shell out to smbclient — otherwise // operators see opaque NT_STATUS codes for what's really a typo. let (state, _dir) = build_state().await; let app = build_router(state); let (s, b) = post_json( &app, "/api/smb-shares", r#"{"server":"","share":"isos","guest":true}"#, ) .await; assert_eq!(s, StatusCode::BAD_REQUEST); let msg = String::from_utf8_lossy(&b); assert!( msg.to_lowercase().contains("server"), "expected validation hint, got: {msg}" ); } #[tokio::test] async fn smb_share_add_requires_username_when_not_guest() { let (state, _dir) = build_state().await; let app = build_router(state); let (s, b) = post_json( &app, "/api/smb-shares", r#"{"server":"10.0.0.5","share":"isos","guest":false}"#, ) .await; assert_eq!(s, StatusCode::BAD_REQUEST); let msg = String::from_utf8_lossy(&b); assert!( msg.to_lowercase().contains("username"), "expected username hint, got: {msg}" ); } #[tokio::test] async fn smb_share_add_rejects_paths_in_share_name() { let (state, _dir) = build_state().await; let app = build_router(state); let (s, b) = post_json( &app, "/api/smb-shares", r#"{"server":"10.0.0.5","share":"isos/subdir","guest":true}"#, ) .await; assert_eq!(s, StatusCode::BAD_REQUEST); let msg = String::from_utf8_lossy(&b); assert!( msg.to_lowercase().contains("share name"), "expected share name hint, got: {msg}" ); } #[tokio::test] async fn smb_shares_list_starts_empty() { let (state, _dir) = build_state().await; let app = build_router(state); let (s, b) = get(&app, "/api/smb-shares").await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); assert_eq!(v["shares"].as_array().unwrap().len(), 0); } // v0.4.67: NFSv3 share manager (parallel to SMB). // ── v0.5.0: notifications + Wake-on-LAN ──────────────────────────────────── #[tokio::test] async fn notify_config_round_trips_and_redacts_smtp_password() { let (state, _dir) = build_state().await; let app = build_router(state); // Save an SMTP config with a password. let (s, _b) = put_json( &app, "/api/notify", r#"{"enabled":true,"kind":"smtp","smtp_host":"smtp.example.com","smtp_port":587,"smtp_to":"ops@example.com","smtp_from":"openpxe@example.com","smtp_password":"s3cret"}"#, ) .await; assert_eq!(s, StatusCode::OK); // GET must redact the password (never echo the real secret). let (s, b) = get(&app, "/api/notify").await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); assert_eq!(v["enabled"], true); assert_eq!(v["kind"], "smtp"); let pw = v["smtp_password"].as_str().unwrap_or(""); assert_ne!(pw, "s3cret", "raw password must never be returned"); assert!( !pw.is_empty(), "a set password should surface as a sentinel" ); } #[tokio::test] async fn notify_enable_webhook_without_url_is_rejected() { let (state, _dir) = build_state().await; let app = build_router(state); let (s, _b) = put_json( &app, "/api/notify", r#"{"enabled":true,"kind":"slack","webhook_url":""}"#, ) .await; assert_eq!(s, StatusCode::BAD_REQUEST); } #[tokio::test] async fn wol_on_unbound_mac_is_404() { // WoL only fires for bound MACs — an arbitrary MAC must 404 so the // endpoint isn't an open packet sprayer. let (state, _dir) = build_state().await; let app = build_router(state); let (s, _b) = post_json(&app, "/api/hosts/aa:bb:cc:dd:ee:ff/wol", "{}").await; assert_eq!(s, StatusCode::NOT_FOUND); } #[tokio::test] async fn nfs_shares_list_starts_empty() { let (state, _dir) = build_state().await; let app = build_router(state); let (s, b) = get(&app, "/api/nfs-shares").await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); assert_eq!(v["shares"].as_array().unwrap().len(), 0); } #[tokio::test] async fn nfs_shares_add_rejects_missing_server() { let (state, _dir) = build_state().await; let app = build_router(state); let (s, b) = post_json( &app, "/api/nfs-shares", r#"{"server":"","export":"/srv/isos"}"#, ) .await; assert_eq!(s, StatusCode::BAD_REQUEST); let msg = String::from_utf8_lossy(&b); assert!( msg.to_lowercase().contains("server"), "expected server hint, got: {msg}" ); } #[tokio::test] async fn nfs_shares_add_rejects_export_without_leading_slash() { let (state, _dir) = build_state().await; let app = build_router(state); let (s, b) = post_json( &app, "/api/nfs-shares", r#"{"server":"10.0.0.5","export":"srv/isos"}"#, ) .await; assert_eq!(s, StatusCode::BAD_REQUEST); let msg = String::from_utf8_lossy(&b); assert!( msg.to_lowercase().contains("export"), "expected export-path hint, got: {msg}" ); } #[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_sanboot_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" ); // v0.5.8: Windows boots via iPXE HTTP sanboot of the raw ISO — no SMB, // no extraction, no in-ISO file serving, no operator toggle. The boot // entry is a `san_boot_iso` kind pointing at the raw image. let entry = &meta["boot_entries"][0]; assert_eq!(entry["kind"]["kind"], "san_boot_iso"); let iso_url = entry["kind"]["iso_url"].as_str().unwrap(); assert!( std::path::Path::new(iso_url) .extension() .is_some_and(|e| e.eq_ignore_ascii_case("iso")), "sanboot should target the raw ISO, got: {iso_url}" ); // Render the entry script and verify: // 1. It uses `sanboot` against the raw ISO over HTTP // 2. 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("sanboot"), "missing sanboot line:\n{script}" ); assert!( script.contains(&format!("/{iso_url}")), "sanboot should reference the raw ISO url:\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(format!( r#"{{"filename":"chunked-alpine.iso","size_bytes":{}}}"#, iso.len() ))) .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", // v0.6.3: docs use axum 0.8's `{param}` capture syntax. "/api/isos/{id}/category", "/api/storage/disk", "/api/branding/logo/{slot}", "/api/unattended", "/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/dark") .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}" ); } /// v0.5.4 guard: the typed `StatusResponse` must keep every key the WebUI /// (`crates/webui/src/app.js`) reads off `/api/status`. If a refactor drops /// or renames one, the dashboard silently breaks — this catches it. #[tokio::test] async fn status_contract_has_all_ui_keys() { 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(); for key in [ "version", "public_base_url", "iso_count", "client_count", "queue_count", "imaging_count", "waiting_count", "ipxe_assets", "settings", "smb_share_count", "smb_share_reachable", "nfs_share_count", "nfs_share_reachable", "host_bindings", "custom_logo", "branding", "unattended_count", "uptime_secs", "started_at", "nic_name", "subnet_mask", "gateway", ] { assert!( v.get(key).is_some(), "/api/status missing UI key '{key}': {v}" ); } // Nested branding presence the Settings tab reads. for key in ["light", "dark", "client", "rev"] { assert!( v["branding"].get(key).is_some(), "/api/status branding missing '{key}': {v}" ); } // started_at must remain an RFC3339 string (the UI does fmtUptime on // uptime_secs but renders started_at as text), not a serialized struct. assert!( v["started_at"].is_string(), "started_at should serialize as a string: {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) } // ─── v0.4.5: Forms auth + SSO ───────────────────────────────────────────── async fn post_collect( router: &axum::Router, path: &str, body: &str, ) -> (StatusCode, Vec, 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 cookies: Vec<_> = res .headers() .get_all(axum::http::header::SET_COOKIE) .iter() .cloned() .collect(); let body = axum::body::to_bytes(res.into_body(), usize::MAX) .await .unwrap() .to_vec(); (status, body, cookies) } fn session_value(cookies: &[axum::http::HeaderValue]) -> Option { for c in cookies { let s = c.to_str().ok()?; if let Some(rest) = s.strip_prefix("openpxe_session=") { // Until the first ';' let val = rest.split(';').next().unwrap_or("").to_string(); return Some(val); } } None } async fn get_with_cookie(router: &axum::Router, path: &str, cookie: &str) -> (StatusCode, Vec) { let res = router .clone() .oneshot( Request::builder() .uri(path) .header("cookie", format!("openpxe_session={cookie}")) .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) } #[tokio::test] async fn me_reports_setup_required_when_no_admin() { let (state, _dir) = build_state().await; let app = build_router(state); let (s, body) = get(&app, "/api/me").await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(v["setup_required"].as_bool(), Some(true)); assert_eq!(v["authenticated"].as_bool(), Some(false)); } #[tokio::test] async fn setup_creates_admin_logs_in_and_blocks_second_call() { let (state, _dir) = build_state().await; let app = build_router(state); // First-run setup succeeds and returns a session cookie. let (s, body, cookies) = post_collect( &app, "/api/setup", r#"{"username":"admin","password":"hunter2hunter2"}"#, ) .await; assert_eq!(s, StatusCode::CREATED); let token = session_value(&cookies).expect("setup should set cookie"); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(v["user"]["username"], "admin"); // /api/me with that cookie reports authenticated. let (s, body) = get_with_cookie(&app, "/api/me", &token).await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(v["authenticated"].as_bool(), Some(true)); assert_eq!(v["user"]["username"], "admin"); // /api/setup is now closed. let (s, _, _) = post_collect( &app, "/api/setup", r#"{"username":"second","password":"hunter2hunter2"}"#, ) .await; assert_eq!(s, StatusCode::CONFLICT); } #[tokio::test] async fn protected_route_returns_401_after_setup_without_cookie() { let (state, _dir) = build_state().await; let app = build_router(state); // Set up an admin so the middleware engages. let (s, _, _) = post_collect( &app, "/api/setup", r#"{"username":"admin","password":"hunter2hunter2"}"#, ) .await; assert_eq!(s, StatusCode::CREATED); // No cookie → 401 on a protected route. let (s, _) = get(&app, "/api/isos").await; assert_eq!(s, StatusCode::UNAUTHORIZED); // PXE-essential routes stay reachable. let (s, _) = get(&app, "/boot.ipxe").await; assert_eq!(s, StatusCode::OK); let (s, _) = get(&app, "/healthz").await; assert_eq!(s, StatusCode::OK); } #[tokio::test] async fn login_logout_round_trip_uses_session_cookie() { let (state, _dir) = build_state().await; let app = build_router(state); let (_, _, _) = post_collect( &app, "/api/setup", r#"{"username":"admin","password":"hunter2hunter2"}"#, ) .await; // Fresh login (separate from the setup-issued session). let (s, _, cookies) = post_collect( &app, "/api/login", r#"{"username":"admin","password":"hunter2hunter2"}"#, ) .await; assert_eq!(s, StatusCode::OK); let token = session_value(&cookies).expect("login should set cookie"); // With cookie, /api/isos is reachable. let (s, _) = get_with_cookie(&app, "/api/isos", &token).await; assert_eq!(s, StatusCode::OK); // Logout revokes the session; /api/isos goes back to 401. let res = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/logout") .header("cookie", format!("openpxe_session={token}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::NO_CONTENT); let (s, _) = get_with_cookie(&app, "/api/isos", &token).await; assert_eq!(s, StatusCode::UNAUTHORIZED); } #[tokio::test] async fn login_rejects_wrong_password_with_401_and_no_cookie() { let (state, _dir) = build_state().await; let app = build_router(state); let (_, _, _) = post_collect( &app, "/api/setup", r#"{"username":"admin","password":"hunter2hunter2"}"#, ) .await; let (s, body, cookies) = post_collect( &app, "/api/login", r#"{"username":"admin","password":"nope"}"#, ) .await; assert_eq!(s, StatusCode::UNAUTHORIZED); assert!(session_value(&cookies).is_none(), "no cookie on failure"); let text = std::str::from_utf8(&body).unwrap(); assert!(text.contains("invalid"), "got: {text}"); } #[tokio::test] async fn update_credentials_requires_current_password_and_rotates_session() { let (state, _dir) = build_state().await; let app = build_router(state); let (_, _, cookies) = post_collect( &app, "/api/setup", r#"{"username":"admin","password":"hunter2hunter2"}"#, ) .await; let token = session_value(&cookies).unwrap(); // Wrong current password → 400. let res = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/me/credentials") .header("content-type", "application/json") .header("cookie", format!("openpxe_session={token}")) .body(Body::from( r#"{"current_password":"wrong","new_password":"newpassword1"}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::BAD_REQUEST); // Correct current password rotates + returns a fresh cookie. let res = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/me/credentials") .header("content-type", "application/json") .header("cookie", format!("openpxe_session={token}")) .body(Body::from( r#"{"current_password":"hunter2hunter2","new_username":"alice","new_password":"newpassword1"}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::OK); let new_cookies: Vec<_> = res .headers() .get_all(axum::http::header::SET_COOKIE) .iter() .cloned() .collect(); let new_token = session_value(&new_cookies).expect("rotation issues fresh cookie"); // Old cookie no longer valid (every session was revoked). let (s, _) = get_with_cookie(&app, "/api/isos", &token).await; assert_eq!(s, StatusCode::UNAUTHORIZED); // New cookie works. let (s, _) = get_with_cookie(&app, "/api/isos", &new_token).await; assert_eq!(s, StatusCode::OK); // Old creds no longer log in. let (s, _, _) = post_collect( &app, "/api/login", r#"{"username":"admin","password":"hunter2hunter2"}"#, ) .await; assert_eq!(s, StatusCode::UNAUTHORIZED); // New creds do. let (s, _, _) = post_collect( &app, "/api/login", r#"{"username":"alice","password":"newpassword1"}"#, ) .await; assert_eq!(s, StatusCode::OK); } #[tokio::test] async fn sso_round_trip_default_then_replace() { // Pre-setup state: middleware is open, so we can hit /api/sso directly. let (state, _dir) = build_state().await; let app = build_router(state); let (s, body) = get(&app, "/api/sso").await; assert_eq!(s, StatusCode::OK); let cfg: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(cfg["enabled"].as_bool(), Some(false)); // Enable with a metadata URL. let (s, _) = put_json( &app, "/api/sso", r#"{"enabled":true,"idp_name":"Okta","metadata":"","metadata_url":"https://idp.example.com/metadata"}"#, ) .await; assert_eq!(s, StatusCode::OK); let (_, body) = get(&app, "/api/sso").await; let cfg: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(cfg["enabled"].as_bool(), Some(true)); assert_eq!(cfg["idp_name"], "Okta"); // Enabling without a source is rejected. let (s, body) = put_json( &app, "/api/sso", r#"{"enabled":true,"idp_name":"","metadata":"","metadata_url":""}"#, ) .await; assert_eq!(s, StatusCode::BAD_REQUEST); let text = std::str::from_utf8(&body).unwrap(); assert!(text.contains("metadata"), "got: {text}"); } #[tokio::test] async fn docs_lists_new_v0_4_5_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 mut paths: Vec = Vec::new(); for g in v["groups"].as_array().unwrap() { for ep in g["endpoints"].as_array().unwrap() { paths.push(ep["path"].as_str().unwrap().into()); } } // /api/docs predates v0.4.5 but the new surface should be reachable // here too — confirms we don't forget to update it. For now we only // require the *existing* docs entries to keep working. for needle in ["/api/isos", "/api/boot-log", "/api/storage/disk"] { assert!(paths.iter().any(|p| p == needle), "{needle} missing"); } } // ─── v0.4.6: PXE logo endpoint ──────────────────────────────────────────── #[tokio::test] async fn pxe_background_serves_default_when_no_custom_logo() { // v0.4.69: the endpoint always returns a full-screen PNG background // now — when no custom logo is configured it composes the default // OpenPXE mark on a dark field rather than 404ing. This is what lets // the PXE menu's `console --picture` paint a real background instead // of falling back to the (now-removed) ASCII placeholder. let (state, _dir) = build_state().await; let app = build_router(state); let res = app .clone() .oneshot( Request::builder() .uri("/branding/pxe-logo") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::OK); assert_eq!( res.headers() .get(axum::http::header::CONTENT_TYPE) .unwrap() .to_str() .unwrap(), "image/png" ); let body = axum::body::to_bytes(res.into_body(), usize::MAX) .await .unwrap(); assert!(body.starts_with(b"\x89PNG"), "default background not a PNG"); let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]); assert_eq!(width, 1024, "default background should be 1024 wide"); } /// Build a tiny valid PNG via the `image` crate. The v0.4.61 PXE-logo /// compositor decodes whatever the operator uploaded — hand-rolled /// PNGs with handwritten CRCs are too easy to break; let the encoder /// produce something it can later decode. fn tiny_png() -> Vec { use image::{DynamicImage, ImageBuffer, ImageFormat, Rgb}; use std::io::Cursor; let buf: ImageBuffer, Vec> = ImageBuffer::from_pixel(8, 8, Rgb([0, 180, 220])); let mut out = Vec::with_capacity(256); DynamicImage::ImageRgb8(buf) .write_to(&mut Cursor::new(&mut out), ImageFormat::Png) .unwrap(); out } #[tokio::test] async fn pxe_background_falls_back_to_default_for_svg_upload() { // iPXE can't rasterize SVG, so an SVG upload doesn't paint as the // PXE background — but v0.4.69 still returns the *default* OpenPXE // background PNG (not a 404) so the boot screen stays graphical. // The WebUI top-left continues to render the SVG natively. let (state, _dir) = build_state().await; state .branding .set_logo( openpxe_core::LogoSlot::Client, "image/svg+xml", "svg", br#""#, ) .unwrap(); let app = build_router(state); let res = app .clone() .oneshot( Request::builder() .uri("/branding/pxe-logo") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::OK); let body = axum::body::to_bytes(res.into_body(), usize::MAX) .await .unwrap(); assert!( body.starts_with(b"\x89PNG"), "should serve default PNG for SVG" ); let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]); assert_eq!(width, 1024); } #[tokio::test] async fn pxe_logo_composes_to_1024x768_png() { // v0.4.61: the endpoint no longer serves the raw upload — it // composes the operator's logo into a fixed 1024×768 canvas so // the iPXE menu always paints at consistent dimensions. let (state, _dir) = build_state().await; let png = tiny_png(); state .branding .set_logo(openpxe_core::LogoSlot::Client, "image/png", "png", &png) .unwrap(); let app = build_router(state); let res = app .clone() .oneshot( Request::builder() .uri("/branding/pxe-logo") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::OK); let ct = res .headers() .get(axum::http::header::CONTENT_TYPE) .unwrap() .to_str() .unwrap(); assert_eq!(ct, "image/png"); let body = axum::body::to_bytes(res.into_body(), usize::MAX) .await .unwrap(); // PNG signature. assert!(body.starts_with(b"\x89PNG"), "PNG header missing"); // IHDR chunk lives at bytes 8..29; width is bytes 16..20, height // 20..24 in big-endian u32. The composed canvas should be 1024×768. let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]); let height = u32::from_be_bytes([body[20], body[21], body[22], body[23]]); assert_eq!(width, 1024, "compose should pin width to 1024"); assert_eq!(height, 768, "compose should pin height to 768"); } #[tokio::test] async fn pxe_logo_endpoint_is_public_after_admin_setup() { // iPXE clients can't send a session cookie, so /branding/pxe-logo // must stay reachable once the admin has been bootstrapped. The // auth allowlist gates `/api/*` only. let (state, _dir) = build_state().await; let png = tiny_png(); state .branding .set_logo(openpxe_core::LogoSlot::Client, "image/png", "png", &png) .unwrap(); let app = build_router(state); // Configure an admin so the middleware kicks in. let (s, _, _) = post_collect( &app, "/api/setup", r#"{"username":"admin","password":"hunter2hunter2"}"#, ) .await; assert_eq!(s, StatusCode::CREATED); // Still public without a cookie. let (s, _) = get(&app, "/branding/pxe-logo").await; assert_eq!(s, StatusCode::OK); } // ─── v0.5.2: unattended files + deployment profiles ───────────────────────── async fn post_multipart( router: &axum::Router, path: &str, ct: &str, body: Vec, ) -> (StatusCode, Vec) { let res = router .clone() .oneshot( Request::builder() .method("POST") .uri(path) .header("content-type", ct) .body(Body::from(body)) .unwrap(), ) .await .unwrap(); let status = res.status(); let body = axum::body::to_bytes(res.into_body(), usize::MAX) .await .unwrap() .to_vec(); (status, body) } #[tokio::test] async fn unattended_upload_list_serve_and_template() { let (state, _dir) = build_state().await; let app = build_router(state); let ks = b"install\nnetwork --hostname={{HOSTNAME}} --ip={{IP}}\n%packages\n@core\n%end\n"; let (ct, body) = multipart_iso_body("rocky.ks", ks); let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await; assert_eq!(s, StatusCode::CREATED, "{}", String::from_utf8_lossy(&b)); let m: serde_json::Value = serde_json::from_slice(&b).unwrap(); assert_eq!(m["kind"], "kickstart"); let id = m["id"].as_str().unwrap().to_string(); let (s, b) = get(&app, "/api/unattended").await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); assert_eq!(v["files"].as_array().unwrap().len(), 1); // Public serve substitutes the query tokens. let (s, b) = get( &app, &format!("/unattended/{id}?hostname=node7&ip=10.0.0.7"), ) .await; assert_eq!(s, StatusCode::OK); let text = String::from_utf8_lossy(&b); assert!(text.contains("--hostname=node7"), "got: {text}"); assert!(text.contains("--ip=10.0.0.7"), "got: {text}"); assert!(!text.contains("{{"), "tokens left unrendered: {text}"); // Delete. let res = app .clone() .oneshot( Request::builder() .method("DELETE") .uri(format!("/api/unattended/{id}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::NO_CONTENT); let (_, b) = get(&app, "/api/unattended").await; let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); assert_eq!(v["files"].as_array().unwrap().len(), 0); } #[tokio::test] async fn unattended_upload_rejects_bad_type() { let (state, _dir) = build_state().await; let app = build_router(state); let (ct, body) = multipart_iso_body("evil.sh", b"#!/bin/sh\n"); let (s, _) = post_multipart(&app, "/api/unattended", &ct, body).await; assert_eq!(s, StatusCode::BAD_REQUEST); } #[tokio::test] async fn host_pin_with_unattended_injects_kickstart_arg() { let (state, _dir) = build_state().await; let app = build_router(state.clone()); // Upload a Linux ISO → synthesises the `fake-alpine-linux` LinuxKernel entry. let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await; assert_eq!(s, StatusCode::CREATED); // Upload a kickstart. let (ct, body) = multipart_iso_body("ks.ks", b"install\n%packages\n@core\n%end\n"); let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await; assert_eq!(s, StatusCode::CREATED); let ks_id = serde_json::from_slice::(&b).unwrap()["id"] .as_str() .unwrap() .to_string(); // Pin a MAC to the Linux entry with the unattended profile. let mac = "aa:bb:cc:dd:ee:01"; let pin = format!( r#"{{"mac":"{mac}","target":"fake-alpine-linux","label":"lab","auto_hostname":"node7","auto_ip":"10.0.0.7","unattended_file":"{ks_id}"}}"# ); let (s, b) = post_json(&app, "/api/hosts", &pin).await; assert_eq!(s, StatusCode::CREATED, "{}", String::from_utf8_lossy(&b)); // Boot the entry as that MAC; the kernel line should carry inst.ks=. let (s, b) = get(&app, &format!("/boot/fake-alpine-linux.ipxe?mac={mac}")).await; assert_eq!(s, StatusCode::OK); let script = String::from_utf8_lossy(&b); assert!( script.contains("inst.ks="), "no kickstart arg injected:\n{script}" ); assert!( script.contains("hostname=node7"), "hostname not passed:\n{script}" ); } #[tokio::test] async fn host_pin_rejects_unknown_unattended_file() { let (state, _dir) = build_state().await; let app = build_router(state.clone()); let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await; assert_eq!(s, StatusCode::CREATED); let pin = r#"{"mac":"aa:bb:cc:dd:ee:02","target":"fake-alpine-linux","unattended_file":"does-not-exist"}"#; let (s, _) = post_json(&app, "/api/hosts", pin).await; assert_eq!(s, StatusCode::BAD_REQUEST); } #[tokio::test] async fn host_pin_rejects_bad_auto_ip() { let (state, _dir) = build_state().await; let app = build_router(state.clone()); let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await; assert_eq!(s, StatusCode::CREATED); let pin = r#"{"mac":"aa:bb:cc:dd:ee:03","target":"fake-alpine-linux","auto_ip":"not-an-ip"}"#; let (s, _) = post_json(&app, "/api/hosts", pin).await; assert_eq!(s, StatusCode::BAD_REQUEST); } #[tokio::test] async fn per_theme_logo_and_favicon_serve() { let (state, _dir) = build_state().await; // Light slot only; dark falls back to it, favicon stays bundled. let png = tiny_png(); state .branding .set_logo(openpxe_core::LogoSlot::Light, "image/png", "png", &png) .unwrap(); let app = build_router(state); // Light theme → the uploaded PNG. let (s, b) = get(&app, "/assets/logo.svg?theme=light").await; assert_eq!(s, StatusCode::OK); assert!(b.starts_with(b"\x89PNG"), "light slot should serve the PNG"); // Dark theme → falls back to the light PNG (only slot set). let (s, b) = get(&app, "/assets/logo.svg?theme=dark").await; assert_eq!(s, StatusCode::OK); assert!( b.starts_with(b"\x89PNG"), "dark should fall back to light PNG" ); // Favicon is always the bundled SVG, never the custom raster. let (s, b) = get(&app, "/assets/favicon.svg").await; assert_eq!(s, StatusCode::OK); let txt = String::from_utf8_lossy(&b); assert!(txt.contains(""#; let boundary = "----OpenPxeTestBoundary1234"; let mut b = Vec::new(); b.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); b.extend_from_slice(b"Content-Disposition: form-data; name=\"file\"; filename=\"l.svg\"\r\n"); b.extend_from_slice(b"Content-Type: image/svg+xml\r\n\r\n"); b.extend_from_slice(svg); b.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); let ct = format!("multipart/form-data; boundary={boundary}"); let (s, _) = post_multipart(&app, "/api/branding/logo/client", &ct, b).await; assert_eq!(s, StatusCode::BAD_REQUEST); } // ─── v0.5.1: SAML SSO flow ────────────────────────────────────────────────── // // The core crate exhaustively tests signature verification + semantic // validation (crates/core/src/saml/tests.rs). These integration tests cover // the HTTP wiring the core can't: routing, base64 decode, session minting, // the InResponseTo / IdP-initiated gating, and assertion-replay rejection. use base64::Engine as _; use openpxe_core::SsoConfig; use time::format_description::well_known::Rfc3339; use time::{Duration as TimeDuration, OffsetDateTime}; const SP_BASE: &str = "http://127.0.0.1"; // build_state's public_base_url const SP_ACS: &str = "http://127.0.0.1/api/sso/acs"; const IDP_ENTITY: &str = "https://idp.test/realms/fleet"; const IDP_SSO: &str = "https://idp.test/realms/fleet/protocol/saml"; struct TestIdp { cert_b64: String, key_pem: String, } fn make_idp() -> TestIdp { let ck = rcgen::generate_simple_self_signed(vec!["idp.test".to_string()]).unwrap(); let der = ck.cert.der().as_ref().to_vec(); TestIdp { cert_b64: base64::engine::general_purpose::STANDARD.encode(der), key_pem: ck.key_pair.serialize_pem(), } } fn idp_metadata_xml(cert_b64: &str) -> String { format!( r#" {cert_b64} "# ) } /// Build + sign a SAMLResponse with the test IdP key. `in_response_to: None` /// makes it an unsolicited (IdP-initiated) response. fn signed_response(idp: &TestIdp, in_response_to: Option<&str>) -> String { let now = OffsetDateTime::now_utc().replace_nanosecond(0).unwrap(); let fmt = |t: OffsetDateTime| t.format(&Rfc3339).unwrap(); let irt = in_response_to .map(|v| format!(r#" InResponseTo="{v}""#)) .unwrap_or_default(); let template = format!( r##" {IDP_ENTITY} {IDP_ENTITY} tech@example.com {SP_BASE} urn:oasis:names:tc:SAML:2.0:ac:classes:Password "##, now = fmt(now), nb = fmt(now - TimeDuration::minutes(5)), noa = fmt(now + TimeDuration::hours(1)), ); let key = bergshamra::keys::loader::load_pem_auto(idp.key_pem.as_bytes(), None).unwrap(); let mut km = bergshamra::keys::KeysManager::new(); km.add_key(key); let ctx = bergshamra::DsigContext::new(km); bergshamra::sign(&ctx, &template).unwrap() } fn urlencode(s: &str) -> String { let mut out = String::with_capacity(s.len() * 3); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(b as char); } _ => { out.push('%'); out.push( char::from_digit((b >> 4) as u32, 16) .unwrap() .to_ascii_uppercase(), ); out.push( char::from_digit((b & 0xf) as u32, 16) .unwrap() .to_ascii_uppercase(), ); } } } out } fn configure_sso(state: &AppState, metadata: String, allow_idp_initiated: bool) { state .sso .replace(SsoConfig { enabled: true, idp_name: "Test IdP".into(), idp_logo_url: String::new(), metadata, metadata_url: String::new(), entity_id: String::new(), allow_idp_initiated, }) .unwrap(); } async fn post_acs(router: &axum::Router, signed_xml: &str) -> axum::response::Response { let b64 = base64::engine::general_purpose::STANDARD.encode(signed_xml.as_bytes()); let body = format!("SAMLResponse={}", urlencode(&b64)); router .clone() .oneshot( Request::builder() .method("POST") .uri("/api/sso/acs") .header("content-type", "application/x-www-form-urlencoded") .body(Body::from(body)) .unwrap(), ) .await .unwrap() } fn has_session_cookie(resp: &axum::response::Response) -> bool { resp.headers().get_all(header::SET_COOKIE).iter().any(|v| { let s = v.to_str().unwrap_or(""); s.starts_with("openpxe_session=") && !s.contains("openpxe_session=;") && !s.contains("Max-Age=0") }) } fn location(resp: &axum::response::Response) -> String { resp.headers() .get(header::LOCATION) .and_then(|v| v.to_str().ok()) .unwrap_or("") .to_owned() } #[tokio::test] async fn sso_login_redirects_to_idp() { let (state, _dir) = build_state().await; let idp = make_idp(); configure_sso(&state, idp_metadata_xml(&idp.cert_b64), false); let app = build_router(state); let resp = app .clone() .oneshot( Request::builder() .uri("/api/sso/login") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FOUND); let loc = location(&resp); assert!(loc.starts_with(IDP_SSO), "redirect to IdP, got {loc}"); assert!( loc.contains("SAMLRequest="), "carries SAMLRequest, got {loc}" ); } #[tokio::test] async fn sso_login_unavailable_when_disabled() { let (state, _dir) = build_state().await; let app = build_router(state); // SSO never configured let resp = app .oneshot( Request::builder() .uri("/api/sso/login") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FOUND); assert!(location(&resp).contains("sso_error")); } #[tokio::test] async fn sso_metadata_is_served() { let (state, _dir) = build_state().await; let app = build_router(state); let (status, body) = get(&app, "/api/sso/metadata").await; assert_eq!(status, StatusCode::OK); let xml = String::from_utf8(body).unwrap(); assert!(xml.contains("SPSSODescriptor")); assert!(xml.contains(SP_ACS)); assert!(xml.contains(SP_BASE)); } #[tokio::test] async fn acs_idp_initiated_mints_session() { let (state, _dir) = build_state().await; let idp = make_idp(); configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true); let app = build_router(state); let signed = signed_response(&idp, None); let resp = post_acs(&app, &signed).await; assert_eq!(resp.status(), StatusCode::FOUND); assert_eq!(location(&resp), "/"); assert!( has_session_cookie(&resp), "ACS must set an operator session cookie" ); } #[tokio::test] async fn acs_idp_initiated_blocked_when_disabled() { let (state, _dir) = build_state().await; let idp = make_idp(); configure_sso(&state, idp_metadata_xml(&idp.cert_b64), false); // gate OFF let app = build_router(state); let signed = signed_response(&idp, None); let resp = post_acs(&app, &signed).await; assert_eq!(resp.status(), StatusCode::FOUND); assert!(location(&resp).contains("sso_error")); assert!( !has_session_cookie(&resp), "no session when IdP-initiated is disabled" ); } #[tokio::test] async fn acs_sp_initiated_without_known_request_is_rejected() { let (state, _dir) = build_state().await; let idp = make_idp(); configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true); let app = build_router(state); // A valid signature but an InResponseTo we never issued => reject. let signed = signed_response(&idp, Some("_never-issued")); let resp = post_acs(&app, &signed).await; assert_eq!(resp.status(), StatusCode::FOUND); assert!(location(&resp).contains("sso_error")); assert!(!has_session_cookie(&resp)); } #[tokio::test] async fn acs_replayed_assertion_is_rejected() { let (state, _dir) = build_state().await; let idp = make_idp(); configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true); let app = build_router(state); let signed = signed_response(&idp, None); // First use succeeds… let first = post_acs(&app, &signed).await; assert!(has_session_cookie(&first)); // …replaying the identical assertion is rejected. let second = post_acs(&app, &signed).await; assert_eq!(second.status(), StatusCode::FOUND); assert!(location(&second).contains("sso_error")); assert!(!has_session_cookie(&second)); } #[tokio::test] async fn acs_garbage_is_rejected_without_500() { let (state, _dir) = build_state().await; let idp = make_idp(); configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true); let app = build_router(state); let body = "SAMLResponse=not%20valid%20base64%21%21"; let resp = app .oneshot( Request::builder() .method("POST") .uri("/api/sso/acs") .header("content-type", "application/x-www-form-urlencoded") .body(Body::from(body)) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FOUND); assert!(location(&resp).contains("sso_error")); assert!(!has_session_cookie(&resp)); } // ─── v0.7.0: tokenized answer files + boot rules ──────────────────────────── #[tokio::test] async fn unattended_requires_token_once_admin_exists() { let (state, _dir) = build_state().await; let app = build_router(state.clone()); // Upload an answer file while in setup mode (everything open). let (ct, body) = multipart_iso_body("ks.ks", b"install\nrootpw s3cret\n%packages\n@core\n%end\n"); let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await; assert_eq!(s, StatusCode::CREATED); let id = serde_json::from_slice::(&b).unwrap()["id"] .as_str() .unwrap() .to_string(); // Pre-setup, the file serves openly (bootstrap parity with the // auth middleware). let (s, _) = get(&app, &format!("/unattended/{id}")).await; assert_eq!(s, StatusCode::OK); // Create the admin → the gate arms. let (s, _, cookies) = post_collect( &app, "/api/setup", r#"{"username":"admin","password":"hunter2hunter2"}"#, ) .await; assert_eq!(s, StatusCode::CREATED); let session = session_value(&cookies).unwrap(); // Bare fetch (the CVE-2026-0386 harvesting pattern) is refused. let (s, _) = get(&app, &format!("/unattended/{id}")).await; assert_eq!(s, StatusCode::UNAUTHORIZED); // Garbage token is refused. let (s, _) = get(&app, &format!("/unattended/{id}?t=bogus")).await; assert_eq!(s, StatusCode::UNAUTHORIZED); // A token minted for a *different* file is refused. let other = state.boot_tokens.mint("some-other-file"); let (s, _) = get(&app, &format!("/unattended/{id}?t={other}")).await; assert_eq!(s, StatusCode::UNAUTHORIZED); // The boot-scoped token OpenPXE mints into generated URLs passes. let tok = state.boot_tokens.mint(&id); let (s, b) = get(&app, &format!("/unattended/{id}?t={tok}")).await; assert_eq!(s, StatusCode::OK); assert!(String::from_utf8_lossy(&b).contains("rootpw")); // A logged-in operator (browser testing) passes too. let (s, _) = get_with_cookie(&app, &format!("/unattended/{id}"), &session).await; assert_eq!(s, StatusCode::OK); } #[tokio::test] async fn boot_script_for_pinned_unattended_carries_live_token() { let (state, _dir) = build_state().await; let app = build_router(state.clone()); let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await; assert_eq!(s, StatusCode::CREATED); let (ct, body) = multipart_iso_body("ks.ks", b"install\n%packages\n@core\n%end\n"); let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await; assert_eq!(s, StatusCode::CREATED); let ks_id = serde_json::from_slice::(&b).unwrap()["id"] .as_str() .unwrap() .to_string(); let mac = "aa:bb:cc:dd:ee:71"; let pin = format!(r#"{{"mac":"{mac}","target":"fake-alpine-linux","unattended_file":"{ks_id}"}}"#); let (s, _) = post_json(&app, "/api/hosts", &pin).await; assert_eq!(s, StatusCode::CREATED); let (s, b) = get(&app, &format!("/boot/fake-alpine-linux.ipxe?mac={mac}")).await; assert_eq!(s, StatusCode::OK); let script = String::from_utf8_lossy(&b).into_owned(); // The injected inst.ks URL ends with a token that is live for the file. let tok = script .split("t=") .nth(1) .and_then(|rest| rest.split_whitespace().next()) .expect("kernel arg should carry t="); assert!( state.boot_tokens.check(tok, &ks_id), "token in boot script must be live:\n{script}" ); } #[tokio::test] async fn boot_rules_match_and_persist_via_api() { let (state, _dir) = build_state().await; let app = build_router(state); let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await; assert_eq!(s, StatusCode::CREATED); // Save a rule: any MAC under aa:bb:cc, any arch → the Linux entry. let cfg = r#"{"rules":[{"mac_prefix":"AA-BB-CC","arch":"","target":"fake-alpine-linux","enabled":true,"note":"rack"}],"webhook_url":""}"#; let (s, _) = put_json(&app, "/api/boot-rules", cfg).await; assert_eq!(s, StatusCode::NO_CONTENT); // The config reads back (prefix normalized to colons). let (s, b) = get(&app, "/api/boot-rules").await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); assert_eq!(v["rules"][0]["mac_prefix"], "aa:bb:cc"); // A matching client short-circuits to the target... let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:09&arch=uefi-x64").await; assert_eq!(s, StatusCode::OK); let script = String::from_utf8_lossy(&b); assert!( script.contains("boot rule -> fake-alpine-linux"), "rule did not chain:\n{script}" ); // ...while a non-matching one still gets the menu. let (s, b) = get(&app, "/boot.ipxe?mac=11:22:33:00:00:09&arch=uefi-x64").await; assert_eq!(s, StatusCode::OK); assert!( String::from_utf8_lossy(&b).contains("menu"), "non-matching client should see the menu" ); } #[tokio::test] async fn arch_selective_rule_ignores_other_arches() { let (state, _dir) = build_state().await; let app = build_router(state); let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await; assert_eq!(s, StatusCode::CREATED); let cfg = r#"{"rules":[{"mac_prefix":"","arch":"uefi-arm64","target":"fake-alpine-linux","enabled":true,"note":""}],"webhook_url":""}"#; let (s, _) = put_json(&app, "/api/boot-rules", cfg).await; assert_eq!(s, StatusCode::NO_CONTENT); // x64 client: no match → menu. let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01&arch=uefi-x64").await; assert_eq!(s, StatusCode::OK); assert!(!String::from_utf8_lossy(&b).contains("boot rule ->")); // arm64 client: match. let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01&arch=uefi-arm64").await; assert_eq!(s, StatusCode::OK); assert!(String::from_utf8_lossy(&b).contains("boot rule -> fake-alpine-linux")); } #[tokio::test] async fn boot_rule_driver_mode_pin_round_trips_via_api() { // v0.7.1: a rule may pin only a boot binary (no target) — the API // must persist and return it for the DHCP proxy to consult. let (state, _dir) = build_state().await; let app = build_router(state.clone()); let cfg = r#"{"rules":[{"mac_prefix":"aa:bb:cc","arch":"","target":"","driver_mode":"shim","enabled":true,"note":"SB rack"}],"webhook_url":""}"#; let (s, _) = put_json(&app, "/api/boot-rules", cfg).await; assert_eq!(s, StatusCode::NO_CONTENT); let (s, b) = get(&app, "/api/boot-rules").await; assert_eq!(s, StatusCode::OK); let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); assert_eq!(v["rules"][0]["driver_mode"], "shim"); // And the store the DHCP proxy shares resolves the pin. assert_eq!( state.boot_rules.driver_mode_hint("aa:bb:cc:00:00:07", None), Some(openpxe_core::DriverMode::Shim) ); }