migration_users.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. //! `test(migration_users)` — real-Postgres (testcontainers) tests for
  2. //! `migrations/*_users.sql`: the users / scopes / user_scopes / redirect_uris
  3. //! / client_redirect_uris half of the six-table schema.
  4. //!
  5. //! Verifies schema shape and the negative invariants the plan pins:
  6. //! case-insensitive email uniqueness, encrypted (non-plaintext) TOTP storage,
  7. //! the absence of failed-login/lockout columns, safe privilege defaults, the
  8. //! normalized-exact redirect-URI and per-client attachment uniqueness, a clean
  9. //! second migration run, and the checksum guard rejecting a tampered
  10. //! already-applied migration.
  11. //!
  12. //! One Postgres container is shared for the whole binary; each test migrates a
  13. //! freshly created database so the tests stay isolated under parallel execution.
  14. use std::{
  15. collections::BTreeSet,
  16. error::Error,
  17. io,
  18. path::{Path, PathBuf},
  19. };
  20. use sqlx::{
  21. Executor as _, PgPool,
  22. migrate::{MigrateError, Migrator},
  23. postgres::PgPoolOptions,
  24. };
  25. use testcontainers::{
  26. GenericImage, ImageExt,
  27. core::{IntoContainerPort, WaitFor},
  28. runners::AsyncRunner,
  29. };
  30. #[path = "../src/test_guard.rs"]
  31. #[allow(dead_code)]
  32. mod test_guard;
  33. use test_guard::{ContainerGuard, Lease, Shared};
  34. /// Embedded migrations — the exact compile-time set `d_oauth::state::MIGRATOR`
  35. /// serves from (task 8's `oauth_clients` + this task's `users`).
  36. static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
  37. /// Test Postgres superuser password.
  38. const PG_PASSWORD: &str = "migration-users-secret";
  39. /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
  40. type TestError = Box<dyn Error + Send + Sync>;
  41. /// Fallible test body.
  42. type TestResult = Result<(), TestError>;
  43. /// The shared Postgres server plus the guard keeping it alive.
  44. ///
  45. /// The guard removes the container synchronously once the last test in the
  46. /// process releases the shared slot.
  47. struct Db {
  48. _container: ContainerGuard,
  49. url: String,
  50. }
  51. /// The one Postgres server reused by every test in this binary.
  52. static DB: Shared<Db> = Shared::new();
  53. /// Start (once) and return a lease on the shared Postgres server.
  54. ///
  55. /// The lease must stay alive as long as the server is used; dropping the last
  56. /// lease removes the shared container deterministically.
  57. async fn shared_db() -> Result<Lease<Db>, TestError> {
  58. DB.lease::<TestError>(async {
  59. let (container, url) = start_postgres().await?;
  60. Ok(Db {
  61. _container: container,
  62. url,
  63. })
  64. })
  65. .await
  66. }
  67. /// Start a `postgres:latest` container and return it with a plaintext URL.
  68. ///
  69. /// The guard removes the container synchronously when the test drops it.
  70. async fn start_postgres() -> Result<(ContainerGuard, String), TestError> {
  71. let container = GenericImage::new("postgres", "latest")
  72. .with_exposed_port(5432.tcp())
  73. .with_wait_for(WaitFor::message_on_stderr(
  74. "database system is ready to accept connections",
  75. ))
  76. .with_wait_for(WaitFor::message_on_stdout(
  77. "database system is ready to accept connections",
  78. ))
  79. .with_env_var("POSTGRES_PASSWORD", PG_PASSWORD)
  80. .start()
  81. .await?;
  82. let guarded = ContainerGuard::new(container);
  83. let port = guarded.get_host_port_ipv4(5432).await?;
  84. let url = format!("postgres://postgres:{PG_PASSWORD}@127.0.0.1:{port}/postgres?sslmode=disable");
  85. Ok((guarded, url))
  86. }
  87. /// Create a uniquely named database on the shared server and pool to it.
  88. async fn fresh_pool(server_url: &str) -> Result<PgPool, TestError> {
  89. let admin = PgPoolOptions::new()
  90. .max_connections(1)
  91. .connect(server_url)
  92. .await?;
  93. let name = format!("mig_{}", uuid::Uuid::new_v4().simple());
  94. // `CREATE DATABASE` cannot take a bind parameter, and `Pool`'s `Executor`
  95. // requires a `'static` query, so leak this one tiny test-only string.
  96. let ddl: &'static str = Box::leak(format!("CREATE DATABASE \"{name}\"").into_boxed_str());
  97. admin.execute(ddl).await?;
  98. admin.close().await;
  99. let target = server_url.replacen("/postgres?", &format!("/{name}?"), 1);
  100. let pool = PgPoolOptions::new()
  101. .max_connections(4)
  102. .connect(&target)
  103. .await?;
  104. Ok(pool)
  105. }
  106. /// A fresh database with every embedded migration applied.
  107. ///
  108. /// The returned lease keeps the shared server alive; bind it (usually as
  109. /// `_db`) for the duration of the test.
  110. async fn migrated_pool() -> Result<(Lease<Db>, PgPool), TestError> {
  111. let db = shared_db().await?;
  112. let pool = fresh_pool(&db.url).await?;
  113. MIGRATOR.run(&pool).await?;
  114. Ok((db, pool))
  115. }
  116. /// Insert a minimal user (only the NOT-NULL columns without defaults).
  117. async fn insert_user(
  118. pool: &PgPool,
  119. email: &str,
  120. ) -> Result<sqlx::postgres::PgQueryResult, sqlx::Error> {
  121. sqlx::query(
  122. "INSERT INTO users (id, email, password_hash, created_at, updated_at) \
  123. VALUES ($1, $2, $3, now(), now())",
  124. )
  125. .bind(uuid::Uuid::new_v4())
  126. .bind(email)
  127. .bind("$argon2id$v=19$m=19456,t=2,p=1$placeholder")
  128. .execute(pool)
  129. .await
  130. }
  131. /// Insert a minimal client so URI attachments have an FK target.
  132. async fn insert_client(pool: &PgPool, client_id: &str) -> Result<(), sqlx::Error> {
  133. sqlx::query(
  134. "INSERT INTO oauth_clients \
  135. (id, client_id, client_id_issued_at, client_name, grant_types, \
  136. response_types, scope, token_endpoint_auth_method, created_at, updated_at) \
  137. VALUES ($1, $2, now(), $3, $4, $5, $6, $7, now(), now())",
  138. )
  139. .bind(uuid::Uuid::new_v4())
  140. .bind(client_id)
  141. .bind("Migration Users Test Client")
  142. .bind(vec!["authorization_code".to_owned()])
  143. .bind(vec!["code".to_owned()])
  144. .bind("")
  145. .bind("client_secret_basic")
  146. .execute(pool)
  147. .await?;
  148. Ok(())
  149. }
  150. /// Insert a catalog redirect URI.
  151. async fn insert_redirect_uri(
  152. pool: &PgPool,
  153. id: uuid::Uuid,
  154. uri: &str,
  155. ) -> Result<sqlx::postgres::PgQueryResult, sqlx::Error> {
  156. sqlx::query(
  157. "INSERT INTO redirect_uris (id, uri, created_at, updated_at) VALUES ($1, $2, now(), now())",
  158. )
  159. .bind(id)
  160. .bind(uri)
  161. .execute(pool)
  162. .await
  163. }
  164. /// Attach a catalog redirect URI to a client.
  165. async fn attach(
  166. pool: &PgPool,
  167. client_id: &str,
  168. redirect_uri_id: uuid::Uuid,
  169. ) -> Result<sqlx::postgres::PgQueryResult, sqlx::Error> {
  170. sqlx::query(
  171. "INSERT INTO client_redirect_uris (client_id, redirect_uri_id, attached_at) \
  172. VALUES ($1, $2, now())",
  173. )
  174. .bind(client_id)
  175. .bind(redirect_uri_id)
  176. .execute(pool)
  177. .await
  178. }
  179. /// Assert the write failed with a unique-violation (SQLSTATE 23505).
  180. fn assert_unique_violation(
  181. result: Result<sqlx::postgres::PgQueryResult, sqlx::Error>,
  182. ) -> TestResult {
  183. match result {
  184. Err(sqlx::Error::Database(db)) if db.is_unique_violation() => Ok(()),
  185. other => Err(format!("expected a unique violation, got: {other:?}").into()),
  186. }
  187. }
  188. /// Run all migrations on an empty DB: full schema, empty scope catalog, and a
  189. /// second run is a clean no-op.
  190. #[tokio::test]
  191. async fn migration_users_creates_schema_and_second_run_is_noop() -> TestResult {
  192. let (_db, pool) = migrated_pool().await?;
  193. let applied: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _sqlx_migrations")
  194. .fetch_one(&pool)
  195. .await?;
  196. assert_eq!(
  197. usize::try_from(applied)?,
  198. MIGRATOR.iter().count(),
  199. "every embedded migration is applied"
  200. );
  201. // Second run: no error and no newly-applied rows.
  202. MIGRATOR.run(&pool).await?;
  203. let applied_again: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _sqlx_migrations")
  204. .fetch_one(&pool)
  205. .await?;
  206. assert_eq!(applied_again, applied, "second migration run is a no-op");
  207. for table in [
  208. "users",
  209. "scopes",
  210. "user_scopes",
  211. "redirect_uris",
  212. "client_redirect_uris",
  213. "oauth_clients",
  214. ] {
  215. let exists: bool = sqlx::query_scalar(
  216. "SELECT EXISTS (SELECT 1 FROM information_schema.tables \
  217. WHERE table_schema = 'public' AND table_name = $1)",
  218. )
  219. .bind(table)
  220. .fetch_one(&pool)
  221. .await?;
  222. assert!(exists, "table {table} exists");
  223. }
  224. let scope_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM scopes")
  225. .fetch_one(&pool)
  226. .await?;
  227. assert_eq!(
  228. scope_count, 0,
  229. "scope catalog is created empty (no seed rows)"
  230. );
  231. Ok(())
  232. }
  233. /// `users.email` is `citext`, so uniqueness is case-insensitive.
  234. #[tokio::test]
  235. async fn migration_users_email_uniqueness_is_case_insensitive() -> TestResult {
  236. let (_db, pool) = migrated_pool().await?;
  237. insert_user(&pool, "A@x.com").await?;
  238. let duplicate = insert_user(&pool, "a@x.com").await;
  239. assert_unique_violation(duplicate)?;
  240. // A genuinely different address still inserts.
  241. insert_user(&pool, "b@x.com").await?;
  242. Ok(())
  243. }
  244. /// The TOTP secret is an encrypted `bytea` column and no lockout columns exist.
  245. #[tokio::test]
  246. async fn migration_users_totp_is_ciphertext_and_no_lockout_columns() -> TestResult {
  247. let (_db, pool) = migrated_pool().await?;
  248. let columns: Vec<(String, String, String)> = sqlx::query_as(
  249. "SELECT column_name, data_type, udt_name FROM information_schema.columns \
  250. WHERE table_schema = 'public' AND table_name = 'users' ORDER BY column_name",
  251. )
  252. .fetch_all(&pool)
  253. .await?;
  254. let names: BTreeSet<&str> = columns.iter().map(|(name, _, _)| name.as_str()).collect();
  255. let expected: BTreeSet<&str> = [
  256. "created_at",
  257. "disabled",
  258. "email",
  259. "id",
  260. "is_admin",
  261. "must_change_password",
  262. "password_changed_at",
  263. "password_hash",
  264. "totp_enabled",
  265. "totp_recovery_code_hashes",
  266. "totp_secret_enc",
  267. "updated_at",
  268. ]
  269. .into_iter()
  270. .collect();
  271. assert_eq!(
  272. names, expected,
  273. "users has exactly the planned columns (no lockout/failed-login columns)"
  274. );
  275. // The only TOTP-secret column is the encrypted `bytea` one; no plaintext one.
  276. assert!(
  277. !names.contains("totp_secret"),
  278. "no plaintext totp_secret column"
  279. );
  280. let totp_type = columns
  281. .iter()
  282. .find(|(name, _, _)| name == "totp_secret_enc")
  283. .map(|(_, data_type, _)| data_type.as_str())
  284. .ok_or_else(|| io::Error::other("totp_secret_enc column missing"))?;
  285. assert_eq!(
  286. totp_type, "bytea",
  287. "TOTP secret is stored as encrypted bytea"
  288. );
  289. // `email` is the `citext` extension type (proves the extension was created).
  290. let email_type = columns
  291. .iter()
  292. .find(|(name, _, _)| name == "email")
  293. .map(|(_, _, udt_name)| udt_name.as_str())
  294. .ok_or_else(|| io::Error::other("email column missing"))?;
  295. assert_eq!(email_type, "citext", "email is case-insensitive citext");
  296. for name in &names {
  297. assert!(!name.contains("lock"), "no lockout column: {name}");
  298. assert!(!name.contains("failed"), "no failed-login column: {name}");
  299. assert!(
  300. !name.contains("attempt"),
  301. "no failed-attempt column: {name}"
  302. );
  303. }
  304. Ok(())
  305. }
  306. /// `is_admin`, `must_change_password`, `disabled`, and `totp_enabled` default
  307. /// false, so a freshly provisioned user is never accidentally privileged.
  308. #[tokio::test]
  309. async fn migration_users_privilege_defaults_are_false() -> TestResult {
  310. let (_db, pool) = migrated_pool().await?;
  311. insert_user(&pool, "fresh@x.com").await?;
  312. let (is_admin, must_change, disabled, totp_enabled): (bool, bool, bool, bool) = sqlx::query_as(
  313. "SELECT is_admin, must_change_password, disabled, totp_enabled \
  314. FROM users WHERE email = 'fresh@x.com'",
  315. )
  316. .fetch_one(&pool)
  317. .await?;
  318. assert!(!is_admin, "is_admin defaults false");
  319. assert!(!must_change, "must_change_password defaults false");
  320. assert!(!disabled, "disabled defaults false");
  321. assert!(!totp_enabled, "totp_enabled defaults false");
  322. Ok(())
  323. }
  324. /// `redirect_uris.uri` is unique and a client URI attachment is unique.
  325. #[tokio::test]
  326. async fn migration_users_redirect_uri_and_attachment_are_unique() -> TestResult {
  327. let (_db, pool) = migrated_pool().await?;
  328. insert_client(&pool, "client-a").await?;
  329. let uri_id = uuid::Uuid::new_v4();
  330. insert_redirect_uri(&pool, uri_id, "https://app.example/cb").await?;
  331. // The same normalized-exact URI cannot be catalogued twice.
  332. let duplicate_uri =
  333. insert_redirect_uri(&pool, uuid::Uuid::new_v4(), "https://app.example/cb").await;
  334. assert_unique_violation(duplicate_uri)?;
  335. // The same URI cannot be attached to a client twice.
  336. attach(&pool, "client-a", uri_id).await?;
  337. let duplicate_attachment = attach(&pool, "client-a", uri_id).await;
  338. assert_unique_violation(duplicate_attachment)?;
  339. Ok(())
  340. }
  341. /// An already-applied migration whose content changed is rejected by the
  342. /// checksum guard (`MigrateError::VersionMismatch`).
  343. #[tokio::test]
  344. async fn migration_users_rejects_a_tampered_applied_migration() -> TestResult {
  345. let (_db, pool) = migrated_pool().await?;
  346. let tmp = copy_migrations_to_temp()?;
  347. // An untouched copy of the same migrations validates against the applied set.
  348. Migrator::new(tmp.as_path()).await?.run(&pool).await?;
  349. // Tamper an already-applied migration, then re-run: checksum mismatch.
  350. let victim = first_sql_file(&tmp)?;
  351. let mut sql = std::fs::read_to_string(&victim)?;
  352. sql.push_str("\n-- tampered after apply\n");
  353. std::fs::write(&victim, sql)?;
  354. let result = Migrator::new(tmp.as_path()).await?.run(&pool).await;
  355. let _ = std::fs::remove_dir_all(&tmp);
  356. match result {
  357. Err(MigrateError::VersionMismatch(_)) => Ok(()),
  358. other => {
  359. Err(format!("expected VersionMismatch for a tampered migration, got: {other:?}").into())
  360. }
  361. }
  362. }
  363. /// Copy every `*.sql` migration into a fresh temp directory.
  364. fn copy_migrations_to_temp() -> Result<PathBuf, TestError> {
  365. let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
  366. let tmp = std::env::temp_dir().join(format!(
  367. "d-oauth-migration-users-{}",
  368. uuid::Uuid::new_v4().simple()
  369. ));
  370. std::fs::create_dir_all(&tmp)?;
  371. for entry in std::fs::read_dir(&source)? {
  372. let entry = entry?;
  373. let path = entry.path();
  374. if path.extension().and_then(|ext| ext.to_str()) == Some("sql") {
  375. std::fs::copy(&path, tmp.join(entry.file_name()))?;
  376. }
  377. }
  378. Ok(tmp)
  379. }
  380. /// Return the first `*.sql` file in `dir` (deterministic enough: every SQL
  381. /// migration in the directory has been applied by the time this is called).
  382. fn first_sql_file(dir: &Path) -> Result<PathBuf, TestError> {
  383. for entry in std::fs::read_dir(dir)? {
  384. let entry = entry?;
  385. let path = entry.path();
  386. if path.extension().and_then(|ext| ext.to_str()) == Some("sql") {
  387. return Ok(path);
  388. }
  389. }
  390. Err(io::Error::other("no .sql migration files found").into())
  391. }