tests.rs 24 KB

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