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()),
)
}