cli_user_disable.rs 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. //! `test(cli_user_disable)` — the real `user disable` CLI purges the account's
  2. //! Redis token state before flipping `users.disabled` (plan todo 12).
  3. //!
  4. //! The test drives the shipped `d-oauth-server` binary as a child process
  5. //! (`user disable --email <email>` with `D_OAUTH_CONFIG` pointing at a JSON
  6. //! file generated from the live [`World`]'s validated config). No shell is
  7. //! involved and no production code is modified.
  8. //!
  9. //! Setup mints a token pair through the REAL [`d_oauth::protocol::token::TokenService`]
  10. //! with the seeded user's **id** as its `subject`, because `purge_subject`
  11. //! matches the stored record subject against the user id (the CLI passes
  12. //! `record.id.to_string()`); [`World::mint`] hardcodes `e2e-subject` and would
  13. //! leave the token behind.
  14. //!
  15. //! Assertions:
  16. //! * the seeded user starts enabled and both token keys are live;
  17. //! * the child exits 0;
  18. //! * both the access and refresh Redis keys are gone (the purge ran);
  19. //! * `users.disabled` is true only after that purge (exit-0 path).
  20. #[allow(dead_code)]
  21. #[path = "e2e/harness.rs"]
  22. mod harness;
  23. use std::path::PathBuf;
  24. use d_oauth::{
  25. config::RedisTopology,
  26. protocol::token::{ClientKind, MintRequest, SenderProof, TokenFormat},
  27. state::RedisConn,
  28. };
  29. use harness::{TestError, World, ensure, fail, open_rate_limit, seed_entitled_user, seed_scope};
  30. use secrecy::ExposeSecret as _;
  31. /// Seeded account email (canonical form the CLI resolves).
  32. const EMAIL: &str = "cli-disable@example.test";
  33. /// Seeded account password (never printed; the CLI path does not use it).
  34. const PASSWORD: &str = "correct-horse-28-cli-disable";
  35. /// Catalog scope the seeded account is entitled to.
  36. const SCOPE: &str = "cli:disable:read";
  37. /// Fixture client id the token pair is minted for.
  38. const CLIENT_ID: &str = "cli-disable-client";
  39. /// Locate the compiled server binary (cargo sets one of these spellings for
  40. /// integration targets; otherwise resolve next to the test executable).
  41. fn server_binary() -> Result<PathBuf, TestError> {
  42. for var in [
  43. "CARGO_BIN_EXE_d-oauth-server",
  44. "CARGO_BIN_EXE_d_oauth_server",
  45. ] {
  46. if let Ok(path) = std::env::var(var) {
  47. return Ok(PathBuf::from(path));
  48. }
  49. }
  50. let mut path = std::env::current_exe()?;
  51. if !path.pop() {
  52. return Err(fail("test executable has no parent"));
  53. }
  54. if path.ends_with("deps") && !path.pop() {
  55. return Err(fail("cannot leave deps dir"));
  56. }
  57. path.push("d-oauth-server");
  58. Ok(path)
  59. }
  60. /// Temp directory holding the generated config; removed on drop so the
  61. /// secret-bearing file never outlives the test.
  62. struct TempConfig {
  63. /// Directory created for this test (removed recursively on drop).
  64. dir: PathBuf,
  65. }
  66. impl Drop for TempConfig {
  67. fn drop(&mut self) {
  68. let _ = std::fs::remove_dir_all(&self.dir);
  69. }
  70. }
  71. /// Write a JSON config carrying the SAME validated values as `world`
  72. /// (database URL, Redis URL from the single-node topology, both key prefixes,
  73. /// issuer, and the active key ring), returning the guard plus the path stem.
  74. ///
  75. /// The extension is omitted from the returned path because `Config::load`
  76. /// resolves `D_OAUTH_CONFIG` through `File::with_name`, which probes supported
  77. /// formats (`tests/bootstrap.rs` uses the same pattern).
  78. fn write_config(world: &World) -> Result<(TempConfig, String), TestError> {
  79. let config = &world.state.config;
  80. let redis_url = match &config.redis.topology {
  81. RedisTopology::Single { url } => url.expose_secret().to_owned(),
  82. _ => {
  83. return Err(fail(
  84. "CLI config fixture requires the single-node Redis topology",
  85. ));
  86. }
  87. };
  88. let (pepper_id, pepper) = config
  89. .keys
  90. .active_pepper()
  91. .ok_or_else(|| fail("active token pepper is missing"))?;
  92. let (totp_id, totp) = config
  93. .keys
  94. .active_totp_key()
  95. .ok_or_else(|| fail("active TOTP key is missing"))?;
  96. let (pii_id, pii) = config
  97. .keys
  98. .active_pii_key()
  99. .ok_or_else(|| fail("active PII key is missing"))?;
  100. let fixture = serde_json::json!({
  101. "issuer": config.issuer,
  102. "environment": "development",
  103. "bind_addr": "127.0.0.1:0",
  104. "health_addr": "127.0.0.1:0",
  105. "tls": {"cert_path": "/tmp/e2e-cert.pem", "key_path": "/tmp/e2e-key.pem"},
  106. "database": {"url": config.database.url.expose_secret()},
  107. "redis": {
  108. "url": redis_url,
  109. "durability": "waitaof",
  110. "token_key_prefix": config.redis.token_key_prefix,
  111. "ratelimit_key_prefix": config.redis.ratelimit_key_prefix,
  112. },
  113. "token_pepper": pepper.expose_secret(),
  114. "token_pepper_id": pepper_id.as_str(),
  115. "totp_encryption_key": totp.expose_secret(),
  116. "totp_key_id": totp_id.as_str(),
  117. "pii_encryption_key": pii.expose_secret(),
  118. "pii_key_id": pii_id.as_str(),
  119. "pii_dual_write": config.pii_dual_write,
  120. "rate_limit": open_rate_limit(),
  121. });
  122. let dir = std::env::temp_dir().join(format!("cli-disable-{}", uuid::Uuid::new_v4()));
  123. std::fs::create_dir_all(&dir)?;
  124. let file = dir.join("d-oauth.json");
  125. std::fs::write(
  126. &file,
  127. serde_json::to_vec(&fixture).map_err(|error| fail(format!("config JSON failed: {error}")))?,
  128. )?;
  129. let stem = file.with_extension("");
  130. let path = stem
  131. .to_str()
  132. .map(str::to_owned)
  133. .ok_or_else(|| fail(format!("non-UTF-8 temp path: {}", stem.display())))?;
  134. Ok((TempConfig { dir }, path))
  135. }
  136. /// Whether one Redis key still exists.
  137. async fn key_exists(conn: &mut RedisConn, key: &str) -> Result<bool, TestError> {
  138. let count: i64 = redis::cmd("EXISTS").arg(key).query_async(conn).await?;
  139. Ok(count > 0)
  140. }
  141. /// Read the Postgres `users.disabled` flag for one user.
  142. async fn user_disabled(world: &World, user_id: uuid::Uuid) -> Result<bool, TestError> {
  143. let disabled: bool = sqlx::query_scalar("SELECT disabled FROM users WHERE id = $1")
  144. .bind(user_id)
  145. .fetch_one(&world.pool)
  146. .await?;
  147. Ok(disabled)
  148. }
  149. /// The real CLI purges the user's Redis tokens, then flips `users.disabled`.
  150. #[tokio::test]
  151. async fn cli_user_disable_purges_redis_and_flips_the_flag() -> Result<(), TestError> {
  152. let world = World::start(open_rate_limit()).await?;
  153. seed_scope(&world.pool, SCOPE).await?;
  154. let (user_id, _totp_secret) = seed_entitled_user(&world, EMAIL, PASSWORD, SCOPE).await?;
  155. let mut conn = world.state.redis.clone();
  156. let minted = world
  157. .service
  158. .mint(
  159. &mut conn,
  160. &world.store,
  161. &MintRequest {
  162. client_id: CLIENT_ID.to_owned(),
  163. client_kind: ClientKind::Confidential,
  164. dpop_required: false,
  165. subject: user_id.to_string(),
  166. scope: SCOPE.to_owned(),
  167. access_scope: SCOPE.to_owned(),
  168. grant_id: uuid::Uuid::new_v4().to_string(),
  169. proof: SenderProof {
  170. dpop_jkt: None,
  171. mtls_x5t_s256: None,
  172. },
  173. aud: Vec::new(),
  174. refresh_aud: Vec::new(),
  175. format: TokenFormat::Opaque,
  176. },
  177. time::OffsetDateTime::now_utc().unix_timestamp(),
  178. )
  179. .await
  180. .map_err(|error| fail(format!("fixture mint failed: {error}")))?;
  181. let access_key = world.store.access_key(&minted.access_hash);
  182. let refresh_key = world.store.refresh_key(&minted.refresh_hash);
  183. ensure(
  184. key_exists(&mut conn, &access_key).await?,
  185. "seeded access token must be live before disable",
  186. )?;
  187. ensure(
  188. key_exists(&mut conn, &refresh_key).await?,
  189. "seeded refresh token must be live before disable",
  190. )?;
  191. ensure(
  192. !user_disabled(&world, user_id).await?,
  193. "seeded user must start enabled",
  194. )?;
  195. let (_config_guard, config_path) = write_config(&world)?;
  196. let output = tokio::process::Command::new(server_binary()?)
  197. .args(["user", "disable", "--email", EMAIL])
  198. .env("D_OAUTH_CONFIG", &config_path)
  199. .output()
  200. .await?;
  201. ensure(
  202. output.status.success(),
  203. format!(
  204. "user disable must exit 0: {} {}",
  205. output.status,
  206. String::from_utf8_lossy(&output.stderr)
  207. ),
  208. )?;
  209. ensure(
  210. !key_exists(&mut conn, &access_key).await?,
  211. "the purge must delete the access-token key",
  212. )?;
  213. ensure(
  214. !key_exists(&mut conn, &refresh_key).await?,
  215. "the purge must delete the refresh-token key",
  216. )?;
  217. ensure(
  218. user_disabled(&world, user_id).await?,
  219. "users.disabled must be true after the purge (exit-0 path)",
  220. )?;
  221. Ok(())
  222. }