tests.rs 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845
  1. //! `test(cimd_)` suite: strict CIMD fetch/validation rules (offline except
  2. //! the cache round-trip, which uses the shared compose Redis).
  3. use std::{
  4. collections::HashSet,
  5. sync::{
  6. Arc,
  7. atomic::{AtomicUsize, Ordering},
  8. },
  9. time::Duration,
  10. };
  11. use serde_json::{Value, json};
  12. use super::{
  13. CimdAuthMethod, CimdDocument, CimdError, CimdFetcher, CimdLimits, CimdValidation, ValidatedCimd,
  14. acquire_lock, boxed, cache_key, fetch_document, get_cached, invalid_cache_key, lock_key,
  15. negative_cache_key, put_cached, release_lock, resolve, ssrf_fetcher, validate_document,
  16. };
  17. use crate::state::RedisConn;
  18. /// This suite's error type (no `unwrap`/`expect` under the deny lints).
  19. type TestResult = Result<(), TestError>;
  20. /// Test scaffolding failures.
  21. #[derive(Debug, thiserror::Error)]
  22. enum TestError {
  23. /// An assertion observed an unexpected value.
  24. #[error("unexpected test value: {0}")]
  25. Unexpected(String),
  26. }
  27. /// The enabled scope catalog used by the validation tests.
  28. fn scopes() -> HashSet<String> {
  29. ["profile", "email"]
  30. .into_iter()
  31. .map(str::to_owned)
  32. .collect()
  33. }
  34. /// Build a document value; callers override one member per test.
  35. fn document_value(client_id: &str, redirect_uris: &[&str]) -> Value {
  36. json!({
  37. "client_id": client_id,
  38. "redirect_uris": redirect_uris,
  39. "token_endpoint_auth_method": "none",
  40. "grant_types": ["authorization_code"],
  41. "scope": "profile",
  42. })
  43. }
  44. /// Parse a test document.
  45. fn parse(value: Value) -> Result<CimdDocument, TestError> {
  46. serde_json::from_value(value)
  47. .map_err(|error| TestError::Unexpected(format!("document parse: {error}")))
  48. }
  49. /// Parse and validate one document, requiring acceptance.
  50. fn expect_ok(
  51. client_id: &str,
  52. value: Value,
  53. enabled_scopes: &HashSet<String>,
  54. allow_localhost: bool,
  55. ) -> Result<ValidatedCimd, TestError> {
  56. let document = parse(value)?;
  57. validate_document(
  58. client_id,
  59. &document,
  60. &CimdValidation {
  61. enabled_scopes,
  62. allow_localhost,
  63. },
  64. )
  65. .map_err(|error| TestError::Unexpected(format!("{client_id}: rejected ({error})")))
  66. }
  67. /// Parse and validate one document, requiring rejection; returns the error.
  68. fn expect_err(
  69. client_id: &str,
  70. value: Value,
  71. enabled_scopes: &HashSet<String>,
  72. allow_localhost: bool,
  73. ) -> Result<CimdError, TestError> {
  74. let document = parse(value)?;
  75. match validate_document(
  76. client_id,
  77. &document,
  78. &CimdValidation {
  79. enabled_scopes,
  80. allow_localhost,
  81. },
  82. ) {
  83. Ok(_) => Err(TestError::Unexpected(format!("{client_id}: accepted"))),
  84. Err(error) => Ok(error),
  85. }
  86. }
  87. /// The guarded fetcher rejects special-use and private IP literals before any
  88. /// connection is attempted (no DNS, no network I/O).
  89. #[tokio::test]
  90. async fn cimd_rejects_special_use_ip() -> TestResult {
  91. let limits = CimdLimits {
  92. max_bytes: 5_120,
  93. timeout: Duration::from_secs(1),
  94. };
  95. for url in [
  96. "https://127.0.0.1/cimd.json",
  97. "https://10.0.0.1/cimd.json",
  98. "https://169.254.169.254/latest/meta-data",
  99. "https://192.168.1.10/cimd.json",
  100. "https://[::1]/cimd.json",
  101. ] {
  102. match fetch_document(url, limits).await {
  103. Err(CimdError::Fetch) => {}
  104. Err(other) => {
  105. return Err(TestError::Unexpected(format!("{url}: wrong error {other}")));
  106. }
  107. Ok(_) => {
  108. return Err(TestError::Unexpected(format!(
  109. "{url}: fetched a blocked target"
  110. )));
  111. }
  112. }
  113. }
  114. Ok(())
  115. }
  116. /// Empty, wildcarded, non-http(s), and query-bearing redirect URIs are all
  117. /// rejected; the strict redirect matcher is the single authority.
  118. #[test]
  119. fn cimd_rejects_redirect() -> TestResult {
  120. let enabled = scopes();
  121. let client_id = "https://client.example/cimd.json";
  122. for redirects in [
  123. Vec::<&str>::new(),
  124. vec!["https://client.example/*"],
  125. vec!["https://*.client.example/cb"],
  126. vec!["com.example.app:/cb"],
  127. vec!["https://client.example/cb?next=1"],
  128. ] {
  129. match expect_err(
  130. client_id,
  131. document_value(client_id, &redirects),
  132. &enabled,
  133. false,
  134. )? {
  135. CimdError::InvalidRedirectUri => {}
  136. other => {
  137. return Err(TestError::Unexpected(format!(
  138. "{redirects:?}: wrong error {other}"
  139. )));
  140. }
  141. }
  142. }
  143. Ok(())
  144. }
  145. /// CIMD §4.1: every shared-secret and mTLS method is rejected; only `none`
  146. /// and `private_key_jwt` are registration-free-safe.
  147. #[test]
  148. fn cimd_rejects_shared_secret_method() -> TestResult {
  149. let enabled = scopes();
  150. let client_id = "https://client.example/cimd.json";
  151. for method in [
  152. "client_secret_basic",
  153. "client_secret_post",
  154. "client_secret_jwt",
  155. "tls_client_auth",
  156. "self_signed_tls_client_auth",
  157. ] {
  158. let mut value = document_value(client_id, &["https://client.example/cb"]);
  159. value["token_endpoint_auth_method"] = json!(method);
  160. match expect_err(client_id, value, &enabled, false)? {
  161. CimdError::UnsupportedAuthMethod => {}
  162. other => {
  163. return Err(TestError::Unexpected(format!(
  164. "{method}: wrong error {other}"
  165. )));
  166. }
  167. }
  168. }
  169. Ok(())
  170. }
  171. /// `private_key_jwt` requires an INLINE `jwks`; a `jwks_uri`-only document is
  172. /// rejected so no second fetch ever happens on the auth path.
  173. #[test]
  174. fn cimd_rejects_jwks_uri_only() -> TestResult {
  175. let enabled = scopes();
  176. let client_id = "https://client.example/cimd.json";
  177. let mut remote = document_value(client_id, &["https://client.example/cb"]);
  178. remote["token_endpoint_auth_method"] = json!("private_key_jwt");
  179. remote["jwks_uri"] = json!("https://client.example/jwks.json");
  180. match expect_err(client_id, remote, &enabled, false)? {
  181. CimdError::JwksUriNotAllowed => {}
  182. other => {
  183. return Err(TestError::Unexpected(format!(
  184. "jwks_uri-only: wrong error {other}"
  185. )));
  186. }
  187. }
  188. let mut missing = document_value(client_id, &["https://client.example/cb"]);
  189. missing["token_endpoint_auth_method"] = json!("private_key_jwt");
  190. match expect_err(client_id, missing, &enabled, false)? {
  191. CimdError::JwksRequired => {}
  192. other => {
  193. return Err(TestError::Unexpected(format!(
  194. "no jwks: wrong error {other}"
  195. )));
  196. }
  197. }
  198. let mut inline = document_value(client_id, &["https://client.example/cb"]);
  199. inline["token_endpoint_auth_method"] = json!("private_key_jwt");
  200. inline["jwks"] = json!({ "keys": [{ "kty": "EC", "crv": "P-256", "x": "x", "y": "y" }] });
  201. let validated = expect_ok(client_id, inline, &enabled, false)?;
  202. if validated.token_endpoint_auth_method != CimdAuthMethod::PrivateKeyJwt {
  203. return Err(TestError::Unexpected(
  204. "inline jwks: wrong auth method".to_owned(),
  205. ));
  206. }
  207. Ok(())
  208. }
  209. /// Plaintext `http` is accepted only for a loopback host; a non-loopback host
  210. /// (and `localhost` with the flag off) is rejected while `https` passes.
  211. #[test]
  212. fn cimd_rejects_non_loopback_http() -> TestResult {
  213. let enabled = scopes();
  214. let client_id = "https://client.example/cimd.json";
  215. for uri in ["http://example.com/cb", "http://localhost/cb"] {
  216. match expect_err(
  217. client_id,
  218. document_value(client_id, &[uri]),
  219. &enabled,
  220. false,
  221. )? {
  222. CimdError::InvalidRedirectUri => {}
  223. other => {
  224. return Err(TestError::Unexpected(format!("{uri}: wrong error {other}")));
  225. }
  226. }
  227. }
  228. // https is always acceptable, and a loopback literal is acceptable even
  229. // with the `localhost` flag off.
  230. for uri in [
  231. "https://client.example/cb",
  232. "http://127.0.0.1/cb",
  233. "http://[::1]/cb",
  234. ] {
  235. expect_ok(
  236. client_id,
  237. document_value(client_id, &[uri]),
  238. &enabled,
  239. false,
  240. )?;
  241. }
  242. Ok(())
  243. }
  244. /// RFC 8252 §7.3 loopback: plaintext `http` to `127.0.0.1`/`[::1]` is always
  245. /// accepted; the exact host `localhost` only while `allow_localhost` is on.
  246. #[test]
  247. fn cimd_accepts_loopback_redirect() -> TestResult {
  248. let enabled = scopes();
  249. let client_id = "https://client.example/cimd.json";
  250. for uri in [
  251. "http://127.0.0.1/cb",
  252. "http://127.0.0.1:8080/cb",
  253. "http://[::1]/cb",
  254. "http://[::1]:9000/cb",
  255. "http://localhost/cb",
  256. "http://localhost:3000/cb",
  257. ] {
  258. expect_ok(client_id, document_value(client_id, &[uri]), &enabled, true)?;
  259. }
  260. for uri in ["http://localhost/cb", "http://localhost:3000/cb"] {
  261. match expect_err(
  262. client_id,
  263. document_value(client_id, &[uri]),
  264. &enabled,
  265. false,
  266. )? {
  267. CimdError::InvalidRedirectUri => {}
  268. other => {
  269. return Err(TestError::Unexpected(format!("{uri}: wrong error {other}")));
  270. }
  271. }
  272. }
  273. Ok(())
  274. }
  275. /// The document must echo the fetched URL exactly.
  276. #[test]
  277. fn cimd_rejects_client_id_mismatch() -> TestResult {
  278. let enabled = scopes();
  279. let fetched = "https://client.example/cimd.json";
  280. let document = document_value(
  281. "https://other.example/cimd.json",
  282. &["https://other.example/cb"],
  283. );
  284. match expect_err(fetched, document, &enabled, false)? {
  285. CimdError::ClientIdMismatch => Ok(()),
  286. other => Err(TestError::Unexpected(format!("wrong error {other}"))),
  287. }
  288. }
  289. /// A validated document is cached under `{state}` `cimd:<sha256>` for 15
  290. /// minutes; `resolve` serves the cached copy without fetching.
  291. #[tokio::test]
  292. async fn cimd_cache_round_trip() -> TestResult {
  293. let manager = crate::test_support::redis_manager()
  294. .await
  295. .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
  296. let mut conn = RedisConn::Single(manager);
  297. let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
  298. // `.invalid` never resolves, so a cache miss would fail the fetch.
  299. let url = "https://client.invalid/cimd.json";
  300. let document = parse(document_value(url, &["https://client.invalid/cb"]))?;
  301. let key = cache_key(&prefix, url);
  302. if !key.starts_with(&prefix) || !key.contains("{state}") || !key.contains("cimd:") {
  303. return Err(TestError::Unexpected(format!("bad cache key {key}")));
  304. }
  305. put_cached(&mut conn, &key, &document)
  306. .await
  307. .map_err(|error| TestError::Unexpected(format!("cache write: {error}")))?;
  308. match get_cached(&mut conn, &key)
  309. .await
  310. .map_err(|error| TestError::Unexpected(format!("cache read: {error}")))?
  311. {
  312. Some(cached) if cached.client_id == url => {}
  313. Some(_) => return Err(TestError::Unexpected("cached wrong document".to_owned())),
  314. None => return Err(TestError::Unexpected("cache miss".to_owned())),
  315. }
  316. let enabled = scopes();
  317. let fetcher = ssrf_fetcher();
  318. let resolved = resolve(
  319. &mut conn,
  320. &prefix,
  321. url,
  322. &CimdValidation {
  323. enabled_scopes: &enabled,
  324. allow_localhost: false,
  325. },
  326. CimdLimits {
  327. max_bytes: 5_120,
  328. timeout: Duration::from_secs(1),
  329. },
  330. &fetcher,
  331. )
  332. .await
  333. .map_err(|error| TestError::Unexpected(format!("resolve: {error}")))?;
  334. if resolved.client_id != url {
  335. return Err(TestError::Unexpected("resolved wrong client".to_owned()));
  336. }
  337. Ok(())
  338. }
  339. /// A seeded negative key fails fast with `Fetch` before any fetch: even a
  340. /// valid cached document is shadowed while the negative holds, and the raw
  341. /// URL never appears in the key.
  342. #[tokio::test]
  343. async fn cimd_negative_cache_blocks_fetch() -> TestResult {
  344. let manager = crate::test_support::redis_manager()
  345. .await
  346. .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
  347. let mut conn = RedisConn::Single(manager);
  348. let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
  349. // `.invalid` can never fetch, so any fetch attempt would be observable as
  350. // DNS work; the seeded negative must short-circuit before that.
  351. let url = "https://client.invalid/neg-blocked.json";
  352. let document = parse(document_value(url, &["https://client.invalid/cb"]))?;
  353. let key = cache_key(&prefix, url);
  354. put_cached(&mut conn, &key, &document)
  355. .await
  356. .map_err(|error| TestError::Unexpected(format!("cache write: {error}")))?;
  357. let neg = negative_cache_key(&prefix, url);
  358. if !neg.starts_with(&prefix) || !neg.contains("cimd:neg:") || neg.contains("client.invalid") {
  359. return Err(TestError::Unexpected(format!("bad negative key {neg}")));
  360. }
  361. redis::cmd("SET")
  362. .arg(&neg)
  363. .arg("1")
  364. .arg("EX")
  365. .arg(30)
  366. .query_async::<()>(&mut conn)
  367. .await
  368. .map_err(|error| TestError::Unexpected(format!("negative seed: {error}")))?;
  369. let enabled = scopes();
  370. let fetcher = ssrf_fetcher();
  371. match resolve(
  372. &mut conn,
  373. &prefix,
  374. url,
  375. &CimdValidation {
  376. enabled_scopes: &enabled,
  377. allow_localhost: false,
  378. },
  379. CimdLimits {
  380. max_bytes: 5_120,
  381. timeout: Duration::from_secs(1),
  382. },
  383. &fetcher,
  384. )
  385. .await
  386. {
  387. Err(CimdError::Fetch) => {}
  388. Err(other) => {
  389. return Err(TestError::Unexpected(format!(
  390. "negative hit must be Fetch, got {other}"
  391. )));
  392. }
  393. Ok(_) => {
  394. return Err(TestError::Unexpected(
  395. "negative cache must block even a cached document".to_owned(),
  396. ));
  397. }
  398. }
  399. // The valid positive entry survives the negative hit untouched.
  400. match get_cached(&mut conn, &key)
  401. .await
  402. .map_err(|error| TestError::Unexpected(format!("cache read: {error}")))?
  403. {
  404. Some(cached) if cached.client_id == url => Ok(()),
  405. _ => Err(TestError::Unexpected(
  406. "positive cache must survive a negative hit".to_owned(),
  407. )),
  408. }
  409. }
  410. /// An origin-level fetch failure writes the negative key (30 s TTL); a
  411. /// validation failure served from cache never does, so a fixed document is
  412. /// seen immediately.
  413. #[tokio::test]
  414. async fn cimd_fetch_failure_negatively_cached() -> TestResult {
  415. let manager = crate::test_support::redis_manager()
  416. .await
  417. .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
  418. let mut conn = RedisConn::Single(manager);
  419. let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
  420. let url = "https://client.invalid/neg-write.json";
  421. let enabled = scopes();
  422. let fetcher = ssrf_fetcher();
  423. match resolve(
  424. &mut conn,
  425. &prefix,
  426. url,
  427. &CimdValidation {
  428. enabled_scopes: &enabled,
  429. allow_localhost: false,
  430. },
  431. CimdLimits {
  432. max_bytes: 5_120,
  433. timeout: Duration::from_secs(2),
  434. },
  435. &fetcher,
  436. )
  437. .await
  438. {
  439. Err(CimdError::Fetch | CimdError::InvalidJson) => {}
  440. Err(other) => {
  441. return Err(TestError::Unexpected(format!(
  442. "unfetchable origin must fail fetch-side, got {other}"
  443. )));
  444. }
  445. Ok(_) => {
  446. return Err(TestError::Unexpected(
  447. "unfetchable origin resolved".to_owned(),
  448. ));
  449. }
  450. }
  451. let neg = negative_cache_key(&prefix, url);
  452. let ttl: i64 = redis::cmd("TTL")
  453. .arg(&neg)
  454. .query_async(&mut conn)
  455. .await
  456. .map_err(|error| TestError::Unexpected(format!("negative ttl: {error}")))?;
  457. if ttl <= 0 || ttl > 30 {
  458. return Err(TestError::Unexpected(format!(
  459. "negative key must carry a 30 s TTL, got {ttl}"
  460. )));
  461. }
  462. Ok(())
  463. }
  464. /// A validation failure served from cache is not negatively cached (no fetch
  465. /// occurred); only a fresh-fetch validation failure writes the 2 s
  466. /// `cimd:invalid:` completion marker. The cached document echoes another URL,
  467. /// so `resolve` reports `ClientIdMismatch` and leaves no negative key behind.
  468. #[tokio::test]
  469. async fn cimd_validation_failure_not_negatively_cached() -> TestResult {
  470. let manager = crate::test_support::redis_manager()
  471. .await
  472. .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
  473. let mut conn = RedisConn::Single(manager);
  474. let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
  475. let url = "https://client.invalid/neg-nocache.json";
  476. let wrong = parse(document_value(
  477. "https://other.invalid/cimd.json",
  478. &["https://other.invalid/cb"],
  479. ))?;
  480. let key = cache_key(&prefix, url);
  481. put_cached(&mut conn, &key, &wrong)
  482. .await
  483. .map_err(|error| TestError::Unexpected(format!("cache write: {error}")))?;
  484. let enabled = scopes();
  485. let fetcher = ssrf_fetcher();
  486. match resolve(
  487. &mut conn,
  488. &prefix,
  489. url,
  490. &CimdValidation {
  491. enabled_scopes: &enabled,
  492. allow_localhost: false,
  493. },
  494. CimdLimits {
  495. max_bytes: 5_120,
  496. timeout: Duration::from_secs(1),
  497. },
  498. &fetcher,
  499. )
  500. .await
  501. {
  502. Err(CimdError::ClientIdMismatch) => {}
  503. Err(other) => {
  504. return Err(TestError::Unexpected(format!(
  505. "mismatched cache hit must be ClientIdMismatch, got {other}"
  506. )));
  507. }
  508. Ok(_) => {
  509. return Err(TestError::Unexpected(
  510. "mismatched cached document was accepted".to_owned(),
  511. ));
  512. }
  513. }
  514. let neg = negative_cache_key(&prefix, url);
  515. let present: bool = redis::cmd("EXISTS")
  516. .arg(&neg)
  517. .query_async(&mut conn)
  518. .await
  519. .map_err(|error| TestError::Unexpected(format!("negative read: {error}")))?;
  520. if present {
  521. return Err(TestError::Unexpected(
  522. "validation failure must not be negatively cached".to_owned(),
  523. ));
  524. }
  525. Ok(())
  526. }
  527. /// The injected fetcher serves the single fetch: its failure is the resolve
  528. /// failure, and the negative cache blocks a second call (the count stays 1).
  529. #[tokio::test]
  530. async fn cimd_injected_fetcher_failure_is_negatively_cached() -> TestResult {
  531. let manager = crate::test_support::redis_manager()
  532. .await
  533. .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
  534. let mut conn = RedisConn::Single(manager);
  535. let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
  536. let url = "https://client.invalid/injected-fetch.json";
  537. let calls = Arc::new(AtomicUsize::new(0));
  538. let counting = Arc::clone(&calls);
  539. let fetcher: CimdFetcher = Arc::new(move |_url: &str, _limits: CimdLimits| {
  540. counting.fetch_add(1, Ordering::SeqCst);
  541. boxed(async { Err::<CimdDocument, _>(CimdError::Fetch) })
  542. });
  543. let enabled = scopes();
  544. let validation = CimdValidation {
  545. enabled_scopes: &enabled,
  546. allow_localhost: false,
  547. };
  548. let limits = CimdLimits {
  549. max_bytes: 5_120,
  550. timeout: Duration::from_secs(1),
  551. };
  552. for _ in 0..2 {
  553. match resolve(&mut conn, &prefix, url, &validation, limits, &fetcher).await {
  554. Err(CimdError::Fetch) => {}
  555. Err(other) => {
  556. return Err(TestError::Unexpected(format!(
  557. "injected failure must be Fetch, got {other}"
  558. )));
  559. }
  560. Ok(_) => {
  561. return Err(TestError::Unexpected(
  562. "injected failure resolved".to_owned(),
  563. ));
  564. }
  565. }
  566. }
  567. if calls.load(Ordering::SeqCst) != 1 {
  568. return Err(TestError::Unexpected(format!(
  569. "negative cache must block the second fetch, saw {} calls",
  570. calls.load(Ordering::SeqCst)
  571. )));
  572. }
  573. Ok(())
  574. }
  575. /// The single-flight lock is exclusive, ignores a release with the wrong
  576. /// token, and can be reacquired after the owner releases it.
  577. #[tokio::test]
  578. async fn cimd_single_flight_lock_is_exclusive() -> TestResult {
  579. let manager = crate::test_support::redis_manager()
  580. .await
  581. .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
  582. let mut conn = RedisConn::Single(manager);
  583. let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
  584. let key = lock_key(&prefix, "https://client.invalid/lock.json");
  585. let ttl = 3_000;
  586. let Some(token) = acquire_lock(&mut conn, &key, ttl)
  587. .await
  588. .map_err(|error| TestError::Unexpected(format!("acquire: {error}")))?
  589. else {
  590. return Err(TestError::Unexpected("first acquire must win".to_owned()));
  591. };
  592. if acquire_lock(&mut conn, &key, ttl)
  593. .await
  594. .map_err(|error| TestError::Unexpected(format!("second acquire: {error}")))?
  595. .is_some()
  596. {
  597. return Err(TestError::Unexpected(
  598. "a held lock must not be acquired twice".to_owned(),
  599. ));
  600. }
  601. release_lock(&mut conn, &key, "wrong-token")
  602. .await
  603. .map_err(|error| TestError::Unexpected(format!("wrong release: {error}")))?;
  604. if acquire_lock(&mut conn, &key, ttl)
  605. .await
  606. .map_err(|error| TestError::Unexpected(format!("reacquire: {error}")))?
  607. .is_some()
  608. {
  609. return Err(TestError::Unexpected(
  610. "a wrong-token release must not drop the lock".to_owned(),
  611. ));
  612. }
  613. release_lock(&mut conn, &key, &token)
  614. .await
  615. .map_err(|error| TestError::Unexpected(format!("owner release: {error}")))?;
  616. let Some(next) = acquire_lock(&mut conn, &key, ttl)
  617. .await
  618. .map_err(|error| TestError::Unexpected(format!("final acquire: {error}")))?
  619. else {
  620. return Err(TestError::Unexpected(
  621. "the owner release must free the lock".to_owned(),
  622. ));
  623. };
  624. release_lock(&mut conn, &key, &next)
  625. .await
  626. .map_err(|error| TestError::Unexpected(format!("final release: {error}")))?;
  627. Ok(())
  628. }
  629. /// Two concurrent cache misses for the same uncached URL both return the
  630. /// document published by the elected fetcher. The `.invalid` host proves
  631. /// neither waiter started a fetch of its own.
  632. #[tokio::test]
  633. async fn cimd_single_flight_waiters_share_one_fetch() -> TestResult {
  634. let manager = crate::test_support::redis_manager()
  635. .await
  636. .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
  637. let mut holder = RedisConn::Single(manager.clone());
  638. let mut waiter_a = RedisConn::Single(manager.clone());
  639. let mut waiter_b = RedisConn::Single(manager);
  640. let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
  641. let url = "https://client.invalid/single-flight.json";
  642. let lock = lock_key(&prefix, url);
  643. let token = acquire_lock(&mut holder, &lock, 3_000)
  644. .await
  645. .map_err(|error| TestError::Unexpected(format!("lock: {error}")))?
  646. .ok_or_else(|| TestError::Unexpected("lock must be free".to_owned()))?;
  647. let key = cache_key(&prefix, url);
  648. let document = parse(document_value(url, &["https://client.invalid/cb"]))?;
  649. let enabled = scopes();
  650. let validation = CimdValidation {
  651. enabled_scopes: &enabled,
  652. allow_localhost: false,
  653. };
  654. let limits = CimdLimits {
  655. max_bytes: 5_120,
  656. timeout: Duration::from_secs(1),
  657. };
  658. let fetcher = ssrf_fetcher();
  659. let a = resolve(&mut waiter_a, &prefix, url, &validation, limits, &fetcher);
  660. let b = resolve(&mut waiter_b, &prefix, url, &validation, limits, &fetcher);
  661. let publish = async {
  662. tokio::time::sleep(Duration::from_millis(100)).await;
  663. put_cached(&mut holder, &key, &document)
  664. .await
  665. .map_err(|error| TestError::Unexpected(format!("cache write: {error}")))?;
  666. release_lock(&mut holder, &lock, &token)
  667. .await
  668. .map_err(|error| TestError::Unexpected(format!("release: {error}")))?;
  669. Ok::<(), TestError>(())
  670. };
  671. let (a_result, b_result, published) = tokio::join!(a, b, publish);
  672. published?;
  673. for (label, result) in [("a", a_result), ("b", b_result)] {
  674. match result {
  675. Ok(validated) if validated.client_id == url => {}
  676. Ok(validated) => {
  677. return Err(TestError::Unexpected(format!(
  678. "waiter {label} saw the wrong client: {}",
  679. validated.client_id
  680. )));
  681. }
  682. Err(error) => {
  683. return Err(TestError::Unexpected(format!(
  684. "waiter {label} rejected the published document: {error}"
  685. )));
  686. }
  687. }
  688. }
  689. Ok(())
  690. }
  691. /// A waiter observes the elected fetcher's negative cache and fails with the
  692. /// same fetch error instead of starting a second fetch.
  693. #[tokio::test]
  694. async fn cimd_single_flight_waiter_observes_negative() -> TestResult {
  695. let manager = crate::test_support::redis_manager()
  696. .await
  697. .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
  698. let mut holder = RedisConn::Single(manager.clone());
  699. let mut waiter = RedisConn::Single(manager);
  700. let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
  701. let url = "https://client.invalid/single-flight-neg.json";
  702. let lock = lock_key(&prefix, url);
  703. let token = acquire_lock(&mut holder, &lock, 3_000)
  704. .await
  705. .map_err(|error| TestError::Unexpected(format!("lock: {error}")))?
  706. .ok_or_else(|| TestError::Unexpected("lock must be free".to_owned()))?;
  707. let neg = negative_cache_key(&prefix, url);
  708. let enabled = scopes();
  709. let validation = CimdValidation {
  710. enabled_scopes: &enabled,
  711. allow_localhost: false,
  712. };
  713. let limits = CimdLimits {
  714. max_bytes: 5_120,
  715. timeout: Duration::from_secs(1),
  716. };
  717. let fetcher = ssrf_fetcher();
  718. let waiting = resolve(&mut waiter, &prefix, url, &validation, limits, &fetcher);
  719. let publish = async {
  720. tokio::time::sleep(Duration::from_millis(100)).await;
  721. redis::cmd("SET")
  722. .arg(&neg)
  723. .arg("1")
  724. .arg("EX")
  725. .arg(30)
  726. .query_async::<()>(&mut holder)
  727. .await
  728. .map_err(|error| TestError::Unexpected(format!("negative seed: {error}")))?;
  729. release_lock(&mut holder, &lock, &token)
  730. .await
  731. .map_err(|error| TestError::Unexpected(format!("release: {error}")))?;
  732. Ok::<(), TestError>(())
  733. };
  734. let (result, published) = tokio::join!(waiting, publish);
  735. published?;
  736. match result {
  737. Err(CimdError::Fetch) => Ok(()),
  738. Err(other) => Err(TestError::Unexpected(format!(
  739. "waiter must observe the elected fetch failure, got {other}"
  740. ))),
  741. Ok(_) => Err(TestError::Unexpected(
  742. "waiter accepted a token the elected fetcher rejected".to_owned(),
  743. )),
  744. }
  745. }
  746. /// A fresh-fetch validation failure is short-cached under `cimd:invalid:`
  747. /// with its class marker: a later resolve for the same URL fails fast with
  748. /// the original error class without a network fetch, and the marker TTL is
  749. /// the 2-second completion window.
  750. #[tokio::test]
  751. async fn cimd_invalid_cache_blocks_fetch() -> TestResult {
  752. let manager = crate::test_support::redis_manager()
  753. .await
  754. .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
  755. let mut conn = RedisConn::Single(manager);
  756. let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
  757. let enabled = scopes();
  758. for (marker, expected_redirect) in [("metadata", false), ("redirect", true)] {
  759. let url = format!("https://client.invalid/invalid-cached-{marker}.json");
  760. let key = invalid_cache_key(&prefix, &url);
  761. if !key.starts_with(&prefix) || !key.contains("cimd:invalid:") || key.contains("client.invalid")
  762. {
  763. return Err(TestError::Unexpected(format!("bad invalid key {key}")));
  764. }
  765. redis::cmd("SET")
  766. .arg(&key)
  767. .arg(marker)
  768. .arg("EX")
  769. .arg(2)
  770. .query_async::<()>(&mut conn)
  771. .await
  772. .map_err(|error| TestError::Unexpected(format!("invalid seed: {error}")))?;
  773. match resolve(
  774. &mut conn,
  775. &prefix,
  776. &url,
  777. &CimdValidation {
  778. enabled_scopes: &enabled,
  779. allow_localhost: false,
  780. },
  781. CimdLimits {
  782. max_bytes: 5_120,
  783. timeout: Duration::from_secs(1),
  784. },
  785. &ssrf_fetcher(),
  786. )
  787. .await
  788. {
  789. Err(CimdError::InvalidRedirectUri) if expected_redirect => {}
  790. Err(CimdError::InvalidJson) if !expected_redirect => {}
  791. Err(other) => {
  792. return Err(TestError::Unexpected(format!(
  793. "{marker}-cache hit must replay its class, got {other}"
  794. )));
  795. }
  796. Ok(_) => {
  797. return Err(TestError::Unexpected(
  798. "invalid-cache hit fetched or accepted a document".to_owned(),
  799. ));
  800. }
  801. }
  802. let ttl: i64 = redis::cmd("TTL")
  803. .arg(&key)
  804. .query_async(&mut conn)
  805. .await
  806. .map_err(|error| TestError::Unexpected(format!("invalid ttl: {error}")))?;
  807. if ttl <= 0 || ttl > 2 {
  808. return Err(TestError::Unexpected(format!(
  809. "invalid key must carry a 2 s TTL, got {ttl}"
  810. )));
  811. }
  812. }
  813. Ok(())
  814. }