proptest_rotation.rs 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  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. }
  127. }
  128. fn access_record(client: &str, grant: &str) -> AccessRecord {
  129. AccessRecord {
  130. client_id: client.to_owned(),
  131. grant_id: grant.to_owned(),
  132. scope: "read".to_owned(),
  133. subject: "user-1".to_owned(),
  134. cnf: String::new(),
  135. }
  136. }
  137. fn rotation_input(
  138. old_refresh_hash: &str,
  139. new_refresh_hash: &str,
  140. new_access_hash: &str,
  141. client: &str,
  142. grant: &str,
  143. request_id: &str,
  144. ) -> RotationInput {
  145. RotationInput {
  146. old_refresh_hash: old_refresh_hash.to_owned(),
  147. expected_client_id: client.to_owned(),
  148. new_refresh_hash: new_refresh_hash.to_owned(),
  149. new_access_hash: new_access_hash.to_owned(),
  150. new_refresh: refresh_record(client, grant),
  151. new_access: access_record(client, grant),
  152. new_refresh_ttl: Duration::from_secs(600),
  153. new_access_ttl: Duration::from_secs(300),
  154. request_id: Some(request_id.to_owned()),
  155. }
  156. }
  157. /// Rotation applies once, replays idempotently, and rejects strangers.
  158. #[tokio::test]
  159. async fn rotation_apply_replay_absent_mismatch() -> TestResult {
  160. let url = redis_single();
  161. let (mut conn, store) = direct_store(&url).await?;
  162. let pepper = b"rotation-proptest-pepper";
  163. let client = unique("client");
  164. let grant = unique("grant");
  165. let old_hash = unique("old");
  166. let new_refresh_hash = unique("new-refresh");
  167. let new_access_hash = unique("new-access");
  168. let request_id = unique("request");
  169. put_refresh(
  170. &mut conn,
  171. &store,
  172. &old_hash,
  173. &refresh_record(&client, &grant),
  174. Duration::from_secs(600),
  175. )
  176. .await?;
  177. // Production mint always writes the grant record before any rotation;
  178. // the rotation fence requires it, so the harness mirrors mint here.
  179. create_grant(
  180. &mut conn,
  181. &store,
  182. &GrantRecord {
  183. grant_id: grant.clone(),
  184. client_id: client.clone(),
  185. subject: "user-1".to_owned(),
  186. scope: "read".to_owned(),
  187. },
  188. Duration::from_secs(600),
  189. )
  190. .await?;
  191. // First rotation applies.
  192. let input = rotation_input(
  193. &old_hash,
  194. &new_refresh_hash,
  195. &new_access_hash,
  196. &client,
  197. &grant,
  198. &request_id,
  199. );
  200. match rotate(&mut conn, &store, pepper, &input).await? {
  201. RotateOutcome::Rotated {
  202. new_refresh_hash: rotated_refresh,
  203. new_access_hash: rotated_access,
  204. } => {
  205. if rotated_refresh != new_refresh_hash || rotated_access != new_access_hash {
  206. return Err(TestError::Unexpected(
  207. "rotated hashes differ from input".to_owned(),
  208. ));
  209. }
  210. }
  211. outcome => {
  212. return Err(TestError::Unexpected(format!(
  213. "expected Rotated, saw {outcome:?}"
  214. )));
  215. }
  216. }
  217. // The idempotent retry replays the identical tokens.
  218. let replay = rotation_input(
  219. &old_hash,
  220. &new_refresh_hash,
  221. &new_access_hash,
  222. &client,
  223. &grant,
  224. &request_id,
  225. );
  226. match rotate(&mut conn, &store, pepper, &replay).await? {
  227. RotateOutcome::Replay {
  228. new_refresh_hash: replayed_refresh,
  229. new_access_hash: replayed_access,
  230. } => {
  231. if replayed_refresh != new_refresh_hash || replayed_access != new_access_hash {
  232. return Err(TestError::Unexpected(
  233. "replayed hashes differ from input".to_owned(),
  234. ));
  235. }
  236. }
  237. outcome => {
  238. return Err(TestError::Unexpected(format!(
  239. "expected Replay, saw {outcome:?}"
  240. )));
  241. }
  242. }
  243. // An unknown token is absent (no existence oracle beyond the outcome).
  244. let unknown = rotation_input(
  245. &unique("ghost"),
  246. &unique("ghost-new-refresh"),
  247. &unique("ghost-new-access"),
  248. &client,
  249. &grant,
  250. &unique("ghost-request"),
  251. );
  252. match rotate(&mut conn, &store, pepper, &unknown).await? {
  253. RotateOutcome::Absent => {}
  254. outcome => {
  255. return Err(TestError::Unexpected(format!(
  256. "expected Absent, saw {outcome:?}"
  257. )));
  258. }
  259. }
  260. // A foreign client mismatches against the freshly rotated record.
  261. let foreign = rotation_input(
  262. &new_refresh_hash,
  263. &unique("foreign-new-refresh"),
  264. &unique("foreign-new-access"),
  265. "someone-else",
  266. &grant,
  267. &unique("foreign-request"),
  268. );
  269. match rotate(&mut conn, &store, pepper, &foreign).await? {
  270. RotateOutcome::Mismatch => {}
  271. outcome => {
  272. return Err(TestError::Unexpected(format!(
  273. "expected Mismatch, saw {outcome:?}"
  274. )));
  275. }
  276. }
  277. Ok(())
  278. }