bootstrap.rs 20 KB

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