Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

mannequin/tests/smoke_serve.rs

//! Mannequin smoke test — proves the test-dummy daemon still BOOTS and serves.
//!
//! Two boot cells, one LAW (inject real input + assert real output):
//!   * `mannequin_boots_in_process_and_serves` — the **CI-safe injected-fixture**
//!     path. It constructs the real `MannequinServer` in-process (fresh in-memory
//!     CA, no config, no files), binds an ephemeral port it owns, serves it on a
//!     background task and asserts the live daemon actually answers `/health` +
//!     serves a real CA PEM from its PKI subsystem. NO subprocess, NO pre-built
//!     binary, NO external network — so it runs unconditionally in CI (not
//!     `#[ignore]`'d).
//!   * `mannequin_serve_boots_and_listens` — the **heavy end-to-end** path. It
//!     spawns the actual compiled `holger-mannequin serve` binary on an ephemeral
//!     port and asserts the daemon comes up and accepts a TCP connection. Kept
//!     `#[ignore]`'d (heavy: builds + spawns the binary), so it runs in the release
//!     gate's `--include-ignored` pass (heavy-on by default; opt out with
//!     `nornir --skip-heavy`). Run locally: `cargo test -p mannequin -- --ignored`.

use std::io::Write as _;
use std::net::TcpStream;
use std::process::{Child, Command};
use std::time::{Duration, Instant};
use traits::test_support::hold_port;

use mannequin::MannequinServer;

/// The **injected-fixture** boot cell — CI-safe, no subprocess, no external deps.
///
/// Builds the real `MannequinServer` (which generates a fresh in-memory P256 CA —
/// milliseconds, no files), binds a port we own so there is no free-port race,
/// serves the real axum router on a background task, then asserts the live daemon
/// answers over the loopback:
///   * `GET /health` → `200 OK` with body `OK`;
///   * `GET /pki/ca.pem` → a real CA certificate PEM (proves the PKI subsystem is
///     actually wired and serving, not just that the process is up).
/// This is the boot proof CI can run every push, unlike the `#[ignore]`'d
/// subprocess sibling below.
#[tokio::test]
async fn mannequin_boots_in_process_and_serves() {
    // Real server, default config → fresh in-memory CA, no on-disk state.
    let server = MannequinServer::new(0).expect("build in-process MannequinServer");
    let router = server.router();

    // Bind the listener ourselves so we learn the real assigned port with no race.
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
        .await
        .expect("bind ephemeral loopback port");
    let addr = listener.local_addr().expect("resolve bound addr");
    tokio::spawn(async move {
        let _ = axum::serve(listener, router).await;
    });

    let client = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");

    // Poll /health until the background serve task is accepting (near-instant, but
    // be robust to scheduling on a busy CI box). Generous but bounded deadline.
    let health_url = format!("http://{addr}/health");
    let deadline = Instant::now() + Duration::from_secs(10);
    let mut body = None;
    while Instant::now() < deadline {
        if let Ok(resp) = client.get(&health_url).send().await {
            if resp.status().is_success() {
                body = Some(resp.text().await.expect("read /health body"));
                break;
            }
        }
        tokio::time::sleep(Duration::from_millis(50)).await;
    }
    let body = body.expect("mannequin /health did not answer 200 within 10s");
    assert_eq!(body.trim(), "OK", "/health must report OK, got {body:?}");

    // The PKI subsystem is live and serving a real CA cert — a stronger boot proof
    // than "the socket opened": the in-process CA the fixture built is reachable.
    let ca_pem = client
        .get(format!("http://{addr}/pki/ca.pem"))
        .send()
        .await
        .expect("GET /pki/ca.pem")
        .text()
        .await
        .expect("read CA PEM");
    assert!(
        ca_pem.contains("BEGIN CERTIFICATE"),
        "PKI subsystem must serve a real CA PEM, got: {ca_pem:?}"
    );
}

struct Daemon(Child);
impl Drop for Daemon {
    fn drop(&mut self) {
        let _ = self.0.kill();
        let _ = self.0.wait();
    }
}

#[test]
#[ignore = "heavy: spawns the mannequin daemon + generates certs"]
fn mannequin_serve_boots_and_listens() {
    let port = hold_port();
    let child = Command::new(env!("CARGO_BIN_EXE_holger-mannequin"))
        .args(["serve", "--port", &port.to_string()])
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn()
        .expect("spawn holger-mannequin serve");
    let _guard = Daemon(child);

    // Assert the daemon is actually listening within a generous deadline (cert
    // generation on first boot takes a moment).
    let deadline = Instant::now() + Duration::from_secs(30);
    let mut up = false;
    while Instant::now() < deadline {
        if let Ok(mut s) = TcpStream::connect(("127.0.0.1", port)) {
            // A live TCP accept is the boot signal; nudge it so the connect is real.
            let _ = s.write_all(b"\r\n");
            up = true;
            break;
        }
        std::thread::sleep(Duration::from_millis(250));
    }
    assert!(
        up,
        "mannequin did not start listening on 127.0.0.1:{port} within 30s"
    );
}

/// reqwest 0.13 with reqwest 0.12 `rustls-tls` behaviour (2026-10-02): the ring
/// provider and the bundled Mozilla roots (webpki), not 0.13's aws-lc-rs + OS
/// trust store. Roots added later on the builder are merged in, as in 0.12.
#[allow(dead_code)]
fn tls_roots(b: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
    let _ = rustls::crypto::ring::default_provider().install_default();
    b.tls_certs_only(
        webpki_root_certs::TLS_SERVER_ROOT_CERTS
            .iter()
            .filter_map(|c| reqwest::Certificate::from_der(c).ok()),
    )
}