migration_users.rs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  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, Row as _,
  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. /// A table's columns as `(name, udt_name, is_nullable)`.
  186. async fn columns_of(
  187. pool: &PgPool,
  188. table: &str,
  189. ) -> Result<Vec<(String, String, String)>, TestError> {
  190. let rows = sqlx::query(
  191. "SELECT column_name, udt_name, is_nullable FROM information_schema.columns \
  192. WHERE table_schema = 'public' AND table_name = $1",
  193. )
  194. .bind(table)
  195. .fetch_all(pool)
  196. .await?;
  197. let mut columns = Vec::with_capacity(rows.len());
  198. for row in &rows {
  199. columns.push((
  200. row.try_get::<String, _>("column_name")?,
  201. row.try_get::<String, _>("udt_name")?,
  202. row.try_get::<String, _>("is_nullable")?,
  203. ));
  204. }
  205. Ok(columns)
  206. }
  207. /// Assert a table has `column` of the given Postgres `udt_name`.
  208. fn assert_udt(columns: &[(String, String, String)], column: &str, udt: &str) -> TestResult {
  209. let found = columns
  210. .iter()
  211. .find(|(name, _, _)| name == column)
  212. .ok_or_else(|| io::Error::other(format!("column {column} missing")))?;
  213. assert_eq!(found.1, udt, "{column} is {udt}");
  214. Ok(())
  215. }
  216. /// Assert a table has `column` and it accepts NULL.
  217. fn assert_nullable(columns: &[(String, String, String)], column: &str) -> TestResult {
  218. let found = columns
  219. .iter()
  220. .find(|(name, _, _)| name == column)
  221. .ok_or_else(|| io::Error::other(format!("column {column} missing")))?;
  222. assert_eq!(found.2, "YES", "{column} is nullable");
  223. Ok(())
  224. }
  225. /// Assert a unique partial index on `table` is keyed by `column` with an
  226. /// `IS NOT NULL` predicate (so NULL rows stay unconstrained).
  227. async fn assert_partial_unique_index(
  228. pool: &PgPool,
  229. index: &str,
  230. table: &str,
  231. column: &str,
  232. ) -> TestResult {
  233. let def = index_def(pool, table, index).await?;
  234. assert!(def.contains("UNIQUE INDEX"), "{index} is unique: {def}");
  235. assert!(def.contains(column), "{index} keys on {column}: {def}");
  236. assert!(def.contains("IS NOT NULL"), "{index} is partial: {def}");
  237. Ok(())
  238. }
  239. /// Assert an index exists on `table`.
  240. async fn assert_index_exists(pool: &PgPool, table: &str, index: &str) -> TestResult {
  241. index_def(pool, table, index).await.map(|_| ())
  242. }
  243. /// The `pg_indexes.indexdef` for `table`'s `index`, or an error naming it.
  244. async fn index_def(pool: &PgPool, table: &str, index: &str) -> Result<String, TestError> {
  245. sqlx::query_scalar(
  246. "SELECT indexdef FROM pg_indexes \
  247. WHERE schemaname = 'public' AND tablename = $1 AND indexname = $2",
  248. )
  249. .bind(table)
  250. .bind(index)
  251. .fetch_optional(pool)
  252. .await?
  253. .ok_or_else(|| io::Error::other(format!("index {index} missing on {table}")).into())
  254. }
  255. /// Assert a table constraint (unique/check) still exists after the expansion.
  256. async fn assert_constraint_kept(pool: &PgPool, table: &str, constraint: &str) -> TestResult {
  257. let exists: bool = sqlx::query_scalar(
  258. "SELECT EXISTS (SELECT 1 FROM pg_constraint c \
  259. JOIN pg_class t ON t.oid = c.conrelid \
  260. JOIN pg_namespace n ON n.oid = t.relnamespace \
  261. WHERE n.nspname = 'public' AND t.relname = $1 AND c.conname = $2)",
  262. )
  263. .bind(table)
  264. .bind(constraint)
  265. .fetch_one(pool)
  266. .await?;
  267. assert!(exists, "legacy constraint {constraint} is kept");
  268. Ok(())
  269. }
  270. /// Run all migrations on an empty DB: full schema, empty scope catalog, and a
  271. /// second run is a clean no-op.
  272. #[tokio::test]
  273. async fn migration_users_creates_schema_and_second_run_is_noop() -> TestResult {
  274. let (_db, pool) = migrated_pool().await?;
  275. let applied: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _sqlx_migrations")
  276. .fetch_one(&pool)
  277. .await?;
  278. assert_eq!(
  279. usize::try_from(applied)?,
  280. MIGRATOR.iter().count(),
  281. "every embedded migration is applied"
  282. );
  283. // Second run: no error and no newly-applied rows.
  284. MIGRATOR.run(&pool).await?;
  285. let applied_again: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _sqlx_migrations")
  286. .fetch_one(&pool)
  287. .await?;
  288. assert_eq!(applied_again, applied, "second migration run is a no-op");
  289. for table in [
  290. "users",
  291. "scopes",
  292. "user_scopes",
  293. "redirect_uris",
  294. "client_redirect_uris",
  295. "oauth_clients",
  296. ] {
  297. let exists: bool = sqlx::query_scalar(
  298. "SELECT EXISTS (SELECT 1 FROM information_schema.tables \
  299. WHERE table_schema = 'public' AND table_name = $1)",
  300. )
  301. .bind(table)
  302. .fetch_one(&pool)
  303. .await?;
  304. assert!(exists, "table {table} exists");
  305. }
  306. let scope_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM scopes")
  307. .fetch_one(&pool)
  308. .await?;
  309. assert_eq!(
  310. scope_count, 0,
  311. "scope catalog is created empty (no seed rows)"
  312. );
  313. Ok(())
  314. }
  315. /// `users.email` is `citext`, so uniqueness is case-insensitive.
  316. #[tokio::test]
  317. async fn migration_users_email_uniqueness_is_case_insensitive() -> TestResult {
  318. let (_db, pool) = migrated_pool().await?;
  319. insert_user(&pool, "A@x.com").await?;
  320. let duplicate = insert_user(&pool, "a@x.com").await;
  321. assert_unique_violation(duplicate)?;
  322. // A genuinely different address still inserts.
  323. insert_user(&pool, "b@x.com").await?;
  324. Ok(())
  325. }
  326. /// The TOTP secret is an encrypted `bytea` column and no lockout columns exist.
  327. #[tokio::test]
  328. async fn migration_users_totp_is_ciphertext_and_no_lockout_columns() -> TestResult {
  329. let (_db, pool) = migrated_pool().await?;
  330. let columns: Vec<(String, String, String)> = sqlx::query_as(
  331. "SELECT column_name, data_type, udt_name FROM information_schema.columns \
  332. WHERE table_schema = 'public' AND table_name = 'users' ORDER BY column_name",
  333. )
  334. .fetch_all(&pool)
  335. .await?;
  336. let names: BTreeSet<&str> = columns.iter().map(|(name, _, _)| name.as_str()).collect();
  337. let expected: BTreeSet<&str> = [
  338. "created_at",
  339. "disabled",
  340. "email",
  341. "email_enc",
  342. "email_index",
  343. "id",
  344. "is_admin",
  345. "must_change_password",
  346. "password_changed_at",
  347. "password_hash",
  348. "totp_enabled",
  349. "totp_recovery_code_hashes",
  350. "totp_secret_enc",
  351. "updated_at",
  352. ]
  353. .into_iter()
  354. .collect();
  355. assert_eq!(
  356. names, expected,
  357. "users has exactly the planned columns (no lockout/failed-login columns)"
  358. );
  359. // The only TOTP-secret column is the encrypted `bytea` one; no plaintext one.
  360. assert!(
  361. !names.contains("totp_secret"),
  362. "no plaintext totp_secret column"
  363. );
  364. let totp_type = columns
  365. .iter()
  366. .find(|(name, _, _)| name == "totp_secret_enc")
  367. .map(|(_, data_type, _)| data_type.as_str())
  368. .ok_or_else(|| io::Error::other("totp_secret_enc column missing"))?;
  369. assert_eq!(
  370. totp_type, "bytea",
  371. "TOTP secret is stored as encrypted bytea"
  372. );
  373. // `email` is the `citext` extension type (proves the extension was created).
  374. let email_type = columns
  375. .iter()
  376. .find(|(name, _, _)| name == "email")
  377. .map(|(_, _, udt_name)| udt_name.as_str())
  378. .ok_or_else(|| io::Error::other("email column missing"))?;
  379. assert_eq!(email_type, "citext", "email is case-insensitive citext");
  380. // The blind index is unique and partial: NULL email_index rows are unconstrained.
  381. assert_partial_unique_index(&pool, "ux_users_email_index", "users", "email_index").await?;
  382. for name in &names {
  383. assert!(!name.contains("lock"), "no lockout column: {name}");
  384. assert!(!name.contains("failed"), "no failed-login column: {name}");
  385. assert!(
  386. !name.contains("attempt"),
  387. "no failed-attempt column: {name}"
  388. );
  389. }
  390. Ok(())
  391. }
  392. /// Todo 8 expansion: the encrypted PII columns and blind indexes exist, the
  393. /// plaintext columns the backfill blanks accept NULL, and the legacy unique
  394. /// constraints survive (inert on NULL), so an encrypt-only row inserts.
  395. #[tokio::test]
  396. async fn expand_migration_adds_pii_columns() -> TestResult {
  397. let (_db, pool) = migrated_pool().await?;
  398. // The expansion version is compiled into the embedded set readiness reads.
  399. assert!(
  400. MIGRATOR.iter().any(|m| m.version == 1_791_417_600),
  401. "the PII expansion migration is embedded in MIGRATOR"
  402. );
  403. // users: email stays citext but becomes nullable; ciphertext + blind index add.
  404. let users = columns_of(&pool, "users").await?;
  405. assert_nullable(&users, "email")?;
  406. assert_udt(&users, "email_enc", "bytea")?;
  407. assert_udt(&users, "email_index", "text")?;
  408. assert_partial_unique_index(&pool, "ux_users_email_index", "users", "email_index").await?;
  409. assert_constraint_kept(&pool, "users", "ux_users_email").await?;
  410. // oauth_clients: every PII metadata column gains a ciphertext twin, the two
  411. // mTLS identities gain blind indexes, and client_name becomes nullable.
  412. let clients = columns_of(&pool, "oauth_clients").await?;
  413. assert_nullable(&clients, "client_name")?;
  414. for column in [
  415. "client_name_enc",
  416. "contacts_enc",
  417. "request_uris_enc",
  418. "jwks_uri_enc",
  419. "software_id_enc",
  420. "tls_client_auth_subject_dn_enc",
  421. "tls_client_auth_cert_thumbprint_enc",
  422. ] {
  423. assert_udt(&clients, column, "bytea")?;
  424. }
  425. assert_udt(&clients, "tls_client_auth_subject_dn_index", "text")?;
  426. assert_udt(&clients, "tls_client_auth_cert_thumbprint_index", "text")?;
  427. assert_partial_unique_index(
  428. &pool,
  429. "ux_oauth_clients_tls_subject_dn_index",
  430. "oauth_clients",
  431. "tls_client_auth_subject_dn_index",
  432. )
  433. .await?;
  434. assert_partial_unique_index(
  435. &pool,
  436. "ux_oauth_clients_tls_cert_thumbprint_index",
  437. "oauth_clients",
  438. "tls_client_auth_cert_thumbprint_index",
  439. )
  440. .await?;
  441. assert_index_exists(&pool, "oauth_clients", "ux_oauth_clients_tls_subject_dn").await?;
  442. assert_index_exists(
  443. &pool,
  444. "oauth_clients",
  445. "ux_oauth_clients_tls_cert_thumbprint",
  446. )
  447. .await?;
  448. // redirect_uris: uri becomes nullable; ciphertext + per-kind blind index add.
  449. let uris = columns_of(&pool, "redirect_uris").await?;
  450. assert_nullable(&uris, "uri")?;
  451. assert_udt(&uris, "uri_enc", "bytea")?;
  452. assert_udt(&uris, "uri_index", "text")?;
  453. assert_partial_unique_index(
  454. &pool,
  455. "ux_redirect_uris_uri_index_kind",
  456. "redirect_uris",
  457. "uri_index",
  458. )
  459. .await?;
  460. assert_constraint_kept(&pool, "redirect_uris", "ux_redirect_uris_uri_kind").await?;
  461. // Encrypt-only rows (plaintext NULL, ciphertext populated) insert cleanly.
  462. sqlx::query(
  463. "INSERT INTO users (id, email, email_enc, email_index, password_hash, created_at, updated_at) \
  464. VALUES ($1, NULL, $2, $3, $4, now(), now())",
  465. )
  466. .bind(uuid::Uuid::new_v4())
  467. .bind(b"sealed-email".to_vec())
  468. .bind("blind-email-index")
  469. .bind("$argon2id$v=19$m=19456,t=2,p=1$placeholder")
  470. .execute(&pool)
  471. .await?;
  472. sqlx::query(
  473. "INSERT INTO redirect_uris (id, uri, uri_enc, uri_index, created_at, updated_at) \
  474. VALUES ($1, NULL, $2, $3, now(), now())",
  475. )
  476. .bind(uuid::Uuid::new_v4())
  477. .bind(b"sealed-uri".to_vec())
  478. .bind("blind-uri-index")
  479. .execute(&pool)
  480. .await?;
  481. sqlx::query(
  482. "INSERT INTO oauth_clients \
  483. (id, client_id, client_id_issued_at, client_name, client_name_enc, \
  484. grant_types, response_types, scope, token_endpoint_auth_method, \
  485. created_at, updated_at) \
  486. VALUES ($1, $2, now(), NULL, $3, $4, $5, $6, $7, now(), now())",
  487. )
  488. .bind(uuid::Uuid::new_v4())
  489. .bind("pii-expansion-client")
  490. .bind(b"sealed-name".to_vec())
  491. .bind(vec!["client_credentials".to_owned()])
  492. .bind(vec!["token".to_owned()])
  493. .bind("")
  494. .bind("client_secret_basic")
  495. .execute(&pool)
  496. .await?;
  497. // Re-running the migrator applies nothing new (a no-op).
  498. let before: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _sqlx_migrations")
  499. .fetch_one(&pool)
  500. .await?;
  501. MIGRATOR.run(&pool).await?;
  502. let after: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _sqlx_migrations")
  503. .fetch_one(&pool)
  504. .await?;
  505. assert_eq!(before, after, "re-running the migrator is a no-op");
  506. Ok(())
  507. }
  508. /// `is_admin`, `must_change_password`, `disabled`, and `totp_enabled` default
  509. /// false, so a freshly provisioned user is never accidentally privileged.
  510. #[tokio::test]
  511. async fn migration_users_privilege_defaults_are_false() -> TestResult {
  512. let (_db, pool) = migrated_pool().await?;
  513. insert_user(&pool, "fresh@x.com").await?;
  514. let (is_admin, must_change, disabled, totp_enabled): (bool, bool, bool, bool) = sqlx::query_as(
  515. "SELECT is_admin, must_change_password, disabled, totp_enabled \
  516. FROM users WHERE email = 'fresh@x.com'",
  517. )
  518. .fetch_one(&pool)
  519. .await?;
  520. assert!(!is_admin, "is_admin defaults false");
  521. assert!(!must_change, "must_change_password defaults false");
  522. assert!(!disabled, "disabled defaults false");
  523. assert!(!totp_enabled, "totp_enabled defaults false");
  524. Ok(())
  525. }
  526. /// `redirect_uris.uri` is unique and a client URI attachment is unique.
  527. #[tokio::test]
  528. async fn migration_users_redirect_uri_and_attachment_are_unique() -> TestResult {
  529. let (_db, pool) = migrated_pool().await?;
  530. insert_client(&pool, "client-a").await?;
  531. let uri_id = uuid::Uuid::new_v4();
  532. insert_redirect_uri(&pool, uri_id, "https://app.example/cb").await?;
  533. // The same normalized-exact URI cannot be catalogued twice.
  534. let duplicate_uri =
  535. insert_redirect_uri(&pool, uuid::Uuid::new_v4(), "https://app.example/cb").await;
  536. assert_unique_violation(duplicate_uri)?;
  537. // The same URI cannot be attached to a client twice.
  538. attach(&pool, "client-a", uri_id).await?;
  539. let duplicate_attachment = attach(&pool, "client-a", uri_id).await;
  540. assert_unique_violation(duplicate_attachment)?;
  541. Ok(())
  542. }
  543. /// An already-applied migration whose content changed is rejected by the
  544. /// checksum guard (`MigrateError::VersionMismatch`).
  545. #[tokio::test]
  546. async fn migration_users_rejects_a_tampered_applied_migration() -> TestResult {
  547. let (_db, pool) = migrated_pool().await?;
  548. let tmp = copy_migrations_to_temp()?;
  549. // An untouched copy of the same migrations validates against the applied set.
  550. Migrator::new(tmp.as_path()).await?.run(&pool).await?;
  551. // Tamper an already-applied migration, then re-run: checksum mismatch.
  552. let victim = first_sql_file(&tmp)?;
  553. let mut sql = std::fs::read_to_string(&victim)?;
  554. sql.push_str("\n-- tampered after apply\n");
  555. std::fs::write(&victim, sql)?;
  556. let result = Migrator::new(tmp.as_path()).await?.run(&pool).await;
  557. let _ = std::fs::remove_dir_all(&tmp);
  558. match result {
  559. Err(MigrateError::VersionMismatch(_)) => Ok(()),
  560. other => {
  561. Err(format!("expected VersionMismatch for a tampered migration, got: {other:?}").into())
  562. }
  563. }
  564. }
  565. /// Copy every `*.sql` migration into a fresh temp directory.
  566. fn copy_migrations_to_temp() -> Result<PathBuf, TestError> {
  567. let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
  568. let tmp = std::env::temp_dir().join(format!(
  569. "d-oauth-migration-users-{}",
  570. uuid::Uuid::new_v4().simple()
  571. ));
  572. std::fs::create_dir_all(&tmp)?;
  573. for entry in std::fs::read_dir(&source)? {
  574. let entry = entry?;
  575. let path = entry.path();
  576. if path.extension().and_then(|ext| ext.to_str()) == Some("sql") {
  577. std::fs::copy(&path, tmp.join(entry.file_name()))?;
  578. }
  579. }
  580. Ok(tmp)
  581. }
  582. /// Return the first `*.sql` file in `dir` (deterministic enough: every SQL
  583. /// migration in the directory has been applied by the time this is called).
  584. fn first_sql_file(dir: &Path) -> Result<PathBuf, TestError> {
  585. for entry in std::fs::read_dir(dir)? {
  586. let entry = entry?;
  587. let path = entry.path();
  588. if path.extension().and_then(|ext| ext.to_str()) == Some("sql") {
  589. return Ok(path);
  590. }
  591. }
  592. Err(io::Error::other("no .sql migration files found").into())
  593. }