Files
OpenPXE/crates/http-api/tests/full_flow.rs
T
Miles WardandClaude Opus 4.8 7f25bb681c v0.6.3: russh 0.61 security bump (CVE batch) + bergshamra 0.5 + axum 0.8
Security-driven dependency release.

- russh =0.55.0 (pinned) -> 0.61.2: closes the advisory batch reachable
  from our SFTP *client* path — unbounded/allocation-first packet
  parsing (CVE-2026-48110, CVE-2026-46702, CVE-2026-46673, HIGH) plus
  CVE-2026-48107 in client auth. A malicious or compromised SFTP server
  an operator pointed us at could previously OOM the PXE server. Also
  drops mlock on non-secret buffers (~21% SSH throughput upstream) —
  directly in the remote-share ISO streaming path. ring backend kept;
  zero code changes needed in sftp_share.rs.
- bergshamra 0.4 -> 0.5.1: the pin's blocking condition (stable
  RustCrypto generation, pkcs8 0.11) is now met upstream, so the
  =0.55.0 pin is deleted and its comment rewritten as history. 0.5 is
  secure-by-default for DSig (flags we already set explicitly) and
  fixes an XML-Enc DerivedKey fallthrough.
- axum 0.7 -> 0.8.9: route captures /:id -> {id} across the router and
  the /api/docs listing; ConnectInfo optional extraction moves to the
  Result form. Gains the HEAD content-length fix (iPXE/sanboot clients
  probe with HEAD before Range requests) and puts us back on the
  maintained line.

Validation: clippy clean, fmt clean, all 272 workspace tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 19:47:13 -04:00

2613 lines
90 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 valid ISO9660 blob with volume label "ALPINE-TEST" so
/// introspection identifies it as Alpine.
fn fake_alpine_iso() -> Vec<u8> {
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<u8>) {
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<u8>) {
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<u8>) {
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 sessions = openpxe_http_api::auth::SessionStore::default();
let metrics = Metrics::new();
let state = AppState {
iso_store,
clients,
queue,
settings,
hosts,
boot_log,
branding,
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
admin,
sessions,
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(),
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 ────────────────────────────────────────────
// 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":"[email protected]","smtp_from":"[email protected]","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/<id>.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/<id>.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<String> = 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<u8>) {
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<u8>, Vec<axum::http::HeaderValue>) {
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<String> {
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<u8>) {
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<String> = 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<u8> {
use image::{DynamicImage, ImageBuffer, ImageFormat, Rgb};
use std::io::Cursor;
let buf: ImageBuffer<Rgb<u8>, Vec<u8>> = 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#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"/>"#,
)
.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<u8>,
) -> (StatusCode, Vec<u8>) {
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::<serde_json::Value>(&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("<svg"), "favicon must be the bundled SVG mark");
}
#[tokio::test]
async fn branding_slot_rejects_unknown_and_client_svg() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// Unknown slot name → 400.
let (ct, body) = multipart_iso_body("logo.png", &tiny_png());
let (s, _) = post_multipart(&app, "/api/branding/logo/sideways", &ct, body).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// SVG into the client (PXE) slot → 400 (raster-only).
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg"/>"#;
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#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="{IDP_ENTITY}">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing"><ds:KeyInfo><ds:X509Data><ds:X509Certificate>{cert_b64}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{IDP_SSO}"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>"#
)
}
/// 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##"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_resp1" Version="2.0" IssueInstant="{now}" Destination="{SP_ACS}"{irt}>
<saml:Issuer>{IDP_ENTITY}</saml:Issuer>
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
<saml:Assertion ID="_assertion1" Version="2.0" IssueInstant="{now}">
<saml:Issuer>{IDP_ENTITY}</saml:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"/>
<ds:Reference URI="#_assertion1">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue></ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue></ds:SignatureValue>
</ds:Signature>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">[email protected]</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData Recipient="{SP_ACS}" NotOnOrAfter="{noa}"{irt}/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="{nb}" NotOnOrAfter="{noa}">
<saml:AudienceRestriction><saml:Audience>{SP_BASE}</saml:Audience></saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="{now}" SessionIndex="sess-1">
<saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef></saml:AuthnContext>
</saml:AuthnStatement>
</saml:Assertion>
</samlp:Response>"##,
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));
}