mod.rs 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. // Dead code is expected here: this file is `#[path]`-included by several
  2. // test binaries (`migration_clients`, the e2e harness), each using a
  3. // different subset of these shared helpers.
  4. #![allow(dead_code)]
  5. //! Shared scaffolding for Postgres-backed migration integration tests.
  6. //!
  7. //! Compose-first: tests connect to the shared external services from
  8. //! `compose.test.yaml` (`docker compose -f compose.test.yaml up -d`) instead
  9. //! of booting per-test containers. Per-test isolation comes from a freshly
  10. //! created database per `postgres()` call, so parallel tests never share
  11. //! state (and never FLUSH shared state).
  12. //!
  13. //! The migration SQL is embedded byte-for-byte from the repo so assertions
  14. //! run against the exact shipped file. Test bodies use `Result` and `?`
  15. //! (`unwrap`/`expect`/`panic` are denied workspace-wide).
  16. //!
  17. //! Reachability: this module is included via `#[path]` both as
  18. //! `tests/common/mod.rs` (from `tests/migration_clients.rs`) and as
  19. //! `../common/mod.rs` (from `tests/e2e/harness.rs`), so the helpers below
  20. //! (`pg_server_url`, `redis_url`, `fresh_migrated_db`, `unique_prefixes`)
  21. //! are the single shared definition — no duplication needed.
  22. use sqlx::{Executor as _, PgPool, Row as _, postgres::PgPoolOptions};
  23. /// The `oauth_clients` migration under test (todo 8).
  24. pub const OAUTH_CLIENTS_SQL: &str = include_str!("../../migrations/1791072000_oauth_clients.sql");
  25. /// Version encoded in the `oauth_clients` migration filename.
  26. pub const OAUTH_CLIENTS_VERSION: i64 = 1_791_072_000;
  27. /// Hint shown when the shared test services are unreachable.
  28. const COMPOSE_HINT: &str = "run `docker compose -f compose.test.yaml up -d`";
  29. /// Aggregate error so test bodies use `?` instead of panicking.
  30. #[derive(Debug, thiserror::Error)]
  31. pub enum TestError {
  32. /// The shared test service is unreachable (usually: compose not started).
  33. #[error("test setup: {0}")]
  34. Setup(String),
  35. /// A sqlx operation failed.
  36. #[error("database error: {0}")]
  37. Database(#[from] sqlx::Error),
  38. }
  39. /// URL of the shared test Postgres server (the `postgres` maintenance DB).
  40. ///
  41. /// Overridable via `D_OAUTH_TEST_POSTGRES_URL`; the default matches
  42. /// `compose.test.yaml` (`test-db`, port 5433).
  43. pub fn pg_server_url() -> String {
  44. match std::env::var("D_OAUTH_TEST_POSTGRES_URL") {
  45. Ok(url) => url,
  46. Err(_) => {
  47. "postgres://postgres:oauth-test-only@127.0.0.1:5433/postgres?sslmode=disable".to_owned()
  48. }
  49. }
  50. }
  51. /// URL of the shared test Redis server.
  52. ///
  53. /// Overridable via `D_OAUTH_TEST_REDIS_URL`; the default matches
  54. /// `compose.test.yaml` (`test-redis`, port 6380).
  55. pub fn redis_url() -> String {
  56. match std::env::var("D_OAUTH_TEST_REDIS_URL") {
  57. Ok(url) => url,
  58. Err(_) => "redis://127.0.0.1:6380".to_owned(),
  59. }
  60. }
  61. /// Per-test unique Redis key prefixes (token + rate-limit keyspaces).
  62. ///
  63. /// Each prefix carries its cluster hash tag (`{state}` / `{rl}`) so the
  64. /// server/store validation accepts them, plus a uuid so parallel tests never
  65. /// see each other's keys. Shared state is never flushed.
  66. pub fn unique_prefixes() -> (String, String) {
  67. let tag = uuid::Uuid::new_v4().simple().to_string();
  68. (
  69. format!("oauth:{{state}}:t{tag}:"),
  70. format!("oauth:{{rl}}:r{tag}:"),
  71. )
  72. }
  73. /// Connect a pool on a FRESH uniquely-named database on the shared server.
  74. ///
  75. /// The database is empty (not migrated); callers apply exactly the migration
  76. /// SQL they assert on. A connection failure names the compose file to start.
  77. pub async fn postgres() -> Result<PgPool, TestError> {
  78. fresh_db(&pg_server_url()).await
  79. }
  80. /// A fresh database on the shared server with every embedded migration applied.
  81. pub async fn fresh_migrated_db() -> Result<PgPool, TestError> {
  82. let pool = fresh_db(&pg_server_url()).await?;
  83. d_oauth::state::MIGRATOR
  84. .run(&pool)
  85. .await
  86. .map_err(|error| TestError::Setup(format!("migrations failed to apply: {error}")))?;
  87. Ok(pool)
  88. }
  89. /// Create a uniquely named database on the shared server and pool to it.
  90. async fn fresh_db(server_url: &str) -> Result<PgPool, TestError> {
  91. let admin = PgPoolOptions::new()
  92. .max_connections(1)
  93. .connect(server_url)
  94. .await
  95. .map_err(|error| connect_hint(server_url, &error))?;
  96. let name = format!("mig_{}", uuid::Uuid::new_v4().simple());
  97. // `CREATE DATABASE` cannot take a bind parameter, and `Pool`'s `Executor`
  98. // requires a `'static` query, so leak this one tiny test-only string.
  99. let ddl: &'static str = Box::leak(format!("CREATE DATABASE \"{name}\"").into_boxed_str());
  100. let created = admin
  101. .execute(ddl)
  102. .await
  103. .map_err(|error| TestError::Setup(format!("failed to create test database {name}: {error}")));
  104. admin.close().await;
  105. created?;
  106. let target = server_url.replacen("/postgres?", &format!("/{name}?"), 1);
  107. PgPoolOptions::new()
  108. .max_connections(4)
  109. .connect(&target)
  110. .await
  111. .map_err(|error| connect_hint(&target, &error))
  112. }
  113. /// Helpful error for an unreachable shared Postgres (never a bare driver error).
  114. fn connect_hint(url: &str, error: &sqlx::Error) -> TestError {
  115. TestError::Setup(format!(
  116. "cannot reach shared test Postgres at {url}: {error}; {COMPOSE_HINT}"
  117. ))
  118. }
  119. /// Apply a migration script exactly as the migrator would (multi-statement).
  120. ///
  121. /// `sqlx::raw_sql` requires a `'static` string, so callers pass an embedded
  122. /// `include_str!` constant.
  123. pub async fn migrate(pool: &PgPool, sql: &'static str) -> Result<(), TestError> {
  124. sqlx::raw_sql(sql).execute(pool).await?;
  125. Ok(())
  126. }
  127. /// Insert one `oauth_clients` row, returning the raw driver error (if any).
  128. pub async fn insert_client(
  129. pool: &PgPool,
  130. id: uuid::Uuid,
  131. client_id: &str,
  132. subject_dn: Option<&str>,
  133. thumbprint: Option<&str>,
  134. ) -> Result<(), sqlx::Error> {
  135. sqlx::query(
  136. "INSERT INTO oauth_clients (
  137. id, client_id, client_id_issued_at, client_name,
  138. grant_types, response_types, scope, token_endpoint_auth_method,
  139. tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint,
  140. created_at, updated_at
  141. ) VALUES ($1, $2, now(), $3,
  142. ARRAY['client_credentials'], ARRAY['token'], 'api:read',
  143. 'client_secret_basic', $4, $5, now(), now())",
  144. )
  145. .bind(id)
  146. .bind(client_id)
  147. .bind("Migration Test Client")
  148. .bind(subject_dn)
  149. .bind(thumbprint)
  150. .execute(pool)
  151. .await
  152. .map(|_| ())
  153. }
  154. /// Whether `err` is Postgres `unique_violation` (SQLSTATE 23505).
  155. pub fn is_unique_violation(err: &sqlx::Error) -> bool {
  156. matches!(err, sqlx::Error::Database(db) if db.code().as_deref() == Some("23505"))
  157. }
  158. /// Collect `oauth_clients` columns as `(name, udt_name, is_nullable)`.
  159. pub async fn client_columns(
  160. pool: &PgPool,
  161. ) -> Result<std::collections::BTreeSet<(String, String, String)>, TestError> {
  162. let rows = sqlx::query(
  163. "SELECT column_name, udt_name, is_nullable
  164. FROM information_schema.columns
  165. WHERE table_schema = 'public' AND table_name = 'oauth_clients'",
  166. )
  167. .fetch_all(pool)
  168. .await?;
  169. let mut columns = std::collections::BTreeSet::new();
  170. for row in &rows {
  171. columns.insert((
  172. row.try_get::<String, _>("column_name")?,
  173. row.try_get::<String, _>("udt_name")?,
  174. row.try_get::<String, _>("is_nullable")?,
  175. ));
  176. }
  177. Ok(columns)
  178. }
  179. /// Collect `oauth_clients` indexes as `indexname -> indexdef`.
  180. pub async fn client_indexes(
  181. pool: &PgPool,
  182. ) -> Result<std::collections::BTreeMap<String, String>, TestError> {
  183. let rows = sqlx::query(
  184. "SELECT indexname, indexdef
  185. FROM pg_indexes
  186. WHERE schemaname = 'public' AND tablename = 'oauth_clients'",
  187. )
  188. .fetch_all(pool)
  189. .await?;
  190. let mut defs = std::collections::BTreeMap::new();
  191. for row in &rows {
  192. defs.insert(
  193. row.try_get::<String, _>("indexname")?,
  194. row.try_get::<String, _>("indexdef")?,
  195. );
  196. }
  197. Ok(defs)
  198. }