cli_user_disable.rs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  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. write_config_with_redis(world, None)
  80. }
  81. /// Like [`write_config`], but an explicit Redis URL overrides the world's, so
  82. /// the fail-closed test can point the CLI at an unavailable server.
  83. fn write_config_with_redis(
  84. world: &World,
  85. redis_override: Option<&str>,
  86. ) -> Result<(TempConfig, String), TestError> {
  87. let config = &world.state.config;
  88. let redis_url = match redis_override {
  89. Some(url) => url.to_owned(),
  90. None => match &config.redis.topology {
  91. RedisTopology::Single { url } => url.expose_secret().to_owned(),
  92. _ => {
  93. return Err(fail(
  94. "CLI config fixture requires the single-node Redis topology",
  95. ));
  96. }
  97. },
  98. };
  99. let (pepper_id, pepper) = config
  100. .keys
  101. .active_pepper()
  102. .ok_or_else(|| fail("active token pepper is missing"))?;
  103. let (totp_id, totp) = config
  104. .keys
  105. .active_totp_key()
  106. .ok_or_else(|| fail("active TOTP key is missing"))?;
  107. let (pii_id, pii) = config
  108. .keys
  109. .active_pii_key()
  110. .ok_or_else(|| fail("active PII key is missing"))?;
  111. let fixture = serde_json::json!({
  112. "issuer": config.issuer,
  113. "environment": "development",
  114. "bind_addr": "127.0.0.1:0",
  115. "health_addr": "127.0.0.1:0",
  116. "tls": {"cert_path": "/tmp/e2e-cert.pem", "key_path": "/tmp/e2e-key.pem"},
  117. "database": {"url": config.database.url.expose_secret()},
  118. "redis": {
  119. "url": redis_url,
  120. "durability": "waitaof",
  121. "token_key_prefix": config.redis.token_key_prefix,
  122. "ratelimit_key_prefix": config.redis.ratelimit_key_prefix,
  123. },
  124. "token_pepper": pepper.expose_secret(),
  125. "token_pepper_id": pepper_id.as_str(),
  126. "totp_encryption_key": totp.expose_secret(),
  127. "totp_key_id": totp_id.as_str(),
  128. "pii_encryption_key": pii.expose_secret(),
  129. "pii_key_id": pii_id.as_str(),
  130. "pii_dual_write": config.pii_dual_write,
  131. "rate_limit": open_rate_limit(),
  132. });
  133. let dir = std::env::temp_dir().join(format!("cli-disable-{}", uuid::Uuid::new_v4()));
  134. std::fs::create_dir_all(&dir)?;
  135. let file = dir.join("d-oauth.json");
  136. std::fs::write(
  137. &file,
  138. serde_json::to_vec(&fixture).map_err(|error| fail(format!("config JSON failed: {error}")))?,
  139. )?;
  140. // The fixture carries live key material; keep it owner-only on disk.
  141. #[cfg(unix)]
  142. {
  143. use std::os::unix::fs::PermissionsExt as _;
  144. std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600))?;
  145. }
  146. let stem = file.with_extension("");
  147. let path = stem
  148. .to_str()
  149. .map(str::to_owned)
  150. .ok_or_else(|| fail(format!("non-UTF-8 temp path: {}", stem.display())))?;
  151. Ok((TempConfig { dir }, path))
  152. }
  153. /// Whether one Redis key still exists.
  154. async fn key_exists(conn: &mut RedisConn, key: &str) -> Result<bool, TestError> {
  155. let count: i64 = redis::cmd("EXISTS").arg(key).query_async(conn).await?;
  156. Ok(count > 0)
  157. }
  158. /// Read the Postgres `users.disabled` flag for one user.
  159. async fn user_disabled(world: &World, user_id: uuid::Uuid) -> Result<bool, TestError> {
  160. let disabled: bool = sqlx::query_scalar("SELECT disabled FROM users WHERE id = $1")
  161. .bind(user_id)
  162. .fetch_one(&world.pool)
  163. .await?;
  164. Ok(disabled)
  165. }
  166. /// Mint one opaque pair whose subject is the seeded user's id, so the CLI's
  167. /// `purge_subject(email, user_id)` sweep matches it.
  168. async fn mint_subject_pair(
  169. world: &World,
  170. conn: &mut RedisConn,
  171. user_id: uuid::Uuid,
  172. ) -> Result<(String, String), TestError> {
  173. let minted = world
  174. .service
  175. .mint(
  176. conn,
  177. &world.store,
  178. &MintRequest {
  179. client_id: CLIENT_ID.to_owned(),
  180. client_kind: ClientKind::Confidential,
  181. dpop_required: false,
  182. subject: user_id.to_string(),
  183. scope: SCOPE.to_owned(),
  184. access_scope: SCOPE.to_owned(),
  185. grant_id: uuid::Uuid::new_v4().to_string(),
  186. proof: SenderProof {
  187. dpop_jkt: None,
  188. mtls_x5t_s256: None,
  189. },
  190. aud: Vec::new(),
  191. refresh_aud: Vec::new(),
  192. format: TokenFormat::Opaque,
  193. },
  194. time::OffsetDateTime::now_utc().unix_timestamp(),
  195. )
  196. .await
  197. .map_err(|error| fail(format!("fixture mint failed: {error}")))?;
  198. Ok((minted.access_hash, minted.refresh_hash))
  199. }
  200. /// The real CLI purges the user's Redis tokens, then flips `users.disabled`.
  201. #[tokio::test]
  202. async fn cli_user_disable_purges_redis_and_flips_the_flag() -> Result<(), TestError> {
  203. let world = World::start(open_rate_limit()).await?;
  204. seed_scope(&world.pool, SCOPE).await?;
  205. let (user_id, _totp_secret) = seed_entitled_user(&world, EMAIL, PASSWORD, SCOPE).await?;
  206. let mut conn = world.state.redis.clone();
  207. let (access_hash, refresh_hash) = mint_subject_pair(&world, &mut conn, user_id).await?;
  208. let access_key = world.store.access_key(&access_hash);
  209. let refresh_key = world.store.refresh_key(&refresh_hash);
  210. ensure(
  211. key_exists(&mut conn, &access_key).await?,
  212. "seeded access token must be live before disable",
  213. )?;
  214. ensure(
  215. key_exists(&mut conn, &refresh_key).await?,
  216. "seeded refresh token must be live before disable",
  217. )?;
  218. ensure(
  219. !user_disabled(&world, user_id).await?,
  220. "seeded user must start enabled",
  221. )?;
  222. let (_config_guard, config_path) = write_config(&world)?;
  223. let output = tokio::process::Command::new(server_binary()?)
  224. .args(["user", "disable", "--email", EMAIL])
  225. .env("D_OAUTH_CONFIG", &config_path)
  226. .output()
  227. .await?;
  228. ensure(
  229. output.status.success(),
  230. format!(
  231. "user disable must exit 0: {} {}",
  232. output.status,
  233. String::from_utf8_lossy(&output.stderr)
  234. ),
  235. )?;
  236. ensure(
  237. !key_exists(&mut conn, &access_key).await?,
  238. "the purge must delete the access-token key",
  239. )?;
  240. ensure(
  241. !key_exists(&mut conn, &refresh_key).await?,
  242. "the purge must delete the refresh-token key",
  243. )?;
  244. ensure(
  245. user_disabled(&world, user_id).await?,
  246. "users.disabled must be true after the purge (exit-0 path)",
  247. )?;
  248. Ok(())
  249. }
  250. /// A Redis outage during the purge must leave the account enabled and the
  251. /// tokens live: the flag flips only after a successful purge.
  252. #[tokio::test]
  253. async fn cli_user_disable_fails_closed_when_redis_is_down() -> Result<(), TestError> {
  254. let world = World::start(open_rate_limit()).await?;
  255. seed_scope(&world.pool, SCOPE).await?;
  256. let (user_id, _totp_secret) = seed_entitled_user(&world, EMAIL, PASSWORD, SCOPE).await?;
  257. let mut conn = world.state.redis.clone();
  258. let (access_hash, refresh_hash) = mint_subject_pair(&world, &mut conn, user_id).await?;
  259. let access_key = world.store.access_key(&access_hash);
  260. let refresh_key = world.store.refresh_key(&refresh_hash);
  261. let (_config_guard, config_path) = write_config_with_redis(&world, Some("redis://127.0.0.1:1"))?;
  262. let output = tokio::process::Command::new(server_binary()?)
  263. .args(["user", "disable", "--email", EMAIL])
  264. .env("D_OAUTH_CONFIG", &config_path)
  265. .output()
  266. .await?;
  267. ensure(
  268. !output.status.success(),
  269. format!(
  270. "a Redis outage must fail the CLI: {} {}",
  271. output.status,
  272. String::from_utf8_lossy(&output.stderr)
  273. ),
  274. )?;
  275. ensure(
  276. !user_disabled(&world, user_id).await?,
  277. "a failed purge must leave users.disabled false",
  278. )?;
  279. ensure(
  280. key_exists(&mut conn, &access_key).await?,
  281. "a failed purge must leave the access token live",
  282. )?;
  283. ensure(
  284. key_exists(&mut conn, &refresh_key).await?,
  285. "a failed purge must leave the refresh token live",
  286. )?;
  287. Ok(())
  288. }