user_repo.rs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  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. state::RedisConn,
  21. store::Store,
  22. };
  23. use sqlx::PgPool;
  24. mod common;
  25. use common::{fresh_migrated_db, redis_url, unique_prefixes};
  26. /// Boxed test error.
  27. type TestError = Box<dyn Error + Send + Sync>;
  28. /// Fallible test body.
  29. type TestResult = Result<(), TestError>;
  30. /// Fast Argon2id parameters keep the hashing-dependent tests quick.
  31. fn fast_params() -> Result<Argon2Params, TestError> {
  32. Ok(Argon2Params::new(8, 1, 1, 32)?)
  33. }
  34. /// Build a `NewUser` with a freshly hashed password.
  35. fn new_user(
  36. email: &str,
  37. password: &str,
  38. is_admin: bool,
  39. must_change_password: bool,
  40. ) -> Result<NewUser, TestError> {
  41. Ok(NewUser {
  42. email: email.to_owned(),
  43. password_hash: hash_secret(password, &fast_params()?)?,
  44. is_admin,
  45. must_change_password,
  46. })
  47. }
  48. /// A pool on a freshly created, fully migrated database on the shared
  49. /// test Postgres (see `tests/common/mod.rs`).
  50. async fn postgres_pool() -> Result<PgPool, TestError> {
  51. Ok(fresh_migrated_db().await?)
  52. }
  53. /// A [`RedisConn`] plus [`Store`] on the shared test Redis with unique key
  54. /// prefixes, so parallel tests never share state.
  55. async fn redis_store() -> Result<(RedisConn, Store), TestError> {
  56. let url = redis_url();
  57. let client = redis::Client::open(url.as_str()).map_err(|error| {
  58. format!("cannot reach shared test Redis at {url}: {error}; run `docker compose -f compose.test.yaml up -d`")
  59. })?;
  60. let manager = redis::aio::ConnectionManager::new(client).await.map_err(|error| {
  61. format!("cannot reach shared test Redis at {url}: {error}; run `docker compose -f compose.test.yaml up -d`")
  62. })?;
  63. let (token_prefix, ratelimit_prefix) = unique_prefixes();
  64. let store = Store::from_parts(token_prefix, ratelimit_prefix, DurabilityMode::WaitAof);
  65. Ok((RedisConn::Single(manager), store))
  66. }
  67. /// Create/find normalizes the email and rejects a case-variant duplicate.
  68. #[tokio::test]
  69. async fn user_repo_create_and_find_is_case_insensitive() -> TestResult {
  70. let pool = postgres_pool().await?;
  71. let created = user_repo::create_user(
  72. &pool,
  73. &new_user(" Alice@Example.COM ", "pw-one", false, false)?,
  74. )
  75. .await?;
  76. assert_eq!(created.email, "alice@example.com");
  77. assert!(created.password_changed_at.is_some());
  78. assert!(!created.must_change_password);
  79. let found = user_repo::find_by_email(&pool, "ALICE@example.com")
  80. .await?
  81. .ok_or("user not found by case-variant email")?;
  82. assert_eq!(found.id, created.id);
  83. let duplicate = user_repo::create_user(
  84. &pool,
  85. &new_user("alice@example.com", "pw-two", false, false)?,
  86. )
  87. .await;
  88. assert!(
  89. duplicate.is_err(),
  90. "case-variant duplicate must be rejected"
  91. );
  92. assert!(
  93. user_repo::find_by_email(&pool, "missing@example.com")
  94. .await?
  95. .is_none()
  96. );
  97. Ok(())
  98. }
  99. /// Password verification matches/mismatches and refuses disabled accounts.
  100. #[tokio::test]
  101. async fn user_repo_verify_password() -> TestResult {
  102. let pool = postgres_pool().await?;
  103. let user = user_repo::create_user(
  104. &pool,
  105. &new_user("carol@example.com", "s3cret", false, true)?,
  106. )
  107. .await?;
  108. assert!(user.must_change_password);
  109. assert_eq!(
  110. user_repo::verify_password(&pool, "CAROL@example.com", "s3cret").await?,
  111. PasswordVerdict::Matched
  112. );
  113. assert_eq!(
  114. user_repo::verify_password(&pool, "carol@example.com", "wrong").await?,
  115. PasswordVerdict::Mismatched
  116. );
  117. assert_eq!(
  118. user_repo::verify_password(&pool, "nobody@example.com", "s3cret").await?,
  119. PasswordVerdict::Unknown
  120. );
  121. assert!(user_repo::disable_user(&pool, user.id).await?);
  122. assert_eq!(
  123. user_repo::verify_password(&pool, "carol@example.com", "s3cret").await?,
  124. PasswordVerdict::Disabled
  125. );
  126. Ok(())
  127. }
  128. /// TOTP secret set/clear/toggle plus one-time recovery-code consumption.
  129. #[tokio::test]
  130. async fn user_repo_totp_and_recovery_codes() -> TestResult {
  131. let pool = postgres_pool().await?;
  132. let user =
  133. user_repo::create_user(&pool, &new_user("dave@example.com", "pw", false, false)?).await?;
  134. assert!(user_repo::set_totp_secret(&pool, user.id, &[1, 2, 3, 4]).await?);
  135. assert!(user_repo::set_totp_enabled(&pool, user.id, true).await?);
  136. assert!(user_repo::clear_totp_secret(&pool, user.id).await?);
  137. let code = "RECOVERY-CODE-ONE";
  138. let hash = hash_secret(code, &fast_params()?)?;
  139. assert!(user_repo::set_recovery_codes(&pool, user.id, std::slice::from_ref(&hash)).await?);
  140. assert!(user_repo::consume_recovery_code(&pool, user.id, code).await?);
  141. assert!(
  142. !user_repo::consume_recovery_code(&pool, user.id, code).await?,
  143. "a recovery code is single-use"
  144. );
  145. assert!(!user_repo::consume_recovery_code(&pool, user.id, "WRONG").await?);
  146. Ok(())
  147. }
  148. /// Admin counting/promote/demote and password rotation semantics.
  149. #[tokio::test]
  150. async fn user_repo_admin_and_password_rotation() -> TestResult {
  151. let pool = postgres_pool().await?;
  152. assert!(!user_repo::has_any_admin(&pool).await?);
  153. let user =
  154. user_repo::create_user(&pool, &new_user("erin@example.com", "first", false, true)?).await?;
  155. assert!(!user_repo::has_any_admin(&pool).await?);
  156. assert!(user_repo::set_admin(&pool, user.id, true).await?);
  157. assert!(user_repo::has_any_admin(&pool).await?);
  158. // A disabled admin does not count toward the bootstrap gate.
  159. assert!(user_repo::disable_user(&pool, user.id).await?);
  160. assert!(!user_repo::has_any_admin(&pool).await?);
  161. // set_password clears must_change_password and stamps password_changed_at.
  162. let rotated = hash_secret("second", &fast_params()?)?;
  163. assert!(user_repo::set_password(&pool, user.id, &rotated).await?);
  164. let refreshed = user_repo::find_by_email(&pool, "erin@example.com")
  165. .await?
  166. .ok_or("user missing after password rotation")?;
  167. assert!(!refreshed.must_change_password);
  168. assert!(refreshed.password_changed_at.is_some());
  169. assert_eq!(
  170. user_repo::verify_password(&pool, "erin@example.com", "second").await?,
  171. PasswordVerdict::Disabled,
  172. "the account is still disabled until re-enabled"
  173. );
  174. Ok(())
  175. }
  176. /// Scope catalog: upsert idempotency, enable/disable, guarded delete, and
  177. /// entitlement round-trips.
  178. #[tokio::test]
  179. async fn user_repo_scope_and_entitlement_round_trip() -> TestResult {
  180. let pool = postgres_pool().await?;
  181. let created = scope_repo::upsert(&pool, "api:read", "Read API").await?;
  182. assert!(created.enabled);
  183. let updated = scope_repo::upsert(&pool, "api:read", "Read API v2").await?;
  184. assert_eq!(updated.description, "Read API v2");
  185. assert_eq!(scope_repo::list_enabled(&pool).await?.len(), 1);
  186. assert!(scope_repo::set_enabled(&pool, "api:read", false).await?);
  187. assert!(scope_repo::list_enabled(&pool).await?.is_empty());
  188. let user =
  189. user_repo::create_user(&pool, &new_user("frank@example.com", "pw", false, false)?).await?;
  190. user_scope_repo::entitle(&pool, user.id, "api:read", None).await?;
  191. user_scope_repo::entitle(&pool, user.id, "api:read", None).await?;
  192. assert_eq!(
  193. user_scope_repo::list_for_user(&pool, user.id).await?,
  194. vec!["api:read".to_owned()]
  195. );
  196. assert!(user_scope_repo::is_entitled(&pool, user.id, "api:read").await?);
  197. // A referenced scope cannot be deleted; revoking the entitlement frees it.
  198. assert!(!scope_repo::delete_if_unreferenced(&pool, "api:read").await?);
  199. assert!(user_scope_repo::revoke(&pool, user.id, "api:read").await?);
  200. assert!(!user_scope_repo::is_entitled(&pool, user.id, "api:read").await?);
  201. assert!(scope_repo::delete_if_unreferenced(&pool, "api:read").await?);
  202. assert!(scope_repo::get(&pool, "api:read").await?.is_none());
  203. Ok(())
  204. }
  205. /// Redis lockout: threshold locks, label is case-insensitive, clear-on-success
  206. /// and TTL expiry both unlock; a correct password while locked is still gated.
  207. #[tokio::test]
  208. async fn user_repo_lockout_locks_after_threshold_and_clears() -> TestResult {
  209. let (mut conn, store) = redis_store().await?;
  210. let policy = LockoutPolicy::new(3, Duration::from_millis(600)).ok_or("invalid policy")?;
  211. let label = "Locked@Example.com";
  212. for _ in 0..3 {
  213. user_repo::register_login_failure(&mut conn, &store, policy, label).await?;
  214. }
  215. assert!(user_repo::is_locked_out(&mut conn, &store, policy, label).await?);
  216. assert_eq!(
  217. user_repo::login_failure_count(&mut conn, &store, "locked@example.com").await?,
  218. 3,
  219. "counter is keyed by the normalized label"
  220. );
  221. assert!(
  222. user_repo::lockout_remaining(&mut conn, &store, label)
  223. .await?
  224. .is_some()
  225. );
  226. // Clear-on-success unlocks immediately.
  227. user_repo::clear_login_failures(&mut conn, &store, label).await?;
  228. assert_eq!(
  229. user_repo::login_failure_count(&mut conn, &store, label).await?,
  230. 0
  231. );
  232. assert!(!user_repo::is_locked_out(&mut conn, &store, policy, label).await?);
  233. // TTL expiry (unlock policy) also releases the lock.
  234. for _ in 0..3 {
  235. user_repo::register_login_failure(&mut conn, &store, policy, label).await?;
  236. }
  237. assert!(user_repo::is_locked_out(&mut conn, &store, policy, label).await?);
  238. tokio::time::sleep(Duration::from_millis(800)).await;
  239. assert_eq!(
  240. user_repo::login_failure_count(&mut conn, &store, label).await?,
  241. 0
  242. );
  243. assert!(!user_repo::is_locked_out(&mut conn, &store, policy, label).await?);
  244. Ok(())
  245. }
  246. /// A correct password is still rejected by the gate while the account is
  247. /// locked: the repo verifies the secret but `is_locked_out` must deny login.
  248. #[tokio::test]
  249. async fn user_repo_lockout_gate_rejects_correct_password() -> TestResult {
  250. let pool = postgres_pool().await?;
  251. let (mut conn, store) = redis_store().await?;
  252. user_repo::create_user(
  253. &pool,
  254. &new_user("gina@example.com", "correct", false, false)?,
  255. )
  256. .await?;
  257. let policy = LockoutPolicy::new(2, Duration::from_secs(60)).ok_or("invalid policy")?;
  258. for _ in 0..2 {
  259. user_repo::register_login_failure(&mut conn, &store, policy, "gina@example.com").await?;
  260. }
  261. let locked = user_repo::is_locked_out(&mut conn, &store, policy, "gina@example.com").await?;
  262. let verdict = user_repo::verify_password(&pool, "gina@example.com", "correct").await?;
  263. assert_eq!(
  264. verdict,
  265. PasswordVerdict::Matched,
  266. "password itself is valid"
  267. );
  268. assert!(locked, "login must refuse despite the valid password");
  269. Ok(())
  270. }