Files
OpenPXE/crates/http-api/tests/full_flow.rs
T
Miles WardandClaude Opus 4.7 f8bfab3823 v0.4.6: iVentoy-style PXE menu, top-right user menu, Settings touchups
PXE boot menu polish (iVentoy-inspired):
- render_menu now opens with a best-effort `console --picture
  <base>/branding/pxe-logo || console` line so iPXE builds with PNG
  support paint the operator's uploaded raster logo as the background.
- ASCII OpenPXE wordmark banner sits at the top of the menu in
  `item --gap` lines — always visible on every iPXE build, including
  the snponly/undionly variants without graphics console.
- New footer line above `choose`: "OpenPXE v0.4.6 - <arch label>",
  where <arch label> is mapped from iPXE's ${buildarch}/${platform}
  to "x86 BIOS", "x86_64 UEFI", or "arm64 UEFI". No URL, per brief.
- New GET /branding/pxe-logo route serves the operator's PNG / JPEG /
  WebP / GIF as-is for iPXE to consume. SVG uploads 404 here (iPXE
  can't rasterize SVG) — the always-visible ASCII wordmark stands in.
  Route stays public after admin setup so iPXE clients (no cookies)
  can fetch it.

UI:
- Removed the bottom-left "signed in as / Sign out" row.
- Added a person-icon button next to the theme toggle in the topbar.
  Click opens a small popover with: Name (display only), Edit account
  (jumps to Settings), Sign out. Esc + click-outside close it.
- Settings → Account card form chrome made consistent. The previous
  `label.field` selector only styled type=text/number, leaving
  password inputs with default browser chrome. Switched to a
  negation-list selector that covers every typed input we use, plus
  -webkit-appearance:none + a 1px focus ring. Light + dark mode both
  show the same border/padding/focus state across all four account
  fields.
- Settings → SSO card now renders display name, IdP logo URL (new),
  and metadata source on one 3-column row. The metadata <select>
  inherits the same chrome as the text inputs so it baseline-aligns
  with them. SsoConfig grew an idp_logo_url field, persisted to
  sso.json, length-capped and validated to http(s) only.

Quality:
- 138 tests passing (was 132 in v0.4.5). +1 IdP-logo-URL validation,
  +1 PXE menu polish regression guard, +4 /branding/pxe-logo
  integration tests covering missing-config / SVG-fallback / raster-
  serve / post-auth public-allowlist cases.
- cargo clippy --workspace --all-targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 00:02:20 -04:00

1834 lines
62 KiB
Rust

//! End-to-end HTTP integration test.
//!
//! Spins up the real axum router against a temp ISO store + settings store,
//! then walks an imaginary iPXE client through: dashboard status → upload
//! ISO → fetch top-level boot menu → fetch per-entry script → Range-GET the
//! ISO. Also drives the Queued Deployment flow end-to-end: two clients join,
//! operator assigns, both polls return the chain script with retry fallback.
//!
//! This is the closest we can get to "real PXE client" without QEMU; the
//! TFTP leg is separately unit-tested in `crates/tftp`. Between the two,
//! every HTTP endpoint a real client touches is covered by a test.
use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsManager};
use tempfile::tempdir;
use tower::ServiceExt;
/// Build a tiny valid ISO9660 blob with volume label "ALPINE-TEST" so
/// introspection identifies it as Alpine.
fn fake_alpine_iso() -> Vec<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 nfs = NfsManager::new(dir.path(), iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root());
let log_bus = LogBus::new(64);
let hosts = HostBindings::load_or_default(dir.path());
let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
let branding = openpxe_core::BrandingStore::load_or_default(dir.path());
let admin = openpxe_core::AdminStore::load_or_default(dir.path());
let sso = openpxe_core::SsoStore::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,
admin,
sessions,
sso,
metrics,
smb: None,
nfs,
uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus,
started_at: time::OffsetDateTime::now_utc(),
public_base_url: "http://127.0.0.1".into(),
nic_name: "lo".into(),
subnet_mask: "255.0.0.0".into(),
gateway: "127.0.0.1".into(),
};
(state, dir)
}
#[tokio::test]
async fn health_and_ready_endpoints() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = get(&app, "/healthz").await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b, b"ok\n");
// /readyz should 503 when no iPXE binaries bundled at test time — this
// actually depends on whether CI has fetched them. Accept either.
let (s2, _) = get(&app, "/readyz").await;
assert!(
s2 == StatusCode::OK || s2 == StatusCode::SERVICE_UNAVAILABLE,
"unexpected readyz status: {s2}"
);
}
#[tokio::test]
async fn upload_introspects_and_generates_boot_entry() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
let iso = fake_alpine_iso();
let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso);
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::CREATED, "upload failed");
// Confirm the ISO shows up in the menu.
let (_, menu) = get(&app, "/boot.ipxe").await;
let menu = String::from_utf8(menu).unwrap();
assert!(
menu.contains("Linux Installers"),
"menu missing Linux submenu:\n{menu}"
);
let (_, linux) = get(&app, "/boot/_linux_menu.ipxe").await;
let linux = String::from_utf8(linux).unwrap();
assert!(
linux.contains("fake-alpine-linux"),
"linux submenu missing entry:\n{linux}"
);
assert!(
linux.contains("[ 0 MB]") || linux.contains("[ 0 MB]"),
"size label missing in {linux}"
);
// Per-entry boot script should include kernel + initrd URLs + boot.
let (_, entry) = get(&app, "/boot/fake-alpine-linux.ipxe").await;
let entry = String::from_utf8(entry).unwrap();
assert!(entry.contains("kernel http://127.0.0.1/iso/fake-alpine/boot/vmlinuz-lts"));
assert!(entry.contains("initrd http://127.0.0.1/iso/fake-alpine/boot/initramfs-lts"));
assert!(entry.contains("boot || goto failed"));
}
#[tokio::test]
async fn iso_range_request_slices_correctly() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
let iso = fake_alpine_iso();
let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso);
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
// Range bytes=0x8000-0x8005 should return the PVD signature byte.
let res = app
.clone()
.oneshot(
Request::builder()
.uri("/iso/fake-alpine.iso")
.header(header::RANGE, "bytes=32768-32773")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
let slice = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(slice[0], 0x01); // PVD type
assert_eq!(&slice[1..6], b"CD001");
}
#[tokio::test]
async fn queued_deployment_full_flow() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
// Upload an ISO so the target exists.
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
// Two clients join.
let (_, join1) = get(&app, "/api/queue/join?mac=aa:bb:cc:00:00:01").await;
let (_, join2) = get(&app, "/api/queue/join?mac=aa:bb:cc:00:00:02").await;
let s1 = String::from_utf8(join1).unwrap();
let s2 = String::from_utf8(join2).unwrap();
assert!(s1.contains("Queue Position 1"));
assert!(s2.contains("Queue Position 2"));
let queue1_id = s1
.lines()
.find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/"))
.unwrap()
.to_string();
let queue2_id = s2
.lines()
.find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/"))
.unwrap()
.to_string();
// Kick off a long-poll for client 1 in the background. Then assign.
let app2 = app.clone();
let poll_future = tokio::spawn(async move {
let uri = format!("/api/queue/poll/{queue1_id}");
get(&app2, &uri).await
});
// Give the poll a moment to register its notify subscription.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Operator assigns.
let body = format!(r#"{{"target":"fake-alpine-linux","entry_ids":["{queue2_id}"]}}"#);
let (s, b) = post_json(&app, "/api/queue/assign", &body).await;
assert_eq!(s, StatusCode::OK);
let assign_json = String::from_utf8(b).unwrap();
assert!(
assign_json.contains(r#""assigned":1"#),
"assign response: {assign_json}"
);
// Now assign to queue entry 1 too so the background poll wakes.
let body = r#"{"target":"fake-alpine-linux","entry_ids":[]}"#;
post_json(&app, "/api/queue/assign", body).await;
let (poll_status, poll_body) = poll_future.await.unwrap();
assert_eq!(poll_status, StatusCode::OK);
let poll_s = String::from_utf8(poll_body).unwrap();
assert!(
poll_s.contains("chain http://127.0.0.1/boot/fake-alpine-linux.ipxe"),
"poll response should chain the boot script:\n{poll_s}"
);
// Retry-on-error fallback must be present.
assert!(
poll_s.contains("|| chain http://127.0.0.1/api/queue/poll/"),
"retry fallback missing"
);
// Bad target must be rejected.
let (_, bad) = post_json(
&app,
"/api/queue/assign",
r#"{"target":"does-not-exist","entry_ids":[]}"#,
)
.await;
let bad_s = String::from_utf8(bad).unwrap();
assert!(
bad_s.contains(r#""ok":false"#),
"expected rejection: {bad_s}"
);
}
#[tokio::test]
async fn settings_put_persists_across_reads() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let body = serde_json::json!({
"boot_menu_timeout_secs": 42,
"timeout_action": "local_hdd",
"windows_enabled": false,
"smb_host_override": "",
"extra_kernel_args": "console=ttyS0",
"default_local_hdd": true,
"queue_wait_max_secs": 0
})
.to_string();
let res = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/settings")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let (_, g) = get(&app, "/api/settings").await;
let got: serde_json::Value = serde_json::from_slice(&g).unwrap();
assert_eq!(got["boot_menu_timeout_secs"], 42);
assert_eq!(got["timeout_action"], "local_hdd");
assert_eq!(got["extra_kernel_args"], "console=ttyS0");
// And the menu should now use the new timeout.
let (_, menu) = get(&app, "/boot.ipxe").await;
let menu = String::from_utf8(menu).unwrap();
assert!(
menu.contains("--timeout 42000"),
"menu should reflect 42s timeout:\n{menu}"
);
assert!(
menu.contains("--default local"),
"menu should default to local:\n{menu}"
);
}
#[tokio::test]
async fn reboot_and_firmware_exit_in_tools_menu() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (_, tools) = get(&app, "/boot/_tools_menu.ipxe").await;
let tools = String::from_utf8(tools).unwrap();
assert!(
tools.contains("Reboot Computer"),
"tools menu missing Reboot item:\n{tools}"
);
assert!(
tools.contains("Exit and continue BIOS boot"),
"tools menu missing firmware-exit item:\n{tools}"
);
assert!(
tools.contains("&& reboot"),
"reboot command not wired:\n{tools}"
);
assert!(
tools.contains("&& exit 0"),
"firmware exit command not wired:\n{tools}"
);
}
#[tokio::test]
async fn ui_assets_served_offline() {
let (state, _dir) = build_state().await;
let app = build_router(state);
for (path, ct) in [
("/", "text/html"),
("/assets/app.js", "application/javascript"),
("/assets/app.css", "text/css"),
("/assets/logo.svg", "image/svg+xml"),
("/assets/loader.svg", "image/svg+xml"),
] {
let res = app
.clone()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK, "{path} not 200");
let got = res
.headers()
.get(header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap();
assert!(got.starts_with(ct), "{path} ct={got}, expected {ct}");
}
}
#[tokio::test]
async fn no_external_urls_in_generated_ipxe() {
// Sanity check that nothing we serve points off-server.
let (state, _dir) = build_state().await;
let app = build_router(state);
for path in [
"/boot.ipxe",
"/boot/_tools_menu.ipxe",
"/boot/_linux_menu.ipxe",
"/boot/_shell.ipxe",
"/boot/_nic.ipxe",
"/boot/_local.ipxe",
] {
let (_, body) = get(&app, path).await;
let s = String::from_utf8(body).unwrap();
// The only URLs we should emit are relative to our own public_base_url.
for url in [
"github.com",
"googleapis",
"cdn.",
"cdnjs",
"unpkg",
"jsdelivr",
] {
assert!(
!s.contains(url),
"{path} references external host {url}:\n{s}"
);
}
// Confirm URLs are all ours.
for line in s.lines() {
if let Some(idx) = line.find("http://") {
let rest = &line[idx..];
assert!(
rest.starts_with("http://127.0.0.1"),
"{path} references non-public-base URL: {line}"
);
}
}
}
}
// ── Phase 4 integration tests ────────────────────────────────────────────
#[tokio::test]
async fn nfs_add_with_bad_export_is_rejected() {
// Validation must happen before we shell out to /bin/mount —
// otherwise the operator sees opaque kernel errors instead of a
// clear "your export must start with /" hint.
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = post_json(
&app,
"/api/nfs",
r#"{"server":"10.0.0.5","export":"isos","version":"v41","read_only":true}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b);
assert!(
msg.contains("export"),
"expected validation hint, got: {msg}"
);
}
#[tokio::test]
async fn nfs_list_starts_empty() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = get(&app, "/api/nfs").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["mounts"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn terminal_help_and_status_round_trip() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// Empty command -> help banner.
let (s, b) = post_json(&app, "/api/terminal", r#"{"command":""}"#).await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert!(v["output"].as_str().unwrap().contains("OpenPXE terminal"));
// status -> contains the version banner.
let (s, b) = post_json(&app, "/api/terminal", r#"{"command":"status"}"#).await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
let out = v["output"].as_str().unwrap();
assert!(
out.starts_with("OpenPXE"),
"unexpected status output: {out}"
);
assert!(out.contains("isos:"), "status missing iso line: {out}");
// Unknown command -> ok=false plus help hint.
let (_, b) = post_json(&app, "/api/terminal", r#"{"command":"frobnicate"}"#).await;
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["ok"], false);
assert!(v["output"].as_str().unwrap().contains("unknown command"));
}
#[tokio::test]
async fn log_recent_returns_buffered_lines() {
// The terminal command we issued seeds the log bus, so a follow-up
// /api/log/recent must surface those lines as JSON.
let (state, _dir) = build_state().await;
let app = build_router(state);
let _ = post_json(&app, "/api/terminal", r#"{"command":"version"}"#).await;
let (s, b) = get(&app, "/api/log/recent").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
let lines = v["lines"].as_array().expect("lines array");
assert!(
!lines.is_empty(),
"log buffer should have at least one line"
);
// Every entry should have the canonical timestamp/level/target/message.
for l in lines {
for k in ["timestamp", "level", "target", "message"] {
assert!(l.get(k).is_some(), "missing field {k} in log line: {l}");
}
}
}
#[tokio::test]
async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
// Synthesize an ISO with a Windows volume label + the sources/boot.wim
// sentinel so introspection labels it WindowsPe with has_boot_wim.
let mut buf = vec![0u8; 32 * 2048];
let off = 16 * 2048;
buf[off] = 0x01;
buf[off + 1..off + 6].copy_from_slice(b"CD001");
buf[off + 6] = 0x01;
let label = b"WIN11_X64".to_vec();
let mut padded = label.clone();
padded.resize(32, b' ');
buf[off + 40..off + 40 + 32].copy_from_slice(&padded);
// Sprinkle the sources/boot.wim sentinel where the introspection
// scanner will find it (anywhere in the first 64 MB).
let sentinel = b"SOURCES\\BOOT.WIM";
buf.extend_from_slice(sentinel);
let term = 17 * 2048;
buf[term] = 0xFF;
buf[term + 1..term + 6].copy_from_slice(b"CD001");
buf[term + 6] = 0x01;
let (state, _dir) = build_state().await;
let app = build_router(state);
// Need windows_enabled for the Windows path to render in the menu.
let res = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/settings")
.header("content-type", "application/json")
.body(Body::from(
r#"{"boot_menu_timeout_secs":600,"timeout_action":"queued_deployment",
"windows_enabled":false,"smb_host_override":"","extra_kernel_args":"",
"default_local_hdd":true,"queue_wait_max_secs":0,"dns_server":""}"#
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
// wimboot binary is bundled in this repo so windows_enabled=true should
// not be rejected; we leave it false to keep the upload path agnostic.
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let (ct, body) = multipart_iso_body("Win11_x64.iso", &buf);
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::CREATED);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let meta: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(meta["introspection"]["family"], "windows_pe");
assert!(
meta["introspection"]["has_boot_wim"].as_bool().unwrap(),
"introspection should detect sources/boot.wim sentinel"
);
// The boot entry should be a wimboot kind with the canonical 5-file
// chain documented in the LinusTechTips iPXE-Windows guide.
let entry = &meta["boot_entries"][0];
assert_eq!(entry["kind"]["kind"], "wimboot");
let files = entry["kind"]["files"].as_array().unwrap();
let names: Vec<&str> = files.iter().map(|f| f[0].as_str().unwrap()).collect();
assert!(names.contains(&"bootmgr"));
assert!(names.contains(&"bootmgr.efi"));
assert!(names.contains(&"bcd"));
assert!(names.contains(&"boot.sdi"));
assert!(names.contains(&"boot.wim"));
// Render the entry script and verify:
// 1. It uses wimboot
// 2. All 5 files are referenced via `initrd --name`
// 3. NO trust-store / driver / testsigning operations slip in
let entry_id = entry["id"].as_str().unwrap();
let url = format!("/boot/{entry_id}.ipxe");
let (s, body) = get(&app, &url).await;
assert_eq!(s, StatusCode::OK);
let script = String::from_utf8(body).unwrap();
assert!(script.contains("kernel "), "missing kernel line:\n{script}");
assert!(
script.contains("ipxe/wimboot"),
"missing wimboot loader:\n{script}"
);
for tag in ["bootmgr", "bootmgr.efi", "bcd", "boot.sdi", "boot.wim"] {
assert!(
script.contains(&format!("initrd --name {tag}")),
"missing `initrd --name {tag}` line:\n{script}"
);
}
// Hard guarantees we never want to see in any client-facing script.
let lower = script.to_lowercase();
for forbidden in [
"bcdedit",
"testsigning",
"certutil",
"test-signed",
"httpdisk",
"/set testsigning",
] {
assert!(
!lower.contains(forbidden),
"forbidden trust-store operation `{forbidden}` in script:\n{script}"
);
}
}
#[tokio::test]
async fn host_binding_short_circuits_boot_menu() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
// Pin a MAC to the reserved local-hdd boot shortcut. `_local` is a
// built-in target so the upsert validator accepts it without
// requiring a real ISO.
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hosts")
.header("content-type", "application/json")
.body(Body::from(
r#"{"mac":"AA:BB:CC:00:00:01","target":"_local","label":"toms-laptop"}"#
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::CREATED);
// Hit /boot.ipxe with the bound MAC and assert we get the
// short-circuit chain instead of the menu.
let (s1, b1) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01").await;
assert_eq!(s1, StatusCode::OK);
let body1 = String::from_utf8(b1).unwrap();
assert!(
body1.contains("per-MAC binding"),
"expected MAC short-circuit, got:\n{body1}"
);
assert!(body1.contains("/boot/_local.ipxe"));
// And a different MAC still gets the menu.
let (s2, b2) = get(&app, "/boot.ipxe?mac=ff:ff:ff:ff:ff:ff").await;
assert_eq!(s2, StatusCode::OK);
let body2 = String::from_utf8(b2).unwrap();
assert!(
body2.contains("menu") || body2.contains("Default"),
"expected interactive menu, got:\n{body2}"
);
}
#[tokio::test]
async fn metrics_endpoint_emits_prometheus_format() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// Drive a couple of paths so counters move off zero.
let _ = get(&app, "/api/status").await;
let _ = get(&app, "/boot.ipxe").await;
let res = app
.clone()
.oneshot(
Request::builder()
.uri("/metrics")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let ct = res
.headers()
.get(header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap();
assert!(ct.starts_with("text/plain"), "wrong content-type: {ct}");
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
// Spot-check the must-have metric families.
for name in [
"openpxe_dhcp_replies_total",
"openpxe_tftp_transfers_total",
"openpxe_http_requests_total",
"openpxe_iso_count",
"openpxe_uptime_seconds",
"openpxe_build_info",
] {
assert!(body.contains(name), "missing metric {name} in:\n{body}");
}
// Each name appears exactly once as a `# TYPE` declaration.
for name in ["openpxe_dhcp_replies_total", "openpxe_iso_count"] {
let count = body.matches(&format!("# TYPE {name}")).count();
assert_eq!(count, 1, "{name} TYPE line appears {count} times");
}
}
#[tokio::test]
async fn network_endpoint_exposes_dns_round_trip() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// GET starts blank.
let (_, b) = get(&app, "/api/network").await;
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["dns_server"], "");
assert_eq!(v["nic_name"], "lo");
// PUT updates only the DNS field.
let res = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/network")
.header("content-type", "application/json")
.body(Body::from(r#"{"dns_server":"10.0.0.1"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let (_, b) = get(&app, "/api/network").await;
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["dns_server"], "10.0.0.1");
}
// ─── Per-ISO password prompt ──────────────────────────────────────────────
#[tokio::test]
async fn iso_password_prompt_blocks_until_correct_token() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// Upload a synthetic Alpine ISO so we have a real boot entry id to
// protect. Upload filename "fake-alpine.iso" -> id "fake-alpine",
// boot entry id "fake-alpine-linux".
let iso = fake_alpine_iso();
let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso);
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::CREATED);
// 1. With NO password set, /boot/<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",
"/api/isos/:id/category",
"/api/storage/disk",
"/api/branding/logo",
"/api/boot-log",
"/metrics",
] {
assert!(
paths.iter().any(|p| p == needle),
"expected {needle} in docs; got {paths:?}"
);
}
}
#[tokio::test]
async fn branding_clear_when_no_logo_is_no_content() {
// No-op clear should still 204 — it's not an error to revert to
// the default when there's nothing to revert from.
let (state, _dir) = build_state().await;
let app = build_router(state);
let res = app
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/branding/logo")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn status_exposes_custom_logo_flag() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/api/status").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
v["custom_logo"].as_bool(),
Some(false),
"fresh state has no custom logo: {v}"
);
}
async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<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_logo_404_when_no_custom_logo_configured() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/branding/pxe-logo").await;
assert_eq!(s, StatusCode::NOT_FOUND);
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("no custom logo"), "got: {text}");
}
#[tokio::test]
async fn pxe_logo_404_when_uploaded_logo_is_svg() {
// iPXE can't rasterize SVG, so an SVG upload deliberately doesn't
// light up the PXE menu's `console --picture` overlay — the ASCII
// wordmark in render_menu stands in instead.
let (state, _dir) = build_state().await;
state
.branding
.set_logo(
"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 (s, body) = get(&app, "/branding/pxe-logo").await;
assert_eq!(s, StatusCode::NOT_FOUND);
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("SVG"), "got: {text}");
}
#[tokio::test]
async fn pxe_logo_serves_raster_with_correct_mime() {
let (state, _dir) = build_state().await;
state
.branding
.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake-png-bytes")
.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();
assert!(body.starts_with(b"\x89PNG"), "PNG header missing");
}
#[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;
state
.branding
.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake")
.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);
}