user_repo.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. //! `test(user_repo)` — real `testcontainers` 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; a single-node Redis
  5. //! backs the failed-login/lockout assertions. Test bodies use `Result` + `?`
  6. //! (`unwrap`/`expect`/`panic` are denied workspace-wide).
  7. use std::{error::Error, time::Duration};
  8. use d_oauth::{
  9. config::DurabilityMode,
  10. crypto::secret::{Argon2Params, hash_secret},
  11. db::{
  12. scope_repo,
  13. user_repo::{self, LockoutPolicy, NewUser, PasswordVerdict},
  14. user_scope_repo,
  15. },
  16. state::{MIGRATOR, RedisConn},
  17. store::Store,
  18. };
  19. use sqlx::PgPool;
  20. use testcontainers::{
  21. GenericImage, ImageExt,
  22. core::{IntoContainerPort, WaitFor},
  23. runners::AsyncRunner,
  24. };
  25. #[path = "../src/test_guard.rs"]
  26. #[allow(dead_code)]
  27. mod test_guard;
  28. use test_guard::ContainerGuard;
  29. /// Boxed test error.
  30. type TestError = Box<dyn Error + Send + Sync>;
  31. /// Fallible test body.
  32. type TestResult = Result<(), TestError>;
  33. /// Throwaway Postgres superuser password.
  34. const PG_PASSWORD: &str = "user-repo-test-secret";
  35. /// Fast Argon2id parameters keep container-backed tests quick.
  36. fn fast_params() -> Result<Argon2Params, TestError> {
  37. Ok(Argon2Params::new(8, 1, 1, 32)?)
  38. }
  39. /// Build a `NewUser` with a freshly hashed password.
  40. fn new_user(
  41. email: &str,
  42. password: &str,
  43. is_admin: bool,
  44. must_change_password: bool,
  45. ) -> Result<NewUser, TestError> {
  46. Ok(NewUser {
  47. email: email.to_owned(),
  48. password_hash: hash_secret(password, &fast_params()?)?,
  49. is_admin,
  50. must_change_password,
  51. })
  52. }
  53. /// Start Postgres, apply every migration, and return the pool.
  54. ///
  55. /// The guard removes the container synchronously when the test drops it.
  56. async fn postgres_pool() -> Result<(ContainerGuard, PgPool), TestError> {
  57. let container = GenericImage::new("postgres", "latest")
  58. .with_exposed_port(5432.tcp())
  59. .with_wait_for(WaitFor::message_on_stderr(
  60. "database system is ready to accept connections",
  61. ))
  62. .with_wait_for(WaitFor::message_on_stdout(
  63. "database system is ready to accept connections",
  64. ))
  65. .with_env_var("POSTGRES_PASSWORD", PG_PASSWORD)
  66. .start()
  67. .await?;
  68. let guarded = ContainerGuard::new(container);
  69. let port = guarded.get_host_port_ipv4(5432).await?;
  70. let url = format!("postgres://postgres:{PG_PASSWORD}@127.0.0.1:{port}/postgres?sslmode=disable");
  71. let pool = PgPool::connect(&url).await?;
  72. MIGRATOR.run(&pool).await?;
  73. Ok((guarded, pool))
  74. }
  75. /// Start a single-node Redis and return a [`RedisConn`] plus [`Store`].
  76. ///
  77. /// The guard removes the container synchronously when the test drops it.
  78. async fn redis_store() -> Result<(ContainerGuard, RedisConn, Store), TestError> {
  79. let container = GenericImage::new("redis", "7.2-alpine")
  80. .with_exposed_port(6379.tcp())
  81. .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
  82. .with_cmd(vec!["redis-server", "--appendonly", "yes"])
  83. .start()
  84. .await?;
  85. let guarded = ContainerGuard::new(container);
  86. let port = guarded.get_host_port_ipv4(6379).await?;
  87. let url = format!("redis://127.0.0.1:{port}");
  88. let client = redis::Client::open(url.as_str())?;
  89. let manager = redis::aio::ConnectionManager::new(client).await?;
  90. let store = Store::from_parts(
  91. "oauth:{state}:".to_owned(),
  92. "oauth:{rl}:".to_owned(),
  93. DurabilityMode::WaitAof,
  94. );
  95. Ok((guarded, RedisConn::Single(manager), store))
  96. }
  97. /// Create/find normalizes the email and rejects a case-variant duplicate.
  98. #[tokio::test]
  99. async fn user_repo_create_and_find_is_case_insensitive() -> TestResult {
  100. let (_pg, pool) = postgres_pool().await?;
  101. let created = user_repo::create_user(
  102. &pool,
  103. &new_user(" Alice@Example.COM ", "pw-one", false, false)?,
  104. )
  105. .await?;
  106. assert_eq!(created.email, "alice@example.com");
  107. assert!(created.password_changed_at.is_some());
  108. assert!(!created.must_change_password);
  109. let found = user_repo::find_by_email(&pool, "ALICE@example.com")
  110. .await?
  111. .ok_or("user not found by case-variant email")?;
  112. assert_eq!(found.id, created.id);
  113. let duplicate = user_repo::create_user(
  114. &pool,
  115. &new_user("alice@example.com", "pw-two", false, false)?,
  116. )
  117. .await;
  118. assert!(
  119. duplicate.is_err(),
  120. "case-variant duplicate must be rejected"
  121. );
  122. assert!(
  123. user_repo::find_by_email(&pool, "missing@example.com")
  124. .await?
  125. .is_none()
  126. );
  127. Ok(())
  128. }
  129. /// Password verification matches/mismatches and refuses disabled accounts.
  130. #[tokio::test]
  131. async fn user_repo_verify_password() -> TestResult {
  132. let (_pg, pool) = postgres_pool().await?;
  133. let user = user_repo::create_user(
  134. &pool,
  135. &new_user("carol@example.com", "s3cret", false, true)?,
  136. )
  137. .await?;
  138. assert!(user.must_change_password);
  139. assert_eq!(
  140. user_repo::verify_password(&pool, "CAROL@example.com", "s3cret").await?,
  141. PasswordVerdict::Matched
  142. );
  143. assert_eq!(
  144. user_repo::verify_password(&pool, "carol@example.com", "wrong").await?,
  145. PasswordVerdict::Mismatched
  146. );
  147. assert_eq!(
  148. user_repo::verify_password(&pool, "nobody@example.com", "s3cret").await?,
  149. PasswordVerdict::Unknown
  150. );
  151. assert!(user_repo::disable_user(&pool, user.id).await?);
  152. assert_eq!(
  153. user_repo::verify_password(&pool, "carol@example.com", "s3cret").await?,
  154. PasswordVerdict::Disabled
  155. );
  156. Ok(())
  157. }
  158. /// TOTP secret set/clear/toggle plus one-time recovery-code consumption.
  159. #[tokio::test]
  160. async fn user_repo_totp_and_recovery_codes() -> TestResult {
  161. let (_pg, pool) = postgres_pool().await?;
  162. let user =
  163. user_repo::create_user(&pool, &new_user("dave@example.com", "pw", false, false)?).await?;
  164. assert!(user_repo::set_totp_secret(&pool, user.id, &[1, 2, 3, 4]).await?);
  165. assert!(user_repo::set_totp_enabled(&pool, user.id, true).await?);
  166. assert!(user_repo::clear_totp_secret(&pool, user.id).await?);
  167. let code = "RECOVERY-CODE-ONE";
  168. let hash = hash_secret(code, &fast_params()?)?;
  169. assert!(user_repo::set_recovery_codes(&pool, user.id, std::slice::from_ref(&hash)).await?);
  170. assert!(user_repo::consume_recovery_code(&pool, user.id, code).await?);
  171. assert!(
  172. !user_repo::consume_recovery_code(&pool, user.id, code).await?,
  173. "a recovery code is single-use"
  174. );
  175. assert!(!user_repo::consume_recovery_code(&pool, user.id, "WRONG").await?);
  176. Ok(())
  177. }
  178. /// Admin counting/promote/demote and password rotation semantics.
  179. #[tokio::test]
  180. async fn user_repo_admin_and_password_rotation() -> TestResult {
  181. let (_pg, pool) = postgres_pool().await?;
  182. assert!(!user_repo::has_any_admin(&pool).await?);
  183. let user =
  184. user_repo::create_user(&pool, &new_user("erin@example.com", "first", false, true)?).await?;
  185. assert!(!user_repo::has_any_admin(&pool).await?);
  186. assert!(user_repo::set_admin(&pool, user.id, true).await?);
  187. assert!(user_repo::has_any_admin(&pool).await?);
  188. // A disabled admin does not count toward the bootstrap gate.
  189. assert!(user_repo::disable_user(&pool, user.id).await?);
  190. assert!(!user_repo::has_any_admin(&pool).await?);
  191. // set_password clears must_change_password and stamps password_changed_at.
  192. let rotated = hash_secret("second", &fast_params()?)?;
  193. assert!(user_repo::set_password(&pool, user.id, &rotated).await?);
  194. let refreshed = user_repo::find_by_email(&pool, "erin@example.com")
  195. .await?
  196. .ok_or("user missing after password rotation")?;
  197. assert!(!refreshed.must_change_password);
  198. assert!(refreshed.password_changed_at.is_some());
  199. assert_eq!(
  200. user_repo::verify_password(&pool, "erin@example.com", "second").await?,
  201. PasswordVerdict::Disabled,
  202. "the account is still disabled until re-enabled"
  203. );
  204. Ok(())
  205. }
  206. /// Scope catalog: upsert idempotency, enable/disable, guarded delete, and
  207. /// entitlement round-trips.
  208. #[tokio::test]
  209. async fn user_repo_scope_and_entitlement_round_trip() -> TestResult {
  210. let (_pg, pool) = postgres_pool().await?;
  211. let created = scope_repo::upsert(&pool, "api:read", "Read API").await?;
  212. assert!(created.enabled);
  213. let updated = scope_repo::upsert(&pool, "api:read", "Read API v2").await?;
  214. assert_eq!(updated.description, "Read API v2");
  215. assert_eq!(scope_repo::list_enabled(&pool).await?.len(), 1);
  216. assert!(scope_repo::set_enabled(&pool, "api:read", false).await?);
  217. assert!(scope_repo::list_enabled(&pool).await?.is_empty());
  218. let user =
  219. user_repo::create_user(&pool, &new_user("frank@example.com", "pw", false, false)?).await?;
  220. user_scope_repo::entitle(&pool, user.id, "api:read", None).await?;
  221. user_scope_repo::entitle(&pool, user.id, "api:read", None).await?;
  222. assert_eq!(
  223. user_scope_repo::list_for_user(&pool, user.id).await?,
  224. vec!["api:read".to_owned()]
  225. );
  226. assert!(user_scope_repo::is_entitled(&pool, user.id, "api:read").await?);
  227. // A referenced scope cannot be deleted; revoking the entitlement frees it.
  228. assert!(!scope_repo::delete_if_unreferenced(&pool, "api:read").await?);
  229. assert!(user_scope_repo::revoke(&pool, user.id, "api:read").await?);
  230. assert!(!user_scope_repo::is_entitled(&pool, user.id, "api:read").await?);
  231. assert!(scope_repo::delete_if_unreferenced(&pool, "api:read").await?);
  232. assert!(scope_repo::get(&pool, "api:read").await?.is_none());
  233. Ok(())
  234. }
  235. /// Redis lockout: threshold locks, label is case-insensitive, clear-on-success
  236. /// and TTL expiry both unlock; a correct password while locked is still gated.
  237. #[tokio::test]
  238. async fn user_repo_lockout_locks_after_threshold_and_clears() -> TestResult {
  239. let (_redis, mut conn, store) = redis_store().await?;
  240. let policy = LockoutPolicy::new(3, Duration::from_millis(600)).ok_or("invalid policy")?;
  241. let label = "Locked@Example.com";
  242. for _ in 0..3 {
  243. user_repo::register_login_failure(&mut conn, &store, policy, label).await?;
  244. }
  245. assert!(user_repo::is_locked_out(&mut conn, &store, policy, label).await?);
  246. assert_eq!(
  247. user_repo::login_failure_count(&mut conn, &store, "locked@example.com").await?,
  248. 3,
  249. "counter is keyed by the normalized label"
  250. );
  251. assert!(
  252. user_repo::lockout_remaining(&mut conn, &store, label)
  253. .await?
  254. .is_some()
  255. );
  256. // Clear-on-success unlocks immediately.
  257. user_repo::clear_login_failures(&mut conn, &store, label).await?;
  258. assert_eq!(
  259. user_repo::login_failure_count(&mut conn, &store, label).await?,
  260. 0
  261. );
  262. assert!(!user_repo::is_locked_out(&mut conn, &store, policy, label).await?);
  263. // TTL expiry (unlock policy) also releases the lock.
  264. for _ in 0..3 {
  265. user_repo::register_login_failure(&mut conn, &store, policy, label).await?;
  266. }
  267. assert!(user_repo::is_locked_out(&mut conn, &store, policy, label).await?);
  268. tokio::time::sleep(Duration::from_millis(800)).await;
  269. assert_eq!(
  270. user_repo::login_failure_count(&mut conn, &store, label).await?,
  271. 0
  272. );
  273. assert!(!user_repo::is_locked_out(&mut conn, &store, policy, label).await?);
  274. Ok(())
  275. }
  276. /// A correct password is still rejected by the gate while the account is
  277. /// locked: the repo verifies the secret but `is_locked_out` must deny login.
  278. #[tokio::test]
  279. async fn user_repo_lockout_gate_rejects_correct_password() -> TestResult {
  280. let (_pg, pool) = postgres_pool().await?;
  281. let (_redis, mut conn, store) = redis_store().await?;
  282. user_repo::create_user(
  283. &pool,
  284. &new_user("gina@example.com", "correct", false, false)?,
  285. )
  286. .await?;
  287. let policy = LockoutPolicy::new(2, Duration::from_secs(60)).ok_or("invalid policy")?;
  288. for _ in 0..2 {
  289. user_repo::register_login_failure(&mut conn, &store, policy, "gina@example.com").await?;
  290. }
  291. let locked = user_repo::is_locked_out(&mut conn, &store, policy, "gina@example.com").await?;
  292. let verdict = user_repo::verify_password(&pool, "gina@example.com", "correct").await?;
  293. assert_eq!(
  294. verdict,
  295. PasswordVerdict::Matched,
  296. "password itself is valid"
  297. );
  298. assert!(locked, "login must refuse despite the valid password");
  299. Ok(())
  300. }