proptest_rotation.rs 8.1 KB

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