migration_users.rs 14 KB

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