user_repo.rs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. //! `test(user_repo)` — shared-services Postgres + Redis coverage for
  2. //! `db/{user_repo,scope_repo,user_scope_repo}` and the Redis lockout counters.
  3. //!
  4. //! Postgres backs the user/scope/entitlement assertions; the shared Redis
  5. //! backs the failed-login/lockout assertions. Both come from
  6. //! `compose.test.yaml` (`docker compose -f compose.test.yaml up -d`); each
  7. //! test migrates a freshly created database and uses unique Redis key
  8. //! prefixes, so parallel tests never share state (and never FLUSH shared
  9. //! state). Test bodies use `Result` + `?` (`unwrap`/`expect`/`panic` are
  10. //! denied workspace-wide).
  11. use std::{error::Error, time::Duration};
  12. use d_oauth::{
  13. config::DurabilityMode,
  14. crypto::secret::{Argon2Params, hash_secret},
  15. db::{
  16. scope_repo,
  17. user_repo::{self, LockoutPolicy, NewUser, PasswordVerdict},
  18. user_scope_repo,
  19. },
  20. error::{AppError, OAuthErrorCode},
  21. keys::{KeyId, KeyRing},
  22. state::RedisConn,
  23. store::Store,
  24. };
  25. use secrecy::SecretString;
  26. use sqlx::PgPool;
  27. mod common;
  28. use common::{fresh_migrated_db, redis_url, unique_prefixes};
  29. /// Boxed test error.
  30. type TestError = Box<dyn Error + Send + Sync>;
  31. /// Fallible test body.
  32. type TestResult = Result<(), TestError>;
  33. /// Fast Argon2id parameters keep the hashing-dependent tests quick.
  34. fn fast_params() -> Result<Argon2Params, TestError> {
  35. Ok(Argon2Params::new(8, 1, 1, 32)?)
  36. }
  37. /// Build a `NewUser` with a freshly hashed password.
  38. fn new_user(
  39. email: &str,
  40. password: &str,
  41. is_admin: bool,
  42. must_change_password: bool,
  43. ) -> Result<NewUser, TestError> {
  44. Ok(NewUser {
  45. email: email.to_owned(),
  46. password_hash: hash_secret(password, &fast_params()?)?,
  47. is_admin,
  48. must_change_password,
  49. })
  50. }
  51. /// A single-generation PII test ring (strict encrypt-only mode).
  52. fn test_ring() -> Result<KeyRing, TestError> {
  53. Ok(KeyRing::from_parts(
  54. vec![(
  55. KeyId::new("v1"),
  56. SecretString::from("user-repo-test-pepper-0123456789"),
  57. )],
  58. vec![(
  59. KeyId::new("v1"),
  60. SecretString::from("user-repo-test-totp-0123456789ab"),
  61. )],
  62. vec![(
  63. KeyId::new("v1"),
  64. SecretString::from("user-repo-test-pii-0123456789abcd"),
  65. )],
  66. )?)
  67. }
  68. /// A unique email under the reserved `.test` TLD (parallel-safe).
  69. fn unique_email(prefix: &str) -> String {
  70. format!("{prefix}-{}@example.test", uuid::Uuid::new_v4().simple())
  71. }
  72. /// A pool on a freshly created, fully migrated database on the shared
  73. /// test Postgres (see `tests/common/mod.rs`).
  74. async fn postgres_pool() -> Result<PgPool, TestError> {
  75. Ok(fresh_migrated_db().await?)
  76. }
  77. /// A [`RedisConn`] plus [`Store`] on the shared test Redis with unique key
  78. /// prefixes, so parallel tests never share state.
  79. async fn redis_store() -> Result<(RedisConn, Store), TestError> {
  80. let url = redis_url();
  81. let client = redis::Client::open(url.as_str()).map_err(|error| {
  82. format!("cannot reach shared test Redis at {url}: {error}; run `docker compose -f compose.test.yaml up -d`")
  83. })?;
  84. let manager = redis::aio::ConnectionManager::new(client).await.map_err(|error| {
  85. format!("cannot reach shared test Redis at {url}: {error}; run `docker compose -f compose.test.yaml up -d`")
  86. })?;
  87. let (token_prefix, ratelimit_prefix) = unique_prefixes();
  88. let store = Store::from_parts(token_prefix, ratelimit_prefix, DurabilityMode::WaitAof);
  89. Ok((RedisConn::Single(manager), store))
  90. }
  91. /// Create/find normalizes the email and rejects a case-variant duplicate.
  92. #[tokio::test]
  93. async fn user_repo_create_and_find_is_case_insensitive() -> TestResult {
  94. let pool = postgres_pool().await?;
  95. let keys = test_ring()?;
  96. let created = user_repo::create_user(
  97. &pool,
  98. &keys,
  99. false,
  100. &new_user(" Alice@Example.COM ", "pw-one", false, false)?,
  101. )
  102. .await?;
  103. assert_eq!(created.email, "alice@example.com");
  104. assert!(created.password_changed_at.is_some());
  105. assert!(!created.must_change_password);
  106. let found = user_repo::find_by_email(&pool, &keys, "ALICE@example.com")
  107. .await?
  108. .ok_or("user not found by case-variant email")?;
  109. assert_eq!(found.id, created.id);
  110. let duplicate = user_repo::create_user(
  111. &pool,
  112. &keys,
  113. false,
  114. &new_user("alice@example.com", "pw-two", false, false)?,
  115. )
  116. .await;
  117. assert!(
  118. duplicate.is_err(),
  119. "case-variant duplicate must be rejected"
  120. );
  121. assert!(
  122. user_repo::find_by_email(&pool, &keys, "missing@example.com")
  123. .await?
  124. .is_none()
  125. );
  126. Ok(())
  127. }
  128. /// Password verification matches/mismatches and refuses disabled accounts.
  129. #[tokio::test]
  130. async fn user_repo_verify_password() -> TestResult {
  131. let pool = postgres_pool().await?;
  132. let keys = test_ring()?;
  133. let user = user_repo::create_user(
  134. &pool,
  135. &keys,
  136. false,
  137. &new_user("carol@example.com", "s3cret", false, true)?,
  138. )
  139. .await?;
  140. assert!(user.must_change_password);
  141. assert_eq!(
  142. user_repo::verify_password(&pool, &keys, "CAROL@example.com", "s3cret").await?,
  143. PasswordVerdict::Matched
  144. );
  145. assert_eq!(
  146. user_repo::verify_password(&pool, &keys, "carol@example.com", "wrong").await?,
  147. PasswordVerdict::Mismatched
  148. );
  149. assert_eq!(
  150. user_repo::verify_password(&pool, &keys, "nobody@example.com", "s3cret").await?,
  151. PasswordVerdict::Unknown
  152. );
  153. assert!(user_repo::disable_user(&pool, user.id).await?);
  154. assert_eq!(
  155. user_repo::verify_password(&pool, &keys, "carol@example.com", "s3cret").await?,
  156. PasswordVerdict::Disabled
  157. );
  158. Ok(())
  159. }
  160. /// TOTP secret set/clear/toggle plus one-time recovery-code consumption.
  161. #[tokio::test]
  162. async fn user_repo_totp_and_recovery_codes() -> TestResult {
  163. let pool = postgres_pool().await?;
  164. let keys = test_ring()?;
  165. let user = user_repo::create_user(
  166. &pool,
  167. &keys,
  168. false,
  169. &new_user("dave@example.com", "pw", false, false)?,
  170. )
  171. .await?;
  172. assert!(user_repo::set_totp_secret(&pool, user.id, &[1, 2, 3, 4]).await?);
  173. assert!(user_repo::set_totp_enabled(&pool, user.id, true).await?);
  174. assert!(user_repo::clear_totp_secret(&pool, user.id).await?);
  175. let code = "RECOVERY-CODE-ONE";
  176. let hash = hash_secret(code, &fast_params()?)?;
  177. assert!(user_repo::set_recovery_codes(&pool, user.id, std::slice::from_ref(&hash)).await?);
  178. assert!(user_repo::consume_recovery_code(&pool, user.id, code).await?);
  179. assert!(
  180. !user_repo::consume_recovery_code(&pool, user.id, code).await?,
  181. "a recovery code is single-use"
  182. );
  183. assert!(!user_repo::consume_recovery_code(&pool, user.id, "WRONG").await?);
  184. Ok(())
  185. }
  186. /// Admin counting/promote/demote and password rotation semantics.
  187. #[tokio::test]
  188. async fn user_repo_admin_and_password_rotation() -> TestResult {
  189. let pool = postgres_pool().await?;
  190. let keys = test_ring()?;
  191. assert!(!user_repo::has_any_admin(&pool).await?);
  192. let user = user_repo::create_user(
  193. &pool,
  194. &keys,
  195. false,
  196. &new_user("erin@example.com", "first", false, true)?,
  197. )
  198. .await?;
  199. assert!(!user_repo::has_any_admin(&pool).await?);
  200. assert!(user_repo::set_admin(&pool, user.id, true).await?);
  201. assert!(user_repo::has_any_admin(&pool).await?);
  202. // A disabled admin does not count toward the bootstrap gate.
  203. assert!(user_repo::disable_user(&pool, user.id).await?);
  204. assert!(!user_repo::has_any_admin(&pool).await?);
  205. // set_password clears must_change_password and stamps password_changed_at.
  206. let rotated = hash_secret("second", &fast_params()?)?;
  207. assert!(user_repo::set_password(&pool, user.id, &rotated).await?);
  208. let refreshed = user_repo::find_by_email(&pool, &keys, "erin@example.com")
  209. .await?
  210. .ok_or("user missing after password rotation")?;
  211. assert!(!refreshed.must_change_password);
  212. assert!(refreshed.password_changed_at.is_some());
  213. assert_eq!(
  214. user_repo::verify_password(&pool, &keys, "erin@example.com", "second").await?,
  215. PasswordVerdict::Disabled,
  216. "the account is still disabled until re-enabled"
  217. );
  218. Ok(())
  219. }
  220. /// Scope catalog: upsert idempotency, enable/disable, guarded delete, and
  221. /// entitlement round-trips.
  222. #[tokio::test]
  223. async fn user_repo_scope_and_entitlement_round_trip() -> TestResult {
  224. let pool = postgres_pool().await?;
  225. let keys = test_ring()?;
  226. let created = scope_repo::upsert(&pool, "api:read", "Read API").await?;
  227. assert!(created.enabled);
  228. let updated = scope_repo::upsert(&pool, "api:read", "Read API v2").await?;
  229. assert_eq!(updated.description, "Read API v2");
  230. assert_eq!(scope_repo::list_enabled(&pool).await?.len(), 1);
  231. assert!(scope_repo::set_enabled(&pool, "api:read", false).await?);
  232. assert!(scope_repo::list_enabled(&pool).await?.is_empty());
  233. let user = user_repo::create_user(
  234. &pool,
  235. &keys,
  236. false,
  237. &new_user("frank@example.com", "pw", false, false)?,
  238. )
  239. .await?;
  240. user_scope_repo::entitle(&pool, user.id, "api:read", None).await?;
  241. user_scope_repo::entitle(&pool, user.id, "api:read", None).await?;
  242. assert_eq!(
  243. user_scope_repo::list_for_user(&pool, user.id).await?,
  244. vec!["api:read".to_owned()]
  245. );
  246. assert!(user_scope_repo::is_entitled(&pool, user.id, "api:read").await?);
  247. // A referenced scope cannot be deleted; revoking the entitlement frees it.
  248. assert!(!scope_repo::delete_if_unreferenced(&pool, "api:read").await?);
  249. assert!(user_scope_repo::revoke(&pool, user.id, "api:read").await?);
  250. assert!(!user_scope_repo::is_entitled(&pool, user.id, "api:read").await?);
  251. assert!(scope_repo::delete_if_unreferenced(&pool, "api:read").await?);
  252. assert!(scope_repo::get(&pool, "api:read").await?.is_none());
  253. Ok(())
  254. }
  255. /// Redis lockout: threshold locks, label is case-insensitive, clear-on-success
  256. /// and TTL expiry both unlock; a correct password while locked is still gated.
  257. #[tokio::test]
  258. async fn user_repo_lockout_locks_after_threshold_and_clears() -> TestResult {
  259. let (mut conn, store) = redis_store().await?;
  260. let policy = LockoutPolicy::new(3, 3, Duration::from_millis(600)).ok_or("invalid policy")?;
  261. let label = "Locked@Example.com";
  262. for _ in 0..3 {
  263. user_repo::register_login_failure(&mut conn, &store, policy, label, None).await?;
  264. }
  265. assert!(user_repo::is_locked_out(&mut conn, &store, policy, label, None).await?);
  266. assert_eq!(
  267. user_repo::login_failure_count(&mut conn, &store, "locked@example.com").await?,
  268. 3,
  269. "counter is keyed by the normalized label"
  270. );
  271. assert!(
  272. user_repo::lockout_remaining(&mut conn, &store, label)
  273. .await?
  274. .is_some()
  275. );
  276. // Clear-on-success unlocks immediately.
  277. user_repo::clear_login_failures(&mut conn, &store, label, None).await?;
  278. assert_eq!(
  279. user_repo::login_failure_count(&mut conn, &store, label).await?,
  280. 0
  281. );
  282. assert!(!user_repo::is_locked_out(&mut conn, &store, policy, label, None).await?);
  283. // TTL expiry (unlock policy) also releases the lock.
  284. for _ in 0..3 {
  285. user_repo::register_login_failure(&mut conn, &store, policy, label, None).await?;
  286. }
  287. assert!(user_repo::is_locked_out(&mut conn, &store, policy, label, None).await?);
  288. tokio::time::sleep(Duration::from_millis(800)).await;
  289. assert_eq!(
  290. user_repo::login_failure_count(&mut conn, &store, label).await?,
  291. 0
  292. );
  293. assert!(!user_repo::is_locked_out(&mut conn, &store, policy, label, None).await?);
  294. Ok(())
  295. }
  296. /// A correct password is still rejected by the gate while the account is
  297. /// locked: the repo verifies the secret but `is_locked_out` must deny login.
  298. #[tokio::test]
  299. async fn user_repo_lockout_gate_rejects_correct_password() -> TestResult {
  300. let pool = postgres_pool().await?;
  301. let keys = test_ring()?;
  302. let (mut conn, store) = redis_store().await?;
  303. user_repo::create_user(
  304. &pool,
  305. &keys,
  306. false,
  307. &new_user("gina@example.com", "correct", false, false)?,
  308. )
  309. .await?;
  310. let policy = LockoutPolicy::new(2, 2, Duration::from_secs(60)).ok_or("invalid policy")?;
  311. for _ in 0..2 {
  312. user_repo::register_login_failure(&mut conn, &store, policy, "gina@example.com", None).await?;
  313. }
  314. let locked =
  315. user_repo::is_locked_out(&mut conn, &store, policy, "gina@example.com", None).await?;
  316. let verdict = user_repo::verify_password(&pool, &keys, "gina@example.com", "correct").await?;
  317. assert_eq!(
  318. verdict,
  319. PasswordVerdict::Matched,
  320. "password itself is valid"
  321. );
  322. assert!(locked, "login must refuse despite the valid password");
  323. Ok(())
  324. }
  325. /// Encrypt-only writes store ciphertext + blind index and no plaintext.
  326. #[tokio::test]
  327. async fn user_repo_email_is_ciphertext_at_rest() -> TestResult {
  328. let pool = postgres_pool().await?;
  329. let keys = test_ring()?;
  330. let email = unique_email("cipher");
  331. let created = user_repo::create_user(
  332. &pool,
  333. &keys,
  334. false,
  335. &new_user(&email, "pw-one", false, false)?,
  336. )
  337. .await?;
  338. assert_eq!(created.email, email);
  339. let row: (Option<String>, Option<Vec<u8>>, Option<String>) =
  340. sqlx::query_as("SELECT email::text, email_enc, email_index FROM users WHERE id = $1")
  341. .bind(created.id)
  342. .fetch_one(&pool)
  343. .await?;
  344. assert!(
  345. row.0.is_none(),
  346. "plaintext email must be NULL in encrypt-only mode"
  347. );
  348. let enc = row.1.ok_or("email_enc must be populated")?;
  349. assert!(
  350. !enc
  351. .windows(email.len())
  352. .any(|window| window == email.as_bytes()),
  353. "stored bytes must not contain the plaintext email"
  354. );
  355. row.2.ok_or("email_index must be populated")?;
  356. let found = user_repo::find_by_email(&pool, &keys, &email.to_uppercase())
  357. .await?
  358. .ok_or("user must be found by a case-variant email")?;
  359. assert_eq!(found.id, created.id);
  360. assert_eq!(found.email, email);
  361. Ok(())
  362. }
  363. /// Encrypted lookups stay case-insensitive; unknown emails return `None`.
  364. #[tokio::test]
  365. async fn user_repo_find_by_email_case_insensitive() -> TestResult {
  366. let pool = postgres_pool().await?;
  367. let keys = test_ring()?;
  368. let email = unique_email("mixed");
  369. let created = user_repo::create_user(
  370. &pool,
  371. &keys,
  372. false,
  373. &new_user(&email.to_uppercase(), "pw-one", false, false)?,
  374. )
  375. .await?;
  376. assert_eq!(created.email, email);
  377. for variant in [email.clone(), email.to_uppercase(), format!(" {email} ")] {
  378. let found = user_repo::find_by_email(&pool, &keys, &variant)
  379. .await?
  380. .ok_or("case-variant lookup must find the user")?;
  381. assert_eq!(found.id, created.id);
  382. }
  383. assert!(
  384. user_repo::find_by_email(&pool, &keys, &unique_email("missing"))
  385. .await?
  386. .is_none()
  387. );
  388. Ok(())
  389. }
  390. /// A legacy row (plaintext set, `email_enc`/`email_index` NULL) is found.
  391. #[tokio::test]
  392. async fn user_repo_finds_legacy_plaintext_row() -> TestResult {
  393. let pool = postgres_pool().await?;
  394. let keys = test_ring()?;
  395. let email = unique_email("legacy");
  396. let hash = hash_secret("legacy-pw", &fast_params()?)?;
  397. sqlx::query(
  398. "INSERT INTO users (id, email, password_hash, is_admin, must_change_password,
  399. password_changed_at, created_at, updated_at)
  400. VALUES ($1, $2::text::citext, $3, false, false, now(), now(), now())",
  401. )
  402. .bind(uuid::Uuid::new_v4())
  403. .bind(&email)
  404. .bind(&hash)
  405. .execute(&pool)
  406. .await?;
  407. let found = user_repo::find_by_email(&pool, &keys, &email.to_uppercase())
  408. .await?
  409. .ok_or("legacy plaintext row must be found")?;
  410. assert_eq!(found.email, email);
  411. assert_eq!(
  412. user_repo::verify_password(&pool, &keys, &email, "legacy-pw").await?,
  413. PasswordVerdict::Matched
  414. );
  415. Ok(())
  416. }
  417. /// A duplicate against a legacy NULL-index row is rejected before INSERT.
  418. #[tokio::test]
  419. async fn user_repo_rejects_duplicate_vs_legacy_plaintext() -> TestResult {
  420. let pool = postgres_pool().await?;
  421. let keys = test_ring()?;
  422. let email = unique_email("legacy-dup");
  423. let hash = hash_secret("legacy-pw", &fast_params()?)?;
  424. sqlx::query(
  425. "INSERT INTO users (id, email, password_hash, is_admin, must_change_password,
  426. password_changed_at, created_at, updated_at)
  427. VALUES ($1, $2::text::citext, $3, false, false, now(), now(), now())",
  428. )
  429. .bind(uuid::Uuid::new_v4())
  430. .bind(&email)
  431. .bind(&hash)
  432. .execute(&pool)
  433. .await?;
  434. let duplicate = user_repo::create_user(
  435. &pool,
  436. &keys,
  437. false,
  438. &new_user(&email.to_uppercase(), "pw-two", false, false)?,
  439. )
  440. .await;
  441. assert!(
  442. matches!(duplicate, Err(AppError::DuplicateEmail)),
  443. "a duplicate vs a legacy NULL-index row must be rejected"
  444. );
  445. Ok(())
  446. }
  447. /// A duplicate INSERT maps to the payload-free typed error, never `Internal`.
  448. #[tokio::test]
  449. async fn user_repo_duplicate_maps_to_typed_error() -> TestResult {
  450. let pool = postgres_pool().await?;
  451. let keys = test_ring()?;
  452. let email = unique_email("dup");
  453. user_repo::create_user(
  454. &pool,
  455. &keys,
  456. false,
  457. &new_user(&email, "pw-one", false, false)?,
  458. )
  459. .await?;
  460. let duplicate = user_repo::create_user(
  461. &pool,
  462. &keys,
  463. false,
  464. &new_user(&email, "pw-two", false, false)?,
  465. )
  466. .await;
  467. let error = match duplicate {
  468. Err(error) => error,
  469. Ok(_) => return Err("case-variant duplicate must be rejected".into()),
  470. };
  471. assert!(
  472. matches!(error, AppError::DuplicateEmail),
  473. "duplicate must map to the typed error"
  474. );
  475. assert_eq!(error.code(), OAuthErrorCode::InvalidRequest);
  476. assert_eq!(error.status().as_u16(), 400);
  477. let rendered = format!("{error:?}");
  478. assert!(
  479. !rendered.contains(&email),
  480. "typed error must carry no PII: {rendered}"
  481. );
  482. Ok(())
  483. }
  484. /// A tampered `email_enc` fails closed with 503 and never falls back.
  485. #[tokio::test]
  486. async fn user_repo_corrupt_ciphertext_fails_closed() -> TestResult {
  487. let pool = postgres_pool().await?;
  488. let keys = test_ring()?;
  489. let email = unique_email("corrupt");
  490. let created = user_repo::create_user(
  491. &pool,
  492. &keys,
  493. true,
  494. &new_user(&email, "pw-one", false, false)?,
  495. )
  496. .await?;
  497. let enc: Vec<u8> = sqlx::query_scalar("SELECT email_enc FROM users WHERE id = $1")
  498. .bind(created.id)
  499. .fetch_one(&pool)
  500. .await?;
  501. let mut tampered = enc;
  502. if let Some(last) = tampered.last_mut() {
  503. *last ^= 0x01;
  504. }
  505. sqlx::query("UPDATE users SET email_enc = $1 WHERE id = $2")
  506. .bind(&tampered)
  507. .bind(created.id)
  508. .execute(&pool)
  509. .await?;
  510. let error = match user_repo::find_by_email(&pool, &keys, &email).await {
  511. Err(error) => error,
  512. Ok(_) => return Err("tampered ciphertext must fail closed".into()),
  513. };
  514. assert!(
  515. matches!(error, AppError::PiiDecrypt),
  516. "corrupt ciphertext must map to the distinguishable decrypt error"
  517. );
  518. assert_eq!(error.code(), OAuthErrorCode::TemporarilyUnavailable);
  519. assert_eq!(error.status().as_u16(), 503);
  520. let rendered = format!("{error:?}");
  521. assert!(
  522. !rendered.contains(&email),
  523. "decrypt error must carry no PII: {rendered}"
  524. );
  525. let verify = user_repo::verify_password(&pool, &keys, &email, "pw-one").await;
  526. assert!(
  527. matches!(verify, Err(AppError::PiiDecrypt)),
  528. "password verification must fail closed on a corrupt row"
  529. );
  530. Ok(())
  531. }