Answers the operational question 'can a machine try all three boot binaries in one go?' The protocol can't carry three NBPs in one cycle (one boot file per DHCP round, the Secure-Boot refusal happens after handoff with no error report, and the broken-NIC case specifically needs the firmware itself to load builtin-driver iPXE — GRUB's network rides the same broken firmware stack). What we CAN do is make the walk a once-per-machine-ever event and give operators a way to skip it: - Learned driver modes persist (<work_dir>/driver_modes.json). A MAC that reaches the Shim rung, or confirms an iPXE handoff at Builtin, is pinned to disk: immune to the 30-min TTL, reloaded at startup. The file only carries exceptions — a healthy fleet never writes it. Corrupt file starts empty (standard crash-cache policy). - Boot rules gain an optional driver_mode pin (auto/firmware/builtin/ shim), consulted by the DHCP proxy BEFORE the escalation ladder: 'this OUI is a Secure Boot rack -> serve shim immediately' = zero failed cycles. Mode-only rules coexist with target rules (a pin doesn't shadow a later target match). Editor column on Hosts tab. - grub.cfg now tries to chainload all-drivers iPXE before showing the signed menu: with SB off the chainload succeeds and the client gets the full iPXE feature set back in the SAME boot (self-healing for mis-escalations, and the handoff then pins the working mode); with SB on, shim's verifier refuses it inline — no reboot — and the signed menu appears. DhcpProxyServer now takes the escalation table + rules store from main (persistence path comes from the configured work dir). Validation: clippy clean, fmt clean, 299 workspace tests green (+9: persistence round-trip across restart, Shim pin survives TTL, learned Builtin survives TTL, corrupt-file recovery, default-mode-never- persisted, rule-pin matching incl. unknown-mode tolerance and pin/target coexistence, GRUB chainload-before-menu ordering, API round-trip of the driver_mode field). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2774 lines
97 KiB
Rust
2774 lines
97 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, 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,
|
||
boot_rules: openpxe_core::BootRulesStore::load_or_default(dir.path()),
|
||
boot_tokens: openpxe_core::BootTokens::new(),
|
||
branding,
|
||
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
|
||
admin,
|
||
sessions,
|
||
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));
|
||
}
|
||
|
||
// ─── v0.7.0: tokenized answer files + boot rules ────────────────────────────
|
||
|
||
#[tokio::test]
|
||
async fn unattended_requires_token_once_admin_exists() {
|
||
let (state, _dir) = build_state().await;
|
||
let app = build_router(state.clone());
|
||
// Upload an answer file while in setup mode (everything open).
|
||
let (ct, body) =
|
||
multipart_iso_body("ks.ks", b"install\nrootpw s3cret\n%packages\n@core\n%end\n");
|
||
let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await;
|
||
assert_eq!(s, StatusCode::CREATED);
|
||
let id = serde_json::from_slice::<serde_json::Value>(&b).unwrap()["id"]
|
||
.as_str()
|
||
.unwrap()
|
||
.to_string();
|
||
// Pre-setup, the file serves openly (bootstrap parity with the
|
||
// auth middleware).
|
||
let (s, _) = get(&app, &format!("/unattended/{id}")).await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
|
||
// Create the admin → the gate arms.
|
||
let (s, _, cookies) = post_collect(
|
||
&app,
|
||
"/api/setup",
|
||
r#"{"username":"admin","password":"hunter2hunter2"}"#,
|
||
)
|
||
.await;
|
||
assert_eq!(s, StatusCode::CREATED);
|
||
let session = session_value(&cookies).unwrap();
|
||
|
||
// Bare fetch (the CVE-2026-0386 harvesting pattern) is refused.
|
||
let (s, _) = get(&app, &format!("/unattended/{id}")).await;
|
||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||
// Garbage token is refused.
|
||
let (s, _) = get(&app, &format!("/unattended/{id}?t=bogus")).await;
|
||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||
// A token minted for a *different* file is refused.
|
||
let other = state.boot_tokens.mint("some-other-file");
|
||
let (s, _) = get(&app, &format!("/unattended/{id}?t={other}")).await;
|
||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||
// The boot-scoped token OpenPXE mints into generated URLs passes.
|
||
let tok = state.boot_tokens.mint(&id);
|
||
let (s, b) = get(&app, &format!("/unattended/{id}?t={tok}")).await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
assert!(String::from_utf8_lossy(&b).contains("rootpw"));
|
||
// A logged-in operator (browser testing) passes too.
|
||
let (s, _) = get_with_cookie(&app, &format!("/unattended/{id}"), &session).await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn boot_script_for_pinned_unattended_carries_live_token() {
|
||
let (state, _dir) = build_state().await;
|
||
let app = build_router(state.clone());
|
||
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
|
||
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
|
||
assert_eq!(s, StatusCode::CREATED);
|
||
let (ct, body) = multipart_iso_body("ks.ks", b"install\n%packages\n@core\n%end\n");
|
||
let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await;
|
||
assert_eq!(s, StatusCode::CREATED);
|
||
let ks_id = serde_json::from_slice::<serde_json::Value>(&b).unwrap()["id"]
|
||
.as_str()
|
||
.unwrap()
|
||
.to_string();
|
||
let mac = "aa:bb:cc:dd:ee:71";
|
||
let pin =
|
||
format!(r#"{{"mac":"{mac}","target":"fake-alpine-linux","unattended_file":"{ks_id}"}}"#);
|
||
let (s, _) = post_json(&app, "/api/hosts", &pin).await;
|
||
assert_eq!(s, StatusCode::CREATED);
|
||
let (s, b) = get(&app, &format!("/boot/fake-alpine-linux.ipxe?mac={mac}")).await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
let script = String::from_utf8_lossy(&b).into_owned();
|
||
// The injected inst.ks URL ends with a token that is live for the file.
|
||
let tok = script
|
||
.split("t=")
|
||
.nth(1)
|
||
.and_then(|rest| rest.split_whitespace().next())
|
||
.expect("kernel arg should carry t=<token>");
|
||
assert!(
|
||
state.boot_tokens.check(tok, &ks_id),
|
||
"token in boot script must be live:\n{script}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn boot_rules_match_and_persist_via_api() {
|
||
let (state, _dir) = build_state().await;
|
||
let app = build_router(state);
|
||
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
|
||
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
|
||
assert_eq!(s, StatusCode::CREATED);
|
||
|
||
// Save a rule: any MAC under aa:bb:cc, any arch → the Linux entry.
|
||
let cfg = r#"{"rules":[{"mac_prefix":"AA-BB-CC","arch":"","target":"fake-alpine-linux","enabled":true,"note":"rack"}],"webhook_url":""}"#;
|
||
let (s, _) = put_json(&app, "/api/boot-rules", cfg).await;
|
||
assert_eq!(s, StatusCode::NO_CONTENT);
|
||
// The config reads back (prefix normalized to colons).
|
||
let (s, b) = get(&app, "/api/boot-rules").await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||
assert_eq!(v["rules"][0]["mac_prefix"], "aa:bb:cc");
|
||
|
||
// A matching client short-circuits to the target...
|
||
let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:09&arch=uefi-x64").await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
let script = String::from_utf8_lossy(&b);
|
||
assert!(
|
||
script.contains("boot rule -> fake-alpine-linux"),
|
||
"rule did not chain:\n{script}"
|
||
);
|
||
// ...while a non-matching one still gets the menu.
|
||
let (s, b) = get(&app, "/boot.ipxe?mac=11:22:33:00:00:09&arch=uefi-x64").await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
assert!(
|
||
String::from_utf8_lossy(&b).contains("menu"),
|
||
"non-matching client should see the menu"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn arch_selective_rule_ignores_other_arches() {
|
||
let (state, _dir) = build_state().await;
|
||
let app = build_router(state);
|
||
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
|
||
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
|
||
assert_eq!(s, StatusCode::CREATED);
|
||
let cfg = r#"{"rules":[{"mac_prefix":"","arch":"uefi-arm64","target":"fake-alpine-linux","enabled":true,"note":""}],"webhook_url":""}"#;
|
||
let (s, _) = put_json(&app, "/api/boot-rules", cfg).await;
|
||
assert_eq!(s, StatusCode::NO_CONTENT);
|
||
// x64 client: no match → menu.
|
||
let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01&arch=uefi-x64").await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
assert!(!String::from_utf8_lossy(&b).contains("boot rule ->"));
|
||
// arm64 client: match.
|
||
let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01&arch=uefi-arm64").await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
assert!(String::from_utf8_lossy(&b).contains("boot rule -> fake-alpine-linux"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn boot_rule_driver_mode_pin_round_trips_via_api() {
|
||
// v0.7.1: a rule may pin only a boot binary (no target) — the API
|
||
// must persist and return it for the DHCP proxy to consult.
|
||
let (state, _dir) = build_state().await;
|
||
let app = build_router(state.clone());
|
||
let cfg = r#"{"rules":[{"mac_prefix":"aa:bb:cc","arch":"","target":"","driver_mode":"shim","enabled":true,"note":"SB rack"}],"webhook_url":""}"#;
|
||
let (s, _) = put_json(&app, "/api/boot-rules", cfg).await;
|
||
assert_eq!(s, StatusCode::NO_CONTENT);
|
||
let (s, b) = get(&app, "/api/boot-rules").await;
|
||
assert_eq!(s, StatusCode::OK);
|
||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||
assert_eq!(v["rules"][0]["driver_mode"], "shim");
|
||
// And the store the DHCP proxy shares resolves the pin.
|
||
assert_eq!(
|
||
state.boot_rules.driver_mode_hint("aa:bb:cc:00:00:07", None),
|
||
Some(openpxe_core::DriverMode::Shim)
|
||
);
|
||
}
|