proptest_rotation.rs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. //! `test(proptest_rotation)` — rotation-invariant suite (task 42).
  2. //!
  3. //! Pure `proptest` properties over `store::tokens::derive_rotation_id`
  4. //! (deterministic, 64 lowercase hex chars, input-sensitive) plus
  5. //! shared-service rotation invariants over the real Redis from
  6. //! `compose.test.yaml` (`docker compose -f compose.test.yaml up -d`, AOF on,
  7. //! `WaitAof` durability): first rotation applies, an idempotent retry replays
  8. //! identical tokens, an unknown token is absent, and a foreign client
  9. //! mismatches. Each test uses unique key prefixes, so parallel tests never
  10. //! share state (and never FLUSH shared state). No `unwrap`/`expect`/`panic`
  11. //! anywhere.
  12. use std::{
  13. sync::atomic::{AtomicU64, Ordering},
  14. time::Duration,
  15. };
  16. use d_oauth::{
  17. config::DurabilityMode,
  18. state::RedisConn,
  19. store::{
  20. Store, StoreError,
  21. grants::{GrantRecord, create as create_grant},
  22. tokens::{
  23. AccessRecord, RefreshRecord, RotateOutcome, RotationInput, derive_rotation_id, put_refresh,
  24. rotate,
  25. },
  26. },
  27. };
  28. use proptest::prelude::*;
  29. mod common;
  30. use common::{redis_url, unique_prefixes};
  31. /// Test-body error.
  32. #[derive(Debug, thiserror::Error)]
  33. enum TestError {
  34. /// The shared test service is unreachable (usually: compose not started).
  35. #[error("test setup: {0}")]
  36. Setup(String),
  37. /// A Redis command failed.
  38. #[error("redis error: {0}")]
  39. Redis(#[from] redis::RedisError),
  40. /// A store operation failed.
  41. #[error("store error: {0}")]
  42. Store(#[from] StoreError),
  43. /// A test assertion observed an unexpected value.
  44. #[error("unexpected test value: {0}")]
  45. Unexpected(String),
  46. }
  47. /// Test body result (no `unwrap`/`expect` under the deny lints).
  48. type TestResult = Result<(), TestError>;
  49. static TEST_NONCE: AtomicU64 = AtomicU64::new(500_000);
  50. fn unique(label: &str) -> String {
  51. let n = TEST_NONCE.fetch_add(1, Ordering::Relaxed);
  52. format!("rotation-test-{n}-{label}")
  53. }
  54. proptest! {
  55. #[test]
  56. fn rotation_id_is_deterministic_hex(
  57. pepper in proptest::collection::vec(any::<u8>(), 0..64),
  58. old_hash in "[a-z0-9:._-]{1,64}",
  59. ) {
  60. let first = match derive_rotation_id(&pepper, &old_hash) {
  61. Ok(id) => id,
  62. Err(_) => return Ok(()),
  63. };
  64. let second = match derive_rotation_id(&pepper, &old_hash) {
  65. Ok(id) => id,
  66. Err(_) => {
  67. prop_assert!(false, "derivation must be deterministic");
  68. return Ok(());
  69. }
  70. };
  71. prop_assert!(first == second, "rotation id must be deterministic");
  72. prop_assert_eq!(first.len(), 64);
  73. prop_assert!(first.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()));
  74. }
  75. #[test]
  76. fn rotation_id_separates_inputs(
  77. pepper in proptest::collection::vec(any::<u8>(), 16),
  78. first_hash in "[a-z0-9]{8}",
  79. second_hash in "[a-z0-9]{8}",
  80. ) {
  81. if first_hash == second_hash {
  82. return Ok(());
  83. }
  84. let first = match derive_rotation_id(&pepper, &first_hash) {
  85. Ok(id) => id,
  86. Err(_) => return Ok(()),
  87. };
  88. let second = match derive_rotation_id(&pepper, &second_hash) {
  89. Ok(id) => id,
  90. Err(_) => return Ok(()),
  91. };
  92. prop_assert_ne!(first, second);
  93. }
  94. }
  95. /// URL of the shared test Redis (see `tests/common/mod.rs`).
  96. fn redis_single() -> String {
  97. redis_url()
  98. }
  99. async fn direct_store(url: &str) -> Result<(RedisConn, Store), TestError> {
  100. let client = redis::Client::open(url).map_err(|error| {
  101. TestError::Setup(format!(
  102. "cannot reach shared test Redis at {url}: {error}; run `docker compose -f compose.test.yaml up -d`"
  103. ))
  104. })?;
  105. let manager_config = redis::aio::ConnectionManagerConfig::new()
  106. .set_response_timeout(Some(Duration::from_secs(10)))
  107. .set_connection_timeout(Some(Duration::from_secs(5)));
  108. let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config)
  109. .await
  110. .map_err(|error| {
  111. TestError::Setup(format!(
  112. "cannot reach shared test Redis at {url}: {error}; run `docker compose -f compose.test.yaml up -d`"
  113. ))
  114. })?;
  115. let (token_prefix, ratelimit_prefix) = unique_prefixes();
  116. let store = Store::from_parts(token_prefix, ratelimit_prefix, DurabilityMode::WaitAof);
  117. Ok((RedisConn::Single(manager), store))
  118. }
  119. fn refresh_record(client: &str, grant: &str) -> RefreshRecord {
  120. RefreshRecord {
  121. client_id: client.to_owned(),
  122. grant_id: grant.to_owned(),
  123. scope: "read".to_owned(),
  124. subject: "user-1".to_owned(),
  125. cnf: String::new(),
  126. aud: Vec::new(),
  127. }
  128. }
  129. fn access_record(client: &str, grant: &str) -> AccessRecord {
  130. AccessRecord {
  131. client_id: client.to_owned(),
  132. grant_id: grant.to_owned(),
  133. scope: "read".to_owned(),
  134. subject: "user-1".to_owned(),
  135. cnf: String::new(),
  136. aud: Vec::new(),
  137. }
  138. }
  139. fn rotation_input(
  140. old_refresh_hash: &str,
  141. new_refresh_hash: &str,
  142. new_access_hash: &str,
  143. client: &str,
  144. grant: &str,
  145. request_id: &str,
  146. ) -> RotationInput {
  147. RotationInput {
  148. old_refresh_hash: old_refresh_hash.to_owned(),
  149. expected_client_id: client.to_owned(),
  150. new_refresh_hash: new_refresh_hash.to_owned(),
  151. new_access_hash: new_access_hash.to_owned(),
  152. new_refresh: refresh_record(client, grant),
  153. new_access: access_record(client, grant),
  154. new_refresh_ttl: Duration::from_secs(600),
  155. new_access_ttl: Duration::from_secs(300),
  156. request_id: Some(request_id.to_owned()),
  157. jwt_jti_hash: None,
  158. }
  159. }
  160. /// Rotation applies once, replays idempotently, and rejects strangers.
  161. #[tokio::test]
  162. async fn rotation_apply_replay_absent_mismatch() -> TestResult {
  163. let url = redis_single();
  164. let (mut conn, store) = direct_store(&url).await?;
  165. let pepper = b"rotation-proptest-pepper";
  166. let client = unique("client");
  167. let grant = unique("grant");
  168. let old_hash = unique("old");
  169. let new_refresh_hash = unique("new-refresh");
  170. let new_access_hash = unique("new-access");
  171. let request_id = unique("request");
  172. put_refresh(
  173. &mut conn,
  174. &store,
  175. &old_hash,
  176. &refresh_record(&client, &grant),
  177. Duration::from_secs(600),
  178. )
  179. .await?;
  180. // Production mint always writes the grant record before any rotation;
  181. // the rotation fence requires it, so the harness mirrors mint here.
  182. create_grant(
  183. &mut conn,
  184. &store,
  185. &GrantRecord {
  186. grant_id: grant.clone(),
  187. client_id: client.clone(),
  188. subject: "user-1".to_owned(),
  189. scope: "read".to_owned(),
  190. aud: Vec::new(),
  191. },
  192. Duration::from_secs(600),
  193. )
  194. .await?;
  195. // First rotation applies.
  196. let input = rotation_input(
  197. &old_hash,
  198. &new_refresh_hash,
  199. &new_access_hash,
  200. &client,
  201. &grant,
  202. &request_id,
  203. );
  204. match rotate(&mut conn, &store, pepper, &input).await? {
  205. RotateOutcome::Rotated {
  206. new_refresh_hash: rotated_refresh,
  207. new_access_hash: rotated_access,
  208. } => {
  209. if rotated_refresh != new_refresh_hash || rotated_access != new_access_hash {
  210. return Err(TestError::Unexpected(
  211. "rotated hashes differ from input".to_owned(),
  212. ));
  213. }
  214. }
  215. outcome => {
  216. return Err(TestError::Unexpected(format!(
  217. "expected Rotated, saw {outcome:?}"
  218. )));
  219. }
  220. }
  221. // The idempotent retry replays the identical tokens.
  222. let replay = rotation_input(
  223. &old_hash,
  224. &new_refresh_hash,
  225. &new_access_hash,
  226. &client,
  227. &grant,
  228. &request_id,
  229. );
  230. match rotate(&mut conn, &store, pepper, &replay).await? {
  231. RotateOutcome::Replay {
  232. new_refresh_hash: replayed_refresh,
  233. new_access_hash: replayed_access,
  234. } => {
  235. if replayed_refresh != new_refresh_hash || replayed_access != new_access_hash {
  236. return Err(TestError::Unexpected(
  237. "replayed hashes differ from input".to_owned(),
  238. ));
  239. }
  240. }
  241. outcome => {
  242. return Err(TestError::Unexpected(format!(
  243. "expected Replay, saw {outcome:?}"
  244. )));
  245. }
  246. }
  247. // An unknown token is absent (no existence oracle beyond the outcome).
  248. let unknown = rotation_input(
  249. &unique("ghost"),
  250. &unique("ghost-new-refresh"),
  251. &unique("ghost-new-access"),
  252. &client,
  253. &grant,
  254. &unique("ghost-request"),
  255. );
  256. match rotate(&mut conn, &store, pepper, &unknown).await? {
  257. RotateOutcome::Absent => {}
  258. outcome => {
  259. return Err(TestError::Unexpected(format!(
  260. "expected Absent, saw {outcome:?}"
  261. )));
  262. }
  263. }
  264. // A foreign client mismatches against the freshly rotated record.
  265. let foreign = rotation_input(
  266. &new_refresh_hash,
  267. &unique("foreign-new-refresh"),
  268. &unique("foreign-new-access"),
  269. "someone-else",
  270. &grant,
  271. &unique("foreign-request"),
  272. );
  273. match rotate(&mut conn, &store, pepper, &foreign).await? {
  274. RotateOutcome::Mismatch => {}
  275. outcome => {
  276. return Err(TestError::Unexpected(format!(
  277. "expected Mismatch, saw {outcome:?}"
  278. )));
  279. }
  280. }
  281. Ok(())
  282. }