bootstrap.rs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  1. //! `test(bootstrap)` — real `serve` bootstrap over the shared test services
  2. //! (plan todo 41).
  3. //!
  4. //! Both tests boot the actual `d-oauth-server` binary (never an in-process
  5. //! router): `config check` first (fail-fast env smoke), then `migrate` (the
  6. //! only path that applies migrations — `serve` never does), then `serve`
  7. //! with a TLS public listener (PKI client-CA, the mTLS reachability
  8. //! contract) plus the plaintext pod-IP health listener. Postgres and Redis
  9. //! come from `compose.test.yaml`
  10. //! (`docker compose -f compose.test.yaml up -d`); each test uses a freshly
  11. //! created database and unique Redis key prefixes, so parallel tests never
  12. //! share state (and never FLUSH shared state).
  13. //!
  14. //! * `bootstrap_serve_tls_mtls_registered_completes_unregistered_rejected`:
  15. //! `/health/ready` is 200 beforehand, mutual-TLS traffic flows through
  16. //! the production accept loop, the presented leaf completes
  17. //! `mtls::authenticate_by_id` for the registered `tls_client_auth`
  18. //! client (the injected peer chain reaches `mtls.rs`), and an
  19. //! unregistered self-signed cert fails the handshake and the matcher.
  20. //! * `bootstrap_serve_sigterm_drains_ready_and_exits_zero`: after SIGTERM,
  21. //! `/health/ready` flips to 503 inside the propagation grace and the
  22. //! process exits 0 with the listeners closed.
  23. //!
  24. //! Test names contain `bootstrap` so
  25. //! `cargo nextest run -E 'test(bootstrap)'` selects exactly them. Bodies use
  26. //! `Result` + `?` (`unwrap`/`expect`/`panic` are denied workspace-wide).
  27. #[path = "mtls/certs.rs"]
  28. // The fixture module is shared per-target with `test(mtls)`: this target
  29. // uses a subset (`der_b64` belongs to the proxy-header suite), so the
  30. // include carries a scoped, documented allow instead of restructuring a
  31. // peer-owned file.
  32. #[allow(dead_code)]
  33. mod certs;
  34. mod common;
  35. use std::{
  36. net::SocketAddr,
  37. path::PathBuf,
  38. process::Stdio,
  39. sync::Arc,
  40. time::{Duration, Instant},
  41. };
  42. use common::{pg_server_url, redis_url, unique_prefixes};
  43. use d_oauth::protocol::mtls;
  44. use rustls::pki_types::{CertificateDer, PrivateKeyDer};
  45. use sqlx::{Executor as _, postgres::PgPoolOptions};
  46. /// Registered `tls_client_auth` client for the mTLS test.
  47. const CLIENT_ID: &str = "bootstrap-tls-client";
  48. /// Subject DN of the CA-signed fixture, in [`mtls::subject_dn`] rendering.
  49. const PKI_DN: &str = "CN=mtls-pki-client, O=Example";
  50. /// `x5t#S256` of the CA-signed fixture (locked by `test(mtls)` too).
  51. const PKI_THUMBPRINT: &str = "PC8ir71uIdRIdw1XFkMmoFvGC8Ud4CANxkk4x3BWqAc";
  52. /// Test-body error.
  53. #[derive(Debug, thiserror::Error)]
  54. enum TestError {
  55. /// The shared test service is unreachable (usually: compose not started).
  56. #[error("test setup: {0}")]
  57. Setup(String),
  58. /// A sqlx operation failed.
  59. #[error("database error: {0}")]
  60. Database(#[from] sqlx::Error),
  61. /// An mTLS operation failed.
  62. #[error("mtls error: {0}")]
  63. Mtls(#[from] mtls::MtlsError),
  64. /// A fixture did not decode.
  65. #[error("fixture error: {0}")]
  66. Fixture(#[from] certs::FixtureError),
  67. /// A rustls operation failed.
  68. #[error("tls error: {0}")]
  69. Tls(#[from] rustls::Error),
  70. /// A DNS name was invalid.
  71. #[error("dns error: {0}")]
  72. Dns(#[from] rustls::pki_types::InvalidDnsNameError),
  73. /// A response was not UTF-8.
  74. #[error("encoding error: {0}")]
  75. Encoding(#[from] std::string::FromUtf8Error),
  76. /// An I/O operation failed.
  77. #[error("io error: {0}")]
  78. Io(#[from] std::io::Error),
  79. /// An async deadline elapsed.
  80. #[error("timeout: {0}")]
  81. Timeout(String),
  82. /// A socket address did not parse.
  83. #[error("address error: {0}")]
  84. Addr(#[from] std::net::AddrParseError),
  85. /// A test step failed.
  86. #[error("failed: {0}")]
  87. Failed(String),
  88. }
  89. type TestResult<T = ()> = Result<T, TestError>;
  90. /// Live world for one test: shared-service URLs, ports, fixture files, and
  91. /// the config file the child `serve` process boots from.
  92. struct World {
  93. pg_url: String,
  94. tls_port: u16,
  95. health_port: u16,
  96. config_path: String,
  97. }
  98. /// Locate the compiled server binary (cargo sets one of these spellings for
  99. /// integration targets; otherwise resolve next to the test executable).
  100. fn server_binary() -> Result<PathBuf, TestError> {
  101. for var in [
  102. "CARGO_BIN_EXE_d-oauth-server",
  103. "CARGO_BIN_EXE_d_oauth_server",
  104. ] {
  105. if let Ok(path) = std::env::var(var) {
  106. return Ok(PathBuf::from(path));
  107. }
  108. }
  109. let mut path = std::env::current_exe()?;
  110. if !path.pop() {
  111. return Err(TestError::Failed(
  112. "test executable has no parent".to_owned(),
  113. ));
  114. }
  115. if path.ends_with("deps") && !path.pop() {
  116. return Err(TestError::Failed("cannot leave deps dir".to_owned()));
  117. }
  118. path.push("d-oauth-server");
  119. Ok(path)
  120. }
  121. /// An unused loopback port (TOCTOU-tolerated: the child binds it right away).
  122. async fn free_port() -> Result<u16, TestError> {
  123. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
  124. Ok(listener.local_addr()?.port())
  125. }
  126. /// Hint shown when the shared test services are unreachable.
  127. const COMPOSE_HINT: &str = "run `docker compose -f compose.test.yaml up -d`";
  128. /// Create a uniquely named database on the shared Postgres server and return
  129. /// a URL pointing at it (the child `serve` process migrates it via its own
  130. /// `migrate` subcommand, like production).
  131. async fn fresh_db_url() -> Result<String, TestError> {
  132. let server = pg_server_url();
  133. let admin = PgPoolOptions::new()
  134. .max_connections(1)
  135. .connect(&server)
  136. .await
  137. .map_err(|error| {
  138. TestError::Setup(format!(
  139. "cannot reach shared test Postgres at {server}: {error}; {COMPOSE_HINT}"
  140. ))
  141. })?;
  142. let name = format!("bootstrap_{}", uuid::Uuid::new_v4().simple());
  143. // `CREATE DATABASE` cannot take a bind parameter, and `Pool`'s `Executor`
  144. // requires a `'static` query, so leak this one tiny test-only string.
  145. let ddl: &'static str = Box::leak(format!("CREATE DATABASE \"{name}\"").into_boxed_str());
  146. let created = admin
  147. .execute(ddl)
  148. .await
  149. .map_err(|error| TestError::Setup(format!("failed to create test database {name}: {error}")));
  150. admin.close().await;
  151. created?;
  152. Ok(server.replacen("/postgres?", &format!("/{name}?"), 1))
  153. }
  154. /// Build one test world: shared-service URLs, ports, PEM fixture files,
  155. /// config file.
  156. async fn build_world() -> Result<World, TestError> {
  157. let pg_url = fresh_db_url().await?;
  158. let redis_url = redis_url();
  159. let (token_prefix, ratelimit_prefix) = unique_prefixes();
  160. let tls_port = free_port().await?;
  161. let health_port = free_port().await?;
  162. let dir = std::env::temp_dir().join(format!("bootstrap-test-{}", uuid::Uuid::new_v4()));
  163. std::fs::create_dir_all(&dir)?;
  164. let cert_path = write_file(&dir, "server-cert.pem", certs::SERVER_CERT_PEM)?;
  165. let key_path = write_file(&dir, "server-key.pem", certs::SERVER_KEY_PEM)?;
  166. let ca_path = write_file(&dir, "client-ca.pem", certs::CA_CERT_PEM)?;
  167. // Layered env (`D_OAUTH_TLS__CERT_PATH`, …) does not reach nested tables,
  168. // so the child boots from a JSON file via `D_OAUTH_CONFIG` (the extension
  169. // is omitted: `File::with_name` probes supported formats).
  170. let config = serde_json::json!({
  171. "issuer": "https://auth.example.test",
  172. "environment": "development",
  173. "bind_addr": format!("127.0.0.1:{tls_port}"),
  174. "health_addr": format!("0.0.0.0:{health_port}"),
  175. "tls": {
  176. "cert_path": path_string(&cert_path)?,
  177. "key_path": path_string(&key_path)?,
  178. "client_ca_path": path_string(&ca_path)?,
  179. },
  180. "database": {"url": pg_url.clone()},
  181. "redis": {
  182. "url": redis_url,
  183. "durability": "waitaof",
  184. "token_key_prefix": token_prefix,
  185. "ratelimit_key_prefix": ratelimit_prefix,
  186. },
  187. "token_pepper": "bootstrap-test-pepper-0123456789abcdef",
  188. "totp_encryption_key": "bootstrap-test-totp-key-0123456789abcdef",
  189. });
  190. let config_file = dir.join("d-oauth.json");
  191. std::fs::write(
  192. &config_file,
  193. serde_json::to_string_pretty(&config)
  194. .map_err(|error| TestError::Failed(format!("config JSON failed: {error}")))?,
  195. )?;
  196. let stem = config_file.with_extension("");
  197. Ok(World {
  198. pg_url,
  199. tls_port,
  200. health_port,
  201. config_path: path_string(&stem)?,
  202. })
  203. }
  204. /// Write one fixture file, returning its path.
  205. fn write_file(dir: &std::path::Path, name: &str, contents: &str) -> Result<PathBuf, TestError> {
  206. let path = dir.join(name);
  207. std::fs::write(&path, contents)?;
  208. Ok(path)
  209. }
  210. /// A filesystem path as UTF-8 (temp dirs always are here).
  211. fn path_string(path: &std::path::Path) -> Result<String, TestError> {
  212. path
  213. .to_str()
  214. .map(str::to_owned)
  215. .ok_or_else(|| TestError::Failed(format!("non-UTF-8 temp path: {}", path.display())))
  216. }
  217. /// A child command for the server binary with the world config applied.
  218. fn child_command(world: &World, args: &[&str]) -> Result<tokio::process::Command, TestError> {
  219. let mut command = tokio::process::Command::new(server_binary()?);
  220. command
  221. .args(args)
  222. .env("D_OAUTH_CONFIG", &world.config_path)
  223. .stdin(Stdio::null());
  224. Ok(command)
  225. }
  226. /// Run a one-shot subcommand (`config check`, `migrate`) to completion.
  227. async fn run_once(world: &World, args: &[&str]) -> Result<(), TestError> {
  228. let output = child_command(world, args)?
  229. .stdout(Stdio::piped())
  230. .stderr(Stdio::piped())
  231. .output()
  232. .await?;
  233. if output.status.success() {
  234. return Ok(());
  235. }
  236. Err(TestError::Failed(format!(
  237. "{} failed: {} {}",
  238. args.join(" "),
  239. output.status,
  240. String::from_utf8_lossy(&output.stderr)
  241. )))
  242. }
  243. /// Spawn the long-lived `serve` child (killed with the test on drop).
  244. fn spawn_serve(world: &World) -> Result<tokio::process::Child, TestError> {
  245. let mut child = child_command(world, &["serve"])?;
  246. child
  247. .stdout(Stdio::null())
  248. .stderr(Stdio::piped())
  249. .kill_on_drop(true)
  250. .spawn()
  251. .map_err(TestError::from)
  252. }
  253. /// Raw plaintext `GET` over TCP, returning `(status, body)`.
  254. async fn http_get_plain(addr: SocketAddr, path: &str) -> Result<(u16, String), TestError> {
  255. use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
  256. let mut stream =
  257. tokio::time::timeout(Duration::from_secs(2), tokio::net::TcpStream::connect(addr))
  258. .await
  259. .map_err(|_| TestError::Timeout("health connect timed out".to_owned()))??;
  260. let request = format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
  261. stream.write_all(request.as_bytes()).await?;
  262. let mut raw = Vec::new();
  263. stream.read_to_end(&mut raw).await?;
  264. parse_response(&raw)
  265. }
  266. /// Split a raw HTTP/1.1 response into `(status, body)`.
  267. fn parse_response(raw: &[u8]) -> Result<(u16, String), TestError> {
  268. let text = String::from_utf8(raw.to_vec())?;
  269. let (head, body) = text
  270. .split_once("\r\n\r\n")
  271. .map_or((text.as_str(), ""), |(head, body)| (head, body));
  272. let line = head
  273. .lines()
  274. .next()
  275. .ok_or_else(|| TestError::Failed("empty HTTP response".to_owned()))?;
  276. let code = line
  277. .split_whitespace()
  278. .nth(1)
  279. .ok_or_else(|| TestError::Failed(format!("bad status line: {line}")))?;
  280. let status: u16 = code
  281. .parse()
  282. .map_err(|_| TestError::Failed(format!("bad status code: {code}")))?;
  283. Ok((status, body.to_owned()))
  284. }
  285. /// Health listener address (reachable via loopback; bound to the pod-IP wildcard).
  286. fn health_addr(world: &World) -> Result<SocketAddr, TestError> {
  287. Ok(SocketAddr::from(([127, 0, 0, 1], world.health_port)))
  288. }
  289. /// Poll `/health/ready` until it answers 200 (fails fast if the child dies).
  290. async fn poll_ready(world: &World, child: &mut tokio::process::Child) -> Result<(), TestError> {
  291. let addr = health_addr(world)?;
  292. let deadline = Instant::now() + Duration::from_secs(90);
  293. loop {
  294. if let Some(status) = child.try_wait()? {
  295. return Err(TestError::Failed(format!("serve exited early: {status}")));
  296. }
  297. match http_get_plain(addr, "/health/ready").await {
  298. Ok((200, _)) => return Ok(()),
  299. Ok(_) | Err(_) if Instant::now() < deadline => {}
  300. Ok((status, body)) => {
  301. return Err(TestError::Failed(format!(
  302. "ready never 200: {status} {body}"
  303. )));
  304. }
  305. Err(error) => {
  306. return Err(TestError::Failed(format!("ready never reachable: {error}")));
  307. }
  308. }
  309. tokio::time::sleep(Duration::from_millis(250)).await;
  310. }
  311. }
  312. /// Poll `/health/ready` until it flips to 503 after SIGTERM.
  313. async fn poll_not_ready(world: &World) -> Result<(), TestError> {
  314. let addr = health_addr(world)?;
  315. let deadline = Instant::now() + Duration::from_secs(20);
  316. loop {
  317. match http_get_plain(addr, "/health/ready").await {
  318. Ok((503, _)) => return Ok(()),
  319. Ok(_) | Err(_) if Instant::now() < deadline => {}
  320. Ok((status, body)) => {
  321. return Err(TestError::Failed(format!(
  322. "ready never flipped: {status} {body}"
  323. )));
  324. }
  325. Err(error) => {
  326. return Err(TestError::Failed(format!(
  327. "ready unreachable post-TERM: {error}"
  328. )));
  329. }
  330. }
  331. tokio::time::sleep(Duration::from_millis(50)).await;
  332. }
  333. }
  334. /// Raw mutual-TLS `GET` through the production listener, returning `(status, body)`.
  335. async fn tls_get(
  336. world: &World,
  337. client: Option<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)>,
  338. path: &str,
  339. ) -> Result<(u16, String), TestError> {
  340. use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
  341. let mut roots = rustls::RootCertStore::empty();
  342. roots.add(certs::cert_der(certs::SERVER_CERT_PEM)?)?;
  343. let builder = rustls::ClientConfig::builder().with_root_certificates(roots);
  344. let config = match client {
  345. Some((chain, key)) => builder.with_client_auth_cert(chain, key)?,
  346. None => builder.with_no_client_auth(),
  347. };
  348. let connector = tokio_rustls::TlsConnector::from(Arc::new(config));
  349. let stream =
  350. tokio::net::TcpStream::connect(SocketAddr::from(([127, 0, 0, 1], world.tls_port))).await?;
  351. let server_name = rustls::pki_types::ServerName::try_from("localhost")?.to_owned();
  352. let mut tls = connector.connect(server_name, stream).await?;
  353. let request = format!("GET {path} HTTP/1.1\r\nhost: localhost\r\nconnection: close\r\n\r\n");
  354. tls.write_all(request.as_bytes()).await?;
  355. let mut raw = Vec::new();
  356. tls.read_to_end(&mut raw).await?;
  357. parse_response(&raw)
  358. }
  359. /// Whether a full mutual-TLS request with the given client identity is
  360. /// rejected: no valid HTTP response is ever served for it.
  361. ///
  362. /// The rejection may surface during the handshake or on the first request
  363. /// (in TLS 1.3 the client can finish its flight before the server's alert
  364. /// arrives), so this drives a complete `GET` and requires "not served".
  365. async fn tls_request_rejected(
  366. world: &World,
  367. cert_pem: &str,
  368. key_pem: &str,
  369. ) -> Result<bool, TestError> {
  370. let attempt = tls_get(
  371. world,
  372. Some((
  373. vec![certs::cert_der(cert_pem)?],
  374. certs::private_key(key_pem)?,
  375. )),
  376. "/health/live",
  377. )
  378. .await;
  379. Ok(!matches!(attempt, Ok((200, _))))
  380. }
  381. /// Register one `tls_client_auth` client row (runtime query: no `.sqlx` cache).
  382. async fn insert_tls_client(pool: &sqlx::PgPool) -> Result<(), TestError> {
  383. sqlx::query(
  384. "INSERT INTO oauth_clients (id, client_id, client_id_issued_at, client_name,
  385. grant_types, response_types, scope, token_endpoint_auth_method,
  386. tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint,
  387. created_at, updated_at)
  388. VALUES ($1, $2, now(), 'Bootstrap Test',
  389. ARRAY['client_credentials'], ARRAY['token'], 'api:read',
  390. 'tls_client_auth', $3, NULL, now(), now())",
  391. )
  392. .bind(uuid::Uuid::new_v4())
  393. .bind(CLIENT_ID)
  394. .bind(PKI_DN)
  395. .execute(pool)
  396. .await?;
  397. Ok(())
  398. }
  399. /// SIGTERM the child and wait for exit 0 (bounded well under the drain budget).
  400. async fn terminate(mut child: tokio::process::Child) -> Result<(), TestError> {
  401. let pid = child
  402. .id()
  403. .ok_or_else(|| TestError::Failed("serve has no pid".to_owned()))?;
  404. let status = tokio::process::Command::new("kill")
  405. .args(["-TERM", &pid.to_string()])
  406. .status()
  407. .await?;
  408. if !status.success() {
  409. return Err(TestError::Failed("kill -TERM failed".to_owned()));
  410. }
  411. let exit = tokio::time::timeout(Duration::from_secs(45), child.wait())
  412. .await
  413. .map_err(|_| TestError::Timeout("serve did not exit after SIGTERM".to_owned()))??;
  414. if exit.success() {
  415. return Ok(());
  416. }
  417. Err(TestError::Failed(format!("serve exited dirty: {exit}")))
  418. }
  419. /// The real `serve` serves mutual-TLS traffic, the registered client
  420. /// completes mTLS against `mtls.rs`, and the rogue cert is rejected.
  421. #[tokio::test]
  422. async fn bootstrap_serve_tls_mtls_registered_completes_unregistered_rejected() -> TestResult {
  423. let world = build_world().await?;
  424. run_once(&world, &["config", "check"]).await?;
  425. run_once(&world, &["migrate"]).await?;
  426. let pool = sqlx::PgPool::connect(&world.pg_url).await?;
  427. insert_tls_client(&pool).await?;
  428. let mut child = spawn_serve(&world)?;
  429. poll_ready(&world, &mut child).await?;
  430. let registered = Some((
  431. vec![certs::cert_der(certs::CLIENT_PKI_CERT_PEM)?],
  432. certs::private_key(certs::CLIENT_PKI_KEY_PEM)?,
  433. ));
  434. let (status, body) = tls_get(&world, registered, "/health/live").await?;
  435. if status != 200 || !body.contains("live") {
  436. return Err(TestError::Failed(format!(
  437. "mTLS /health/live failed: {status} {body}"
  438. )));
  439. }
  440. let (status, _) = tls_get(
  441. &world,
  442. Some((
  443. vec![certs::cert_der(certs::CLIENT_PKI_CERT_PEM)?],
  444. certs::private_key(certs::CLIENT_PKI_KEY_PEM)?,
  445. )),
  446. "/.well-known/oauth-authorization-server",
  447. )
  448. .await?;
  449. if status != 200 {
  450. return Err(TestError::Failed(format!("mTLS metadata failed: {status}")));
  451. }
  452. let leaf = certs::cert_der(certs::CLIENT_PKI_CERT_PEM)?;
  453. let cnf = mtls::authenticate_by_id(&pool, CLIENT_ID, Some(&leaf)).await?;
  454. if cnf.x5t_s256 != PKI_THUMBPRINT {
  455. return Err(TestError::Failed(format!(
  456. "unexpected cnf: {}",
  457. cnf.x5t_s256
  458. )));
  459. }
  460. if !tls_request_rejected(
  461. &world,
  462. certs::CLIENT_SELF_CERT_PEM,
  463. certs::CLIENT_SELF_KEY_PEM,
  464. )
  465. .await?
  466. {
  467. return Err(TestError::Failed(
  468. "unregistered self-signed cert was served".to_owned(),
  469. ));
  470. }
  471. let foreign = certs::cert_der(certs::CLIENT_ROGUE_CERT_PEM)?;
  472. if mtls::authenticate_by_id(&pool, CLIENT_ID, Some(&foreign))
  473. .await
  474. .is_ok()
  475. {
  476. return Err(TestError::Failed(
  477. "unregistered cert authenticated".to_owned(),
  478. ));
  479. }
  480. terminate(child).await
  481. }
  482. /// After SIGTERM, `/health/ready` flips to 503 and the process exits 0.
  483. #[tokio::test]
  484. async fn bootstrap_serve_sigterm_drains_ready_and_exits_zero() -> TestResult {
  485. let world = build_world().await?;
  486. run_once(&world, &["config", "check"]).await?;
  487. run_once(&world, &["migrate"]).await?;
  488. let mut child = spawn_serve(&world)?;
  489. poll_ready(&world, &mut child).await?;
  490. let pid = child
  491. .id()
  492. .ok_or_else(|| TestError::Failed("serve has no pid".to_owned()))?;
  493. let status = tokio::process::Command::new("kill")
  494. .args(["-TERM", &pid.to_string()])
  495. .status()
  496. .await?;
  497. if !status.success() {
  498. return Err(TestError::Failed("kill -TERM failed".to_owned()));
  499. }
  500. poll_not_ready(&world).await?;
  501. let exit = tokio::time::timeout(Duration::from_secs(45), child.wait())
  502. .await
  503. .map_err(|_| TestError::Timeout("serve did not exit after SIGTERM".to_owned()))??;
  504. if !exit.success() {
  505. return Err(TestError::Failed(format!("serve exited dirty: {exit}")));
  506. }
  507. // The listeners are closed: fresh connections are refused.
  508. if http_get_plain(health_addr(&world)?, "/health/live")
  509. .await
  510. .is_ok()
  511. {
  512. return Err(TestError::Failed(
  513. "health listener still open after exit".to_owned(),
  514. ));
  515. }
  516. Ok(())
  517. }