proptest_rotation.rs 8.2 KB

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