//! End-to-end HTTP integration test.
//!
//! Spins up the real axum router against a temp ISO store + settings store,
//! then walks an imaginary iPXE client through: dashboard status → upload
//! ISO → fetch top-level boot menu → fetch per-entry script → Range-GET the
//! ISO. Also drives the Queued Deployment flow end-to-end: two clients join,
//! operator assigns, both polls return the chain script with retry fallback.
//!
//! This is the closest we can get to "real PXE client" without QEMU; the
//! TFTP leg is separately unit-tested in `crates/tftp`. Between the two,
//! every HTTP endpoint a real client touches is covered by a test.

use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsShareManager, SftpShareManager, SmbShareManager};
use tempfile::tempdir;
use tower::ServiceExt;

/// Build a tiny valid ISO9660 blob with volume label "ALPINE-TEST" so
/// introspection identifies it as Alpine.
fn fake_alpine_iso() -> Vec<u8> {
    let mut buf = vec![0u8; 32 * 2048];
    let off = 16 * 2048;
    buf[off] = 0x01;
    buf[off + 1..off + 6].copy_from_slice(b"CD001");
    buf[off + 6] = 0x01;
    let label = b"ALPINE-TEST".to_vec();
    let mut padded = label.clone();
    padded.resize(32, b' ');
    buf[off + 40..off + 40 + 32].copy_from_slice(&padded);
    let term = 17 * 2048;
    buf[term] = 0xFF;
    buf[term + 1..term + 6].copy_from_slice(b"CD001");
    buf[term + 6] = 0x01;
    buf
}

fn multipart_iso_body(filename: &str, bytes: &[u8]) -> (String, Vec<u8>) {
    let boundary = "----OpenPxeTestBoundary1234";
    let mut body = Vec::new();
    body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
    body.extend_from_slice(
        format!("Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n")
            .as_bytes(),
    );
    body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
    body.extend_from_slice(bytes);
    body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
    let ct = format!("multipart/form-data; boundary={boundary}");
    (ct, body)
}

async fn get(router: &axum::Router, path: &str) -> (StatusCode, Vec<u8>) {
    let res = router
        .clone()
        .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
        .await
        .unwrap();
    let status = res.status();
    let body = axum::body::to_bytes(res.into_body(), usize::MAX)
        .await
        .unwrap()
        .to_vec();
    (status, body)
}

async fn post_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>) {
    let res = router
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri(path)
                .header("content-type", "application/json")
                .body(Body::from(body.to_owned()))
                .unwrap(),
        )
        .await
        .unwrap();
    let status = res.status();
    let body = axum::body::to_bytes(res.into_body(), usize::MAX)
        .await
        .unwrap()
        .to_vec();
    (status, body)
}

async fn build_state() -> (AppState, tempfile::TempDir) {
    let dir = tempdir().unwrap();
    let iso_store = IsoStore::new(dir.path().join("isos"));
    iso_store.ensure_dirs().await.unwrap();
    let clients = ClientRegistry::new();
    let queue = DeploymentQueue::new();
    let settings = SettingsStore::load_or_default(dir.path());
    let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone());
    let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone());
    let sftp_shares = SftpShareManager::new(dir.path(), iso_store.clone());
    let unattended = openpxe_iso_store::UnattendedStore::new(dir.path().join("unattended"));
    unattended.ensure_dir().await.unwrap();
    let log_bus = LogBus::new(64);
    let hosts = HostBindings::load_or_default(dir.path());
    let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
    let branding = openpxe_core::BrandingStore::load_or_default(dir.path());
    let admin = openpxe_core::AdminStore::load_or_default(dir.path());
    let sso = openpxe_core::SsoStore::load_or_default(dir.path());
    let notify = openpxe_core::NotifyStore::load_or_default(dir.path());
    let sessions = openpxe_http_api::auth::SessionStore::default();
    let metrics = Metrics::new();
    let state = AppState {
        iso_store,
        clients,
        queue,
        settings,
        hosts,
        boot_log,
        branding,
        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":"ops@example.com","smtp_from":"openpxe@example.com","smtp_password":"s3cret"}"#,
    )
    .await;
    assert_eq!(s, StatusCode::OK);
    // GET must redact the password (never echo the real secret).
    let (s, b) = get(&app, "/api/notify").await;
    assert_eq!(s, StatusCode::OK);
    let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
    assert_eq!(v["enabled"], true);
    assert_eq!(v["kind"], "smtp");
    let pw = v["smtp_password"].as_str().unwrap_or("");
    assert_ne!(pw, "s3cret", "raw password must never be returned");
    assert!(
        !pw.is_empty(),
        "a set password should surface as a sentinel"
    );
}

#[tokio::test]
async fn notify_enable_webhook_without_url_is_rejected() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let (s, _b) = put_json(
        &app,
        "/api/notify",
        r#"{"enabled":true,"kind":"slack","webhook_url":""}"#,
    )
    .await;
    assert_eq!(s, StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn wol_on_unbound_mac_is_404() {
    // WoL only fires for bound MACs — an arbitrary MAC must 404 so the
    // endpoint isn't an open packet sprayer.
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let (s, _b) = post_json(&app, "/api/hosts/aa:bb:cc:dd:ee:ff/wol", "{}").await;
    assert_eq!(s, StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn nfs_shares_list_starts_empty() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let (s, b) = get(&app, "/api/nfs-shares").await;
    assert_eq!(s, StatusCode::OK);
    let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
    assert_eq!(v["shares"].as_array().unwrap().len(), 0);
}

#[tokio::test]
async fn nfs_shares_add_rejects_missing_server() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let (s, b) = post_json(
        &app,
        "/api/nfs-shares",
        r#"{"server":"","export":"/srv/isos"}"#,
    )
    .await;
    assert_eq!(s, StatusCode::BAD_REQUEST);
    let msg = String::from_utf8_lossy(&b);
    assert!(
        msg.to_lowercase().contains("server"),
        "expected server hint, got: {msg}"
    );
}

#[tokio::test]
async fn nfs_shares_add_rejects_export_without_leading_slash() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let (s, b) = post_json(
        &app,
        "/api/nfs-shares",
        r#"{"server":"10.0.0.5","export":"srv/isos"}"#,
    )
    .await;
    assert_eq!(s, StatusCode::BAD_REQUEST);
    let msg = String::from_utf8_lossy(&b);
    assert!(
        msg.to_lowercase().contains("export"),
        "expected export-path hint, got: {msg}"
    );
}

#[tokio::test]
async fn terminal_help_and_status_round_trip() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);

    // Empty command -> help banner.
    let (s, b) = post_json(&app, "/api/terminal", r#"{"command":""}"#).await;
    assert_eq!(s, StatusCode::OK);
    let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
    assert!(v["output"].as_str().unwrap().contains("OpenPXE terminal"));

    // status -> contains the version banner.
    let (s, b) = post_json(&app, "/api/terminal", r#"{"command":"status"}"#).await;
    assert_eq!(s, StatusCode::OK);
    let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
    let out = v["output"].as_str().unwrap();
    assert!(
        out.starts_with("OpenPXE"),
        "unexpected status output: {out}"
    );
    assert!(out.contains("isos:"), "status missing iso line: {out}");

    // Unknown command -> ok=false plus help hint.
    let (_, b) = post_json(&app, "/api/terminal", r#"{"command":"frobnicate"}"#).await;
    let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
    assert_eq!(v["ok"], false);
    assert!(v["output"].as_str().unwrap().contains("unknown command"));
}

#[tokio::test]
async fn log_recent_returns_buffered_lines() {
    // The terminal command we issued seeds the log bus, so a follow-up
    // /api/log/recent must surface those lines as JSON.
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let _ = post_json(&app, "/api/terminal", r#"{"command":"version"}"#).await;
    let (s, b) = get(&app, "/api/log/recent").await;
    assert_eq!(s, StatusCode::OK);
    let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
    let lines = v["lines"].as_array().expect("lines array");
    assert!(
        !lines.is_empty(),
        "log buffer should have at least one line"
    );
    // Every entry should have the canonical timestamp/level/target/message.
    for l in lines {
        for k in ["timestamp", "level", "target", "message"] {
            assert!(l.get(k).is_some(), "missing field {k} in log line: {l}");
        }
    }
}

#[tokio::test]
async fn windows_iso_renders_clean_sanboot_script_with_no_trust_store_writes() {
    // Synthesize an ISO with a Windows volume label + the sources/boot.wim
    // sentinel so introspection labels it WindowsPe with has_boot_wim.
    let mut buf = vec![0u8; 32 * 2048];
    let off = 16 * 2048;
    buf[off] = 0x01;
    buf[off + 1..off + 6].copy_from_slice(b"CD001");
    buf[off + 6] = 0x01;
    let label = b"WIN11_X64".to_vec();
    let mut padded = label.clone();
    padded.resize(32, b' ');
    buf[off + 40..off + 40 + 32].copy_from_slice(&padded);
    // Sprinkle the sources/boot.wim sentinel where the introspection
    // scanner will find it (anywhere in the first 64 MB).
    let sentinel = b"SOURCES\\BOOT.WIM";
    buf.extend_from_slice(sentinel);
    let term = 17 * 2048;
    buf[term] = 0xFF;
    buf[term + 1..term + 6].copy_from_slice(b"CD001");
    buf[term + 6] = 0x01;

    let (state, _dir) = build_state().await;
    let app = build_router(state);

    // Need windows_enabled for the Windows path to render in the menu.
    let res = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/api/settings")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"boot_menu_timeout_secs":600,"timeout_action":"queued_deployment",
                       "windows_enabled":false,"smb_host_override":"","extra_kernel_args":"",
                       "default_local_hdd":true,"queue_wait_max_secs":0,"dns_server":""}"#
                        .to_string(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    // wimboot binary is bundled in this repo so windows_enabled=true should
    // not be rejected; we leave it false to keep the upload path agnostic.
    assert_eq!(res.status(), StatusCode::NO_CONTENT);

    let (ct, body) = multipart_iso_body("Win11_x64.iso", &buf);
    let res = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/isos")
                .header("content-type", ct)
                .body(Body::from(body))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::CREATED);
    let body = axum::body::to_bytes(res.into_body(), usize::MAX)
        .await
        .unwrap();
    let meta: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert_eq!(meta["introspection"]["family"], "windows_pe");
    assert!(
        meta["introspection"]["has_boot_wim"].as_bool().unwrap(),
        "introspection should detect sources/boot.wim sentinel"
    );

    // v0.5.8: Windows boots via iPXE HTTP sanboot of the raw ISO — no SMB,
    // no extraction, no in-ISO file serving, no operator toggle. The boot
    // entry is a `san_boot_iso` kind pointing at the raw image.
    let entry = &meta["boot_entries"][0];
    assert_eq!(entry["kind"]["kind"], "san_boot_iso");
    let iso_url = entry["kind"]["iso_url"].as_str().unwrap();
    assert!(
        std::path::Path::new(iso_url)
            .extension()
            .is_some_and(|e| e.eq_ignore_ascii_case("iso")),
        "sanboot should target the raw ISO, got: {iso_url}"
    );

    // Render the entry script and verify:
    //   1. It uses `sanboot` against the raw ISO over HTTP
    //   2. NO trust-store / driver / testsigning operations slip in
    let entry_id = entry["id"].as_str().unwrap();
    let url = format!("/boot/{entry_id}.ipxe");
    let (s, body) = get(&app, &url).await;
    assert_eq!(s, StatusCode::OK);
    let script = String::from_utf8(body).unwrap();
    assert!(
        script.contains("sanboot"),
        "missing sanboot line:\n{script}"
    );
    assert!(
        script.contains(&format!("/{iso_url}")),
        "sanboot should reference the raw ISO url:\n{script}"
    );
    // Hard guarantees we never want to see in any client-facing script.
    let lower = script.to_lowercase();
    for forbidden in [
        "bcdedit",
        "testsigning",
        "certutil",
        "test-signed",
        "httpdisk",
        "/set testsigning",
    ] {
        assert!(
            !lower.contains(forbidden),
            "forbidden trust-store operation `{forbidden}` in script:\n{script}"
        );
    }
}

#[tokio::test]
async fn host_binding_short_circuits_boot_menu() {
    let (state, _dir) = build_state().await;
    let app = build_router(state.clone());

    // Pin a MAC to the reserved local-hdd boot shortcut. `_local` is a
    // built-in target so the upsert validator accepts it without
    // requiring a real ISO.
    let res = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/hosts")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"mac":"AA:BB:CC:00:00:01","target":"_local","label":"toms-laptop"}"#
                        .to_string(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::CREATED);

    // Hit /boot.ipxe with the bound MAC and assert we get the
    // short-circuit chain instead of the menu.
    let (s1, b1) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01").await;
    assert_eq!(s1, StatusCode::OK);
    let body1 = String::from_utf8(b1).unwrap();
    assert!(
        body1.contains("per-MAC binding"),
        "expected MAC short-circuit, got:\n{body1}"
    );
    assert!(body1.contains("/boot/_local.ipxe"));

    // And a different MAC still gets the menu.
    let (s2, b2) = get(&app, "/boot.ipxe?mac=ff:ff:ff:ff:ff:ff").await;
    assert_eq!(s2, StatusCode::OK);
    let body2 = String::from_utf8(b2).unwrap();
    assert!(
        body2.contains("menu") || body2.contains("Default"),
        "expected interactive menu, got:\n{body2}"
    );
}

#[tokio::test]
async fn metrics_endpoint_emits_prometheus_format() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    // Drive a couple of paths so counters move off zero.
    let _ = get(&app, "/api/status").await;
    let _ = get(&app, "/boot.ipxe").await;

    let res = app
        .clone()
        .oneshot(
            Request::builder()
                .uri("/metrics")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::OK);
    let ct = res
        .headers()
        .get(header::CONTENT_TYPE)
        .unwrap()
        .to_str()
        .unwrap();
    assert!(ct.starts_with("text/plain"), "wrong content-type: {ct}");
    let body = axum::body::to_bytes(res.into_body(), usize::MAX)
        .await
        .unwrap();
    let body = String::from_utf8(body.to_vec()).unwrap();
    // Spot-check the must-have metric families.
    for name in [
        "openpxe_dhcp_replies_total",
        "openpxe_tftp_transfers_total",
        "openpxe_http_requests_total",
        "openpxe_iso_count",
        "openpxe_uptime_seconds",
        "openpxe_build_info",
    ] {
        assert!(body.contains(name), "missing metric {name} in:\n{body}");
    }
    // Each name appears exactly once as a `# TYPE` declaration.
    for name in ["openpxe_dhcp_replies_total", "openpxe_iso_count"] {
        let count = body.matches(&format!("# TYPE {name}")).count();
        assert_eq!(count, 1, "{name} TYPE line appears {count} times");
    }
}

#[tokio::test]
async fn network_endpoint_exposes_dns_round_trip() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);

    // GET starts blank.
    let (_, b) = get(&app, "/api/network").await;
    let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
    assert_eq!(v["dns_server"], "");
    assert_eq!(v["nic_name"], "lo");

    // PUT updates only the DNS field.
    let res = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/api/network")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"dns_server":"10.0.0.1"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::NO_CONTENT);

    let (_, b) = get(&app, "/api/network").await;
    let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
    assert_eq!(v["dns_server"], "10.0.0.1");
}

// ─── Per-ISO password prompt ──────────────────────────────────────────────

#[tokio::test]
async fn iso_password_prompt_blocks_until_correct_token() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);

    // Upload a synthetic Alpine ISO so we have a real boot entry id to
    // protect. Upload filename "fake-alpine.iso" -> id "fake-alpine",
    // boot entry id "fake-alpine-linux".
    let iso = fake_alpine_iso();
    let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso);
    let res = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/isos")
                .header("content-type", ct)
                .body(Body::from(body))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::CREATED);

    // 1. With NO password set, /boot/<id>.ipxe returns the boot script
    //    immediately and the lock indicator is NOT in the menu.
    let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe").await;
    let s = String::from_utf8(body).unwrap();
    assert!(s.contains("kernel "), "expected boot script, got:\n{s}");
    let (_, lm) = get(&app, "/boot/_linux_menu.ipxe").await;
    let lm = String::from_utf8(lm).unwrap();
    assert!(lm.contains("fake-alpine-linux"));
    assert!(
        !lm.contains("fake-alpine-linux *["),
        "expected no lock marker in menu before password set:\n{lm}"
    );

    // 2. Set a password.
    let res = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/api/isos/fake-alpine/password")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"password":"hunter2"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::NO_CONTENT);

    // The menu now shows the lock marker (`*` prefix on the size box).
    let (_, lm) = get(&app, "/boot/_linux_menu.ipxe").await;
    let lm = String::from_utf8(lm).unwrap();
    assert!(
        lm.contains("fake-alpine-linux *["),
        "expected lock marker in menu after password set:\n{lm}"
    );

    // 3. Without a token, /boot/<id>.ipxe now returns the password
    //    PROMPT script (read --secret), not the boot script.
    let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe").await;
    let s = String::from_utf8(body).unwrap();
    assert!(
        s.contains("read --secret password"),
        "expected prompt script with no token, got:\n{s}"
    );
    assert!(!s.contains("kernel "), "should not include kernel line yet");

    // 4. Wrong token -> "Wrong password." script that chains back to the entry.
    let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe?token=wrongpw").await;
    let s = String::from_utf8(body).unwrap();
    assert!(
        s.contains("Wrong password."),
        "expected auth-fail script, got:\n{s}"
    );
    assert!(s.contains("/boot/fake-alpine-linux.ipxe"));
    assert!(!s.contains("kernel "));
    // Critical: the WRONG token must NEVER be echoed back in the script.
    assert!(
        !s.contains("wrongpw"),
        "wrong token must not appear in response"
    );

    // 5. Correct token -> real boot script.
    let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe?token=hunter2").await;
    let s = String::from_utf8(body).unwrap();
    assert!(
        s.contains("kernel "),
        "expected boot script with correct token, got:\n{s}"
    );
    // Don't echo the password into the boot script either.
    assert!(
        !s.contains("hunter2"),
        "correct password must not leak into boot script"
    );

    // 6. Clear the password (DELETE).
    let res = app
        .clone()
        .oneshot(
            Request::builder()
                .method("DELETE")
                .uri("/api/isos/fake-alpine/password")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::NO_CONTENT);

    // Boot is open again, no lock indicator.
    let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe").await;
    let s = String::from_utf8(body).unwrap();
    assert!(
        s.contains("kernel "),
        "expected boot script after clear, got:\n{s}"
    );
    let (_, lm) = get(&app, "/boot/_linux_menu.ipxe").await;
    let lm = String::from_utf8(lm).unwrap();
    assert!(!lm.contains("fake-alpine-linux *["));
}

#[tokio::test]
async fn iso_password_set_then_clear_via_null_body() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);

    // Upload + set + clear via `{"password": null}` (alternative to DELETE).
    let iso = fake_alpine_iso();
    let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso);
    let res = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/isos")
                .header("content-type", ct)
                .body(Body::from(body))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::CREATED);

    for body in [
        r#"{"password":"x"}"#,
        r#"{"password":null}"#,
        r#"{"password":""}"#,
    ] {
        let res = app
            .clone()
            .oneshot(
                Request::builder()
                    .method("PUT")
                    .uri("/api/isos/fake-alpine/password")
                    .header("content-type", "application/json")
                    .body(Body::from(body.to_string()))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(res.status(), StatusCode::NO_CONTENT, "body={body}");
    }
    // After the empty string, the entry should be unprotected.
    let (_, b) = get(&app, "/boot/fake-alpine-linux.ipxe").await;
    let s = String::from_utf8(b).unwrap();
    assert!(
        s.contains("kernel "),
        "should be unprotected after empty pw, got:\n{s}"
    );
}

#[tokio::test]
async fn set_password_for_unknown_iso_returns_404() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let res = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/api/isos/does-not-exist/password")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"password":"x"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn boot_log_records_entry_serve_with_mac() {
    // End-to-end: upload an ISO, fetch the entry's boot script with a
    // MAC query param, then GET /api/boot-log and assert the event is
    // there with the supplied mac.
    let (state, _dir) = build_state().await;
    let app = build_router(state);

    let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
    let upload = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/isos")
                .header("content-type", ct)
                .body(Body::from(body))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(upload.status(), StatusCode::CREATED);

    // Fetch the per-entry script with ?mac=...
    let (s, _) = get(&app, "/boot/fake-alpine-linux.ipxe?mac=AA:BB:CC:00:00:09").await;
    assert_eq!(s, StatusCode::OK);

    // The boot log should now contain exactly one entry, with the
    // normalized MAC and our target id.
    let (s, body) = get(&app, "/api/boot-log").await;
    assert_eq!(s, StatusCode::OK);
    let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
    let events = v["events"].as_array().expect("events");
    assert_eq!(events.len(), 1);
    let ev = &events[0];
    assert_eq!(ev["target_id"], "fake-alpine-linux");
    assert_eq!(ev["mac"], "aa:bb:cc:00:00:09"); // normalized
                                                // Title should include the filename and entry title.
    let title = ev["target_title"].as_str().unwrap();
    assert!(title.contains("fake-alpine.iso"), "title was {title}");
}

#[tokio::test]
async fn boot_log_endpoint_empty_when_no_boots() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let (s, body) = get(&app, "/api/boot-log").await;
    assert_eq!(s, StatusCode::OK);
    let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert!(v["events"].as_array().unwrap().is_empty());
}

#[tokio::test]
async fn boot_log_does_not_record_reserved_menu_targets() {
    // Reserved targets (_local, _queue, …) are operator console actions,
    // not imaging events. The Hosts log skips them so it stays focused
    // on "what got installed where".
    let (state, _dir) = build_state().await;
    let app = build_router(state.clone());

    // Bind a MAC to the _local shortcut and hit /boot.ipxe.
    let body = r#"{"mac":"aa:bb:cc:00:00:11","target":"_local","label":"q"}"#;
    let (s, _) = post_json(&app, "/api/hosts", body).await;
    assert_eq!(s, StatusCode::CREATED);
    let (s, _) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:11").await;
    assert_eq!(s, StatusCode::OK);

    let (_, body) = get(&app, "/api/boot-log").await;
    let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert!(
        v["events"].as_array().unwrap().is_empty(),
        "reserved targets should not appear in boot log; got {v}"
    );
}

#[tokio::test]
async fn upload_rejects_non_iso_filename_with_clear_message() {
    // Sanity for the upload-logging path: a wrong extension should land
    // a 400 with the human message rather than silently being eaten by
    // the multipart loop. (No iso ends up in the store either.)
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let (ct, body) = multipart_iso_body("not-an-iso.txt", b"hello world");
    let res = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/isos")
                .header("content-type", ct)
                .body(Body::from(body))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::BAD_REQUEST);
    let body = axum::body::to_bytes(res.into_body(), usize::MAX)
        .await
        .unwrap();
    let text = std::str::from_utf8(&body).unwrap();
    assert!(text.contains("only .iso uploads accepted"), "got: {text}");
}

#[tokio::test]
async fn chunked_upload_writes_progressively_and_finishes_iso() {
    let (state, dir) = build_state().await;
    let app = build_router(state);
    let iso = fake_alpine_iso();

    let start = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/uploads")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"filename":"chunked-alpine.iso","size_bytes":65536}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(start.status(), StatusCode::CREATED);
    let body = axum::body::to_bytes(start.into_body(), usize::MAX)
        .await
        .unwrap();
    let started: serde_json::Value = serde_json::from_slice(&body).unwrap();
    let upload_id = started["upload_id"].as_str().unwrap();

    let split = 8192usize;
    let first = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri(format!("/api/uploads/{upload_id}"))
                .header("x-openpxe-upload-offset", "0")
                .body(Body::from(iso[..split].to_vec()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(first.status(), StatusCode::ACCEPTED);
    let body = axum::body::to_bytes(first.into_body(), usize::MAX)
        .await
        .unwrap();
    let progress: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert_eq!(progress["offset"].as_u64().unwrap(), split as u64);
    assert!(!progress["complete"].as_bool().unwrap());
    assert!(
        dir.path().join("isos/chunked-alpine.partial").exists(),
        "chunked upload should leave a visible partial file while in progress"
    );

    let final_chunk = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri(format!("/api/uploads/{upload_id}"))
                .header("x-openpxe-upload-offset", split.to_string())
                .header("x-openpxe-upload-complete", "true")
                .body(Body::from(iso[split..].to_vec()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(final_chunk.status(), StatusCode::CREATED);
    let body = axum::body::to_bytes(final_chunk.into_body(), usize::MAX)
        .await
        .unwrap();
    let finished: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert!(finished["complete"].as_bool().unwrap());
    assert_eq!(finished["iso"]["id"], "chunked-alpine");
    assert!(dir.path().join("isos/chunked-alpine.iso").exists());
    assert!(!dir.path().join("isos/chunked-alpine.partial").exists());
}

#[tokio::test]
async fn chunked_upload_rejects_offset_mismatch_without_advancing() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);

    let start = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/uploads")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"filename":"offset-test.iso","size_bytes":16}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(start.status(), StatusCode::CREATED);
    let body = axum::body::to_bytes(start.into_body(), usize::MAX)
        .await
        .unwrap();
    let started: serde_json::Value = serde_json::from_slice(&body).unwrap();
    let upload_id = started["upload_id"].as_str().unwrap();

    let mismatch = app
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri(format!("/api/uploads/{upload_id}"))
                .header("x-openpxe-upload-offset", "8")
                .body(Body::from(vec![1, 2, 3, 4]))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(mismatch.status(), StatusCode::CONFLICT);
    let body = axum::body::to_bytes(mismatch.into_body(), usize::MAX)
        .await
        .unwrap();
    let text = String::from_utf8(body.to_vec()).unwrap();
    assert!(text.contains("expected offset 0"), "got: {text}");
}

#[tokio::test]
async fn iso_category_switch_moves_entry_between_menus() {
    // OS (default): appears in Linux Installers submenu and not in Tools.
    // After flipping to Tools: gone from Linux, present under Tools.
    let (state, _dir) = build_state().await;
    let app = build_router(state);

    let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
    let upload = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/isos")
                .header("content-type", ct)
                .body(Body::from(body))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(upload.status(), StatusCode::CREATED);

    let (_, linux_before) = get(&app, "/boot/_linux_menu.ipxe").await;
    let linux_before = String::from_utf8(linux_before).unwrap();
    assert!(
        linux_before.contains("fake-alpine-linux"),
        "expected entry in Linux submenu (default OS):\n{linux_before}"
    );
    let (_, tools_before) = get(&app, "/boot/_tools_menu.ipxe").await;
    let tools_before = String::from_utf8(tools_before).unwrap();
    assert!(
        !tools_before.contains("fake-alpine-linux"),
        "OS-category ISO shouldn't appear in Tools yet:\n{tools_before}"
    );

    // Flip to Tools.
    let (s, _) = put_json(
        &app,
        "/api/isos/fake-alpine/category",
        r#"{"category":"tools"}"#,
    )
    .await;
    assert_eq!(s, StatusCode::OK);

    let (_, linux_after) = get(&app, "/boot/_linux_menu.ipxe").await;
    let linux_after = String::from_utf8(linux_after).unwrap();
    assert!(
        !linux_after.contains("fake-alpine-linux"),
        "Tools-category ISO should NOT appear in Linux submenu:\n{linux_after}"
    );
    let (_, tools_after) = get(&app, "/boot/_tools_menu.ipxe").await;
    let tools_after = String::from_utf8(tools_after).unwrap();
    assert!(
        tools_after.contains("fake-alpine-linux"),
        "Tools-category ISO should appear in Tools submenu:\n{tools_after}"
    );
}

#[tokio::test]
async fn iso_category_unknown_value_returns_400() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
    app.clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/isos")
                .header("content-type", ct)
                .body(Body::from(body))
                .unwrap(),
        )
        .await
        .unwrap();

    let (s, body) = put_json(
        &app,
        "/api/isos/fake-alpine/category",
        r#"{"category":"gibberish"}"#,
    )
    .await;
    assert_eq!(s, StatusCode::BAD_REQUEST);
    let text = std::str::from_utf8(&body).unwrap();
    assert!(text.contains("unknown category"), "got: {text}");
}

#[tokio::test]
async fn iso_category_unknown_id_returns_404() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let (s, _) = put_json(
        &app,
        "/api/isos/does-not-exist/category",
        r#"{"category":"tools"}"#,
    )
    .await;
    assert_eq!(s, StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn storage_disk_endpoint_reports_volume_stats() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let (s, body) = get(&app, "/api/storage/disk").await;
    assert_eq!(s, StatusCode::OK);
    let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
    // statvfs always returns something on the tempdir filesystem; check
    // that the shape is sane (total >= used >= 0 and total >= available).
    assert!(v["total_bytes"].as_u64().unwrap() > 0, "got {v}");
    let total = v["total_bytes"].as_u64().unwrap();
    let avail = v["available_bytes"].as_u64().unwrap();
    let used = v["used_bytes"].as_u64().unwrap();
    assert!(total >= avail, "{v}");
    assert!(total >= used, "{v}");
    assert!(v["path"].as_str().unwrap().contains("isos"), "got {v}");
}

#[tokio::test]
async fn api_docs_lists_known_endpoints() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let (s, body) = get(&app, "/api/docs").await;
    assert_eq!(s, StatusCode::OK);
    let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
    let groups = v["groups"].as_array().expect("groups array");
    assert!(!groups.is_empty());
    // Flatten the paths and confirm a handful of the routes that real
    // operators will look up are documented.
    let mut paths: Vec<String> = Vec::new();
    for g in groups {
        for ep in g["endpoints"].as_array().unwrap() {
            paths.push(ep["path"].as_str().unwrap().into());
        }
    }
    for needle in [
        "/api/isos",
        "/api/isos/:id/category",
        "/api/storage/disk",
        "/api/branding/logo/: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">tech@example.com</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));
}
