Name update
This commit is contained in:
+296
-123
@@ -38,13 +38,12 @@ fn fake_alpine_iso() -> Vec<u8> {
|
||||
}
|
||||
|
||||
fn multipart_iso_body(filename: &str, bytes: &[u8]) -> (String, Vec<u8>) {
|
||||
let boundary = "----PxeForgeTestBoundary1234";
|
||||
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(),
|
||||
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);
|
||||
@@ -60,7 +59,10 @@ async fn get(router: &axum::Router, path: &str) -> (StatusCode, Vec<u8>) {
|
||||
.await
|
||||
.unwrap();
|
||||
let status = res.status();
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec();
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec();
|
||||
(status, body)
|
||||
}
|
||||
|
||||
@@ -78,7 +80,10 @@ async fn post_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode
|
||||
.await
|
||||
.unwrap();
|
||||
let status = res.status();
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec();
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec();
|
||||
(status, body)
|
||||
}
|
||||
|
||||
@@ -87,7 +92,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
let iso_store = IsoStore::new(dir.path().join("isos"));
|
||||
iso_store.ensure_dirs().await.unwrap();
|
||||
let clients = ClientRegistry::new();
|
||||
let gates = DeploymentQueue::new();
|
||||
let queue = DeploymentQueue::new();
|
||||
let settings = SettingsStore::load_or_default(dir.path());
|
||||
let nfs = NfsManager::new(dir.path(), iso_store.clone());
|
||||
iso_store.set_nfs_root(nfs.mount_root());
|
||||
@@ -97,7 +102,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
let state = AppState {
|
||||
iso_store,
|
||||
clients,
|
||||
queue: gates,
|
||||
queue,
|
||||
settings,
|
||||
hosts,
|
||||
metrics,
|
||||
@@ -153,13 +158,21 @@ async fn upload_introspects_and_generates_boot_entry() {
|
||||
// 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}");
|
||||
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}");
|
||||
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;
|
||||
@@ -178,9 +191,14 @@ async fn iso_range_request_slices_correctly() {
|
||||
app.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST").uri("/api/isos")
|
||||
.method("POST")
|
||||
.uri("/api/isos")
|
||||
.header("content-type", ct)
|
||||
.body(Body::from(body)).unwrap()).await.unwrap();
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Range bytes=0x8000-0x8005 should return the PVD signature byte.
|
||||
let res = app
|
||||
@@ -189,10 +207,15 @@ async fn iso_range_request_slices_correctly() {
|
||||
Request::builder()
|
||||
.uri("/iso/fake-alpine.iso")
|
||||
.header(header::RANGE, "bytes=32768-32773")
|
||||
.body(Body::empty()).unwrap())
|
||||
.await.unwrap();
|
||||
.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();
|
||||
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");
|
||||
}
|
||||
@@ -205,27 +228,40 @@ async fn queued_deployment_full_flow() {
|
||||
// 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();
|
||||
.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("Gate Position 1"));
|
||||
assert!(s2.contains("Gate Position 2"));
|
||||
assert!(s1.contains("Queue Position 1"));
|
||||
assert!(s2.contains("Queue Position 2"));
|
||||
|
||||
let gate1_id = s1.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/"))
|
||||
.unwrap().to_string();
|
||||
let gate2_id = s2.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/"))
|
||||
.unwrap().to_string();
|
||||
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/{gate1_id}");
|
||||
let uri = format!("/api/queue/poll/{queue1_id}");
|
||||
get(&app2, &uri).await
|
||||
});
|
||||
|
||||
@@ -233,13 +269,16 @@ async fn queued_deployment_full_flow() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
|
||||
// Operator assigns.
|
||||
let body = format!(r#"{{"target":"fake-alpine-linux","entry_ids":["{gate2_id}"]}}"#);
|
||||
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}");
|
||||
assert!(
|
||||
assign_json.contains(r#""assigned":1"#),
|
||||
"assign response: {assign_json}"
|
||||
);
|
||||
|
||||
// Now assign to gate 1 too so the background poll wakes.
|
||||
// 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;
|
||||
|
||||
@@ -251,14 +290,23 @@ async fn queued_deployment_full_flow() {
|
||||
"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");
|
||||
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) = 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}");
|
||||
assert!(
|
||||
bad_s.contains(r#""ok":false"#),
|
||||
"expected rejection: {bad_s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -272,8 +320,9 @@ async fn settings_put_persists_across_reads() {
|
||||
"smb_host_override": "",
|
||||
"extra_kernel_args": "console=ttyS0",
|
||||
"default_local_hdd": true,
|
||||
"gate_wait_max_secs": 0
|
||||
}).to_string();
|
||||
"queue_wait_max_secs": 0
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let res = app
|
||||
.clone()
|
||||
@@ -283,7 +332,8 @@ async fn settings_put_persists_across_reads() {
|
||||
.uri("/api/settings")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body))
|
||||
.unwrap())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::NO_CONTENT);
|
||||
@@ -297,10 +347,14 @@ async fn settings_put_persists_across_reads() {
|
||||
// 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}");
|
||||
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]
|
||||
@@ -309,14 +363,22 @@ async fn reboot_and_firmware_exit_in_tools_menu() {
|
||||
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}");
|
||||
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]
|
||||
@@ -334,11 +396,15 @@ async fn ui_assets_served_offline() {
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
|
||||
.await.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK, "{path} not 200");
|
||||
let got = res.headers()
|
||||
.get(header::CONTENT_TYPE).unwrap()
|
||||
.to_str().unwrap();
|
||||
let got = res
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(got.starts_with(ct), "{path} ct={got}, expected {ct}");
|
||||
}
|
||||
}
|
||||
@@ -348,20 +414,38 @@ 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"] {
|
||||
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}");
|
||||
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}");
|
||||
assert!(
|
||||
rest.starts_with("http://127.0.0.1"),
|
||||
"{path} references non-public-base URL: {line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,7 +468,10 @@ async fn nfs_add_with_bad_export_is_rejected() {
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
let msg = String::from_utf8_lossy(&b);
|
||||
assert!(msg.contains("export"), "expected validation hint, got: {msg}");
|
||||
assert!(
|
||||
msg.contains("export"),
|
||||
"expected validation hint, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -413,7 +500,10 @@ async fn terminal_help_and_status_round_trip() {
|
||||
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.starts_with("OpenPXE"),
|
||||
"unexpected status output: {out}"
|
||||
);
|
||||
assert!(out.contains("isos:"), "status missing iso line: {out}");
|
||||
|
||||
// Unknown command -> ok=false plus help hint.
|
||||
@@ -434,7 +524,10 @@ async fn log_recent_returns_buffered_lines() {
|
||||
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");
|
||||
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"] {
|
||||
@@ -479,7 +572,7 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
|
||||
.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,"gate_wait_max_secs":0,"dns_server":""}"#
|
||||
"default_local_hdd":true,"queue_wait_max_secs":0,"dns_server":""}"#
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
@@ -504,7 +597,9 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::CREATED);
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
|
||||
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!(
|
||||
@@ -534,7 +629,10 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let script = String::from_utf8(body).unwrap();
|
||||
assert!(script.contains("kernel "), "missing kernel line:\n{script}");
|
||||
assert!(script.contains("ipxe/wimboot"), "missing wimboot loader:\n{script}");
|
||||
assert!(
|
||||
script.contains("ipxe/wimboot"),
|
||||
"missing wimboot loader:\n{script}"
|
||||
);
|
||||
for tag in ["bootmgr", "bootmgr.efi", "bcd", "boot.sdi", "boot.wim"] {
|
||||
assert!(
|
||||
script.contains(&format!("initrd --name {tag}")),
|
||||
@@ -544,8 +642,12 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
|
||||
// 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",
|
||||
"bcdedit",
|
||||
"testsigning",
|
||||
"certutil",
|
||||
"test-signed",
|
||||
"httpdisk",
|
||||
"/set testsigning",
|
||||
] {
|
||||
assert!(
|
||||
!lower.contains(forbidden),
|
||||
@@ -610,16 +712,25 @@ async fn metrics_endpoint_emits_prometheus_format() {
|
||||
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap())
|
||||
.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 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 [
|
||||
@@ -633,10 +744,7 @@ async fn metrics_endpoint_emits_prometheus_format() {
|
||||
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",
|
||||
] {
|
||||
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");
|
||||
}
|
||||
@@ -673,10 +781,10 @@ async fn network_endpoint_exposes_dns_round_trip() {
|
||||
assert_eq!(v["dns_server"], "10.0.0.1");
|
||||
}
|
||||
|
||||
// ─── v0.3.1: per-ISO password gate ────────────────────────────────────────
|
||||
// ─── Per-ISO password prompt ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn iso_password_gate_blocks_until_correct_token() {
|
||||
async fn iso_password_prompt_blocks_until_correct_token() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
|
||||
@@ -689,9 +797,14 @@ async fn iso_password_gate_blocks_until_correct_token() {
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST").uri("/api/isos")
|
||||
.method("POST")
|
||||
.uri("/api/isos")
|
||||
.header("content-type", ct)
|
||||
.body(Body::from(body)).unwrap()).await.unwrap();
|
||||
.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
|
||||
@@ -702,62 +815,93 @@ async fn iso_password_gate_blocks_until_correct_token() {
|
||||
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}");
|
||||
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();
|
||||
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}");
|
||||
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("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("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");
|
||||
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}");
|
||||
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");
|
||||
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();
|
||||
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}");
|
||||
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 *["));
|
||||
@@ -771,34 +915,63 @@ async fn iso_password_set_then_clear_via_null_body() {
|
||||
// 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();
|
||||
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();
|
||||
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}");
|
||||
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();
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user