bootstrap.rs 19 KB

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