test_support.rs 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. //! Shared testcontainers scaffolding for the `test(health)` suite.
  2. //!
  3. //! Postgres and Redis run from locally cached images (`postgres:16-alpine`,
  4. //! `redis:7.2-alpine`); no pulls, no `redis-server` binary needed.
  5. //! Sentinel and Cluster topologies talk over container bridge IPs, which are
  6. //! directly routable from the Linux host, so Sentinel-advertised and
  7. //! cluster-gossiped addresses stay valid without port-mapping tricks.
  8. use std::time::Duration;
  9. use serde_json::Value;
  10. use testcontainers::{
  11. GenericImage, ImageExt,
  12. core::{IntoContainerPort, WaitFor},
  13. runners::AsyncRunner,
  14. };
  15. use super::{AppState, MIGRATOR};
  16. use crate::{
  17. config::{Config, ConfigError},
  18. test_guard::ContainerGuard,
  19. };
  20. /// Test Postgres superuser password.
  21. const PG_PASSWORD: &str = "health-test-secret";
  22. /// Test body result (no `unwrap`/`expect` under the deny lints).
  23. pub type TestResult = Result<(), TestError>;
  24. /// Start Postgres and return the container plus a plaintext URL with an
  25. /// explicit `sslmode=disable` (honored verbatim by the pool builder).
  26. ///
  27. /// The guard removes the container synchronously when the test drops it.
  28. pub async fn postgres_container() -> Result<(ContainerGuard, String), TestError> {
  29. let container = GenericImage::new("postgres", "16-alpine")
  30. .with_exposed_port(5432.tcp())
  31. .with_wait_for(WaitFor::message_on_stderr(
  32. "database system is ready to accept connections",
  33. ))
  34. .with_wait_for(WaitFor::message_on_stdout(
  35. "database system is ready to accept connections",
  36. ))
  37. .with_env_var("POSTGRES_PASSWORD", PG_PASSWORD)
  38. .start()
  39. .await?;
  40. let guarded = ContainerGuard::new(container);
  41. let port = guarded.get_host_port_ipv4(5432).await?;
  42. let url = format!("postgres://postgres:{PG_PASSWORD}@127.0.0.1:{port}/postgres?sslmode=disable");
  43. Ok((guarded, url))
  44. }
  45. /// Start a single-node Redis with AOF on; `requirepass` enables AUTH.
  46. ///
  47. /// The guard removes the container synchronously when the test drops it.
  48. pub async fn redis_single_container(
  49. requirepass: Option<&str>,
  50. ) -> Result<(ContainerGuard, String), TestError> {
  51. let args: Vec<&str> = match requirepass {
  52. Some(secret) => vec![
  53. "redis-server",
  54. "--appendonly",
  55. "yes",
  56. "--requirepass",
  57. secret,
  58. ],
  59. None => vec!["redis-server", "--appendonly", "yes"],
  60. };
  61. let container = GenericImage::new("redis", "7.2-alpine")
  62. .with_exposed_port(6379.tcp())
  63. .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
  64. .with_cmd(args)
  65. .start()
  66. .await?;
  67. let guarded = ContainerGuard::new(container);
  68. let port = guarded.get_host_port_ipv4(6379).await?;
  69. Ok((guarded, format!("redis://127.0.0.1:{port}")))
  70. }
  71. /// Build a full [`Config`] over the given Postgres URL and Redis block.
  72. pub fn test_config(pg_url: &str, redis: Value) -> Result<Config, ConfigError> {
  73. let fixture = serde_json::json!({
  74. "issuer": "https://auth.example.test",
  75. "environment": "development",
  76. "tls": {"cert_path": "/tmp/health-test-cert.pem", "key_path": "/tmp/health-test-key.pem"},
  77. "database": {"url": pg_url},
  78. "redis": redis,
  79. "token_pepper": "health-test-pepper-0123456789abcdef",
  80. "totp_encryption_key": "health-test-totp-key-0123456789abcdef",
  81. });
  82. let source = ::config::Config::try_from(&fixture)?;
  83. Config::from_source(source)
  84. }
  85. /// Default single-node Redis block for tests (AOF is on in these containers).
  86. pub fn single_redis_block(url: &str) -> Value {
  87. serde_json::json!({"url": url, "durability": "waitaof"})
  88. }
  89. /// Simulate the `migrate` subcommand having run: applies [`MIGRATOR`]
  90. /// (currently zero migrations, but still creates `_sqlx_migrations`, which
  91. /// is exactly what readiness checks for).
  92. pub async fn mark_migrated(state: &AppState) -> Result<(), TestError> {
  93. MIGRATOR.run(&state.pool).await?;
  94. Ok(())
  95. }
  96. /// Drive `GET <path>` at the health-only router without binding a socket.
  97. pub async fn get_health(state: &AppState, path: &str) -> Result<(u16, Value), TestError> {
  98. use tower::ServiceExt as _;
  99. let router = super::health_router(state.clone());
  100. let request = axum::http::Request::builder()
  101. .uri(path)
  102. .body(axum::body::Body::empty())?;
  103. let response = router.oneshot(request).await?;
  104. let status = response.status().as_u16();
  105. let bytes = http_body_util::BodyExt::collect(response.into_body())
  106. .await
  107. .map_err(|_| TestError::Body("body collection failed".to_owned()))?
  108. .to_bytes();
  109. // Plaintext-listener 404s carry no body; normalize empties to null.
  110. let body = if bytes.is_empty() {
  111. Value::Null
  112. } else {
  113. serde_json::from_slice::<Value>(&bytes)?
  114. };
  115. Ok((status, body))
  116. }
  117. /// Freeze (`pause`) or resume (`unpause`) a container via the Docker CLI.
  118. pub async fn docker_pause(id: &str, pause: bool) -> Result<(), TestError> {
  119. let action = if pause { "pause" } else { "unpause" };
  120. let output = tokio::process::Command::new("docker")
  121. .args([action, id])
  122. .output()
  123. .await
  124. .map_err(|_| TestError::Docker(action.to_owned()))?;
  125. if !output.status.success() {
  126. return Err(TestError::Docker(action.to_owned()));
  127. }
  128. // Give the kernel a beat to stop scheduling the container's processes.
  129. tokio::time::sleep(Duration::from_millis(500)).await;
  130. Ok(())
  131. }
  132. /// Errors raised by the health test scaffolding itself.
  133. #[derive(Debug, thiserror::Error)]
  134. pub enum TestError {
  135. /// A testcontainers operation failed.
  136. #[error("container error: {0}")]
  137. Containers(#[from] testcontainers::TestcontainersError),
  138. /// The crate configuration failed to load.
  139. #[error("config error: {0}")]
  140. Config(#[from] crate::config::ConfigError),
  141. /// Application state failed to connect.
  142. #[error("state error: {0}")]
  143. State(#[from] super::StateError),
  144. /// The layered config source or validation failed.
  145. #[error("config source error: {0}")]
  146. Source(#[from] ::config::ConfigError),
  147. /// A `docker pause/unpause` call failed.
  148. #[error("docker {0} failed")]
  149. Docker(String),
  150. /// An HTTP builder error while driving the router.
  151. #[error("http error: {0}")]
  152. Http(#[from] axum::http::Error),
  153. /// The in-process router returned its (uninhabited) error type.
  154. #[error("router error")]
  155. Router(#[from] std::convert::Infallible),
  156. /// A body/JSON error while reading a probe response.
  157. #[error("body error: {0}")]
  158. Body(String),
  159. /// A JSON error while reading a probe response.
  160. #[error("json error: {0}")]
  161. Json(#[from] serde_json::Error),
  162. /// A sqlx failure inside the test body.
  163. #[error("database error: {0}")]
  164. Database(#[from] sqlx::Error),
  165. /// A migration failure inside the test body.
  166. #[error("migration error: {0}")]
  167. Migrate(#[from] sqlx::migrate::MigrateError),
  168. /// A Redis failure inside the test body.
  169. #[error("redis error: {0}")]
  170. Redis(#[from] ::redis::RedisError),
  171. }