1use anyhow::{Context, Result, bail};
2use chrono::Utc;
3use reqwest::StatusCode;
4use reqwest::blocking::Client;
5use serde::Deserialize;
6use std::time::{Duration, Instant};
7
8use shipper_types::{ReadinessConfig, ReadinessEvidence, ReadinessMethod, Registry};
9
10#[derive(Debug, Clone)]
11pub struct RegistryClient {
12 registry: Registry,
13 http: Client,
14 cache_dir: Option<std::path::PathBuf>,
15}
16
17impl RegistryClient {
18 pub fn new(registry: Registry) -> Result<Self> {
19 let http = Client::builder()
20 .user_agent(format!("shipper/{}", env!("CARGO_PKG_VERSION")))
21 .build()
22 .context("failed to build HTTP client")?;
23
24 Ok(Self {
25 registry,
26 http,
27 cache_dir: None,
28 })
29 }
30
31 pub fn with_cache_dir(mut self, cache_dir: std::path::PathBuf) -> Self {
33 self.cache_dir = Some(cache_dir);
34 self
35 }
36
37 pub fn registry(&self) -> &Registry {
38 &self.registry
39 }
40
41 pub fn version_exists(&self, crate_name: &str, version: &str) -> Result<bool> {
42 let url = format!(
43 "{}/api/v1/crates/{}/{}",
44 self.registry.api_base.trim_end_matches('/'),
45 crate_name,
46 version
47 );
48
49 let resp = self
50 .http
51 .get(url)
52 .send()
53 .context("registry request failed")?;
54 match resp.status() {
55 StatusCode::OK => Ok(true),
56 StatusCode::NOT_FOUND => Ok(false),
57 s => bail!("unexpected status while checking version existence: {s}"),
58 }
59 }
60
61 pub fn crate_exists(&self, crate_name: &str) -> Result<bool> {
62 let url = format!(
63 "{}/api/v1/crates/{}",
64 self.registry.api_base.trim_end_matches('/'),
65 crate_name
66 );
67
68 let resp = self
69 .http
70 .get(url)
71 .send()
72 .context("registry request failed")?;
73 match resp.status() {
74 StatusCode::OK => Ok(true),
75 StatusCode::NOT_FOUND => Ok(false),
76 s => bail!("unexpected status while checking crate existence: {s}"),
77 }
78 }
79
80 pub fn list_owners(&self, crate_name: &str, token: &str) -> Result<OwnersResponse> {
81 let url = format!(
82 "{}/api/v1/crates/{}/owners",
83 self.registry.api_base.trim_end_matches('/'),
84 crate_name
85 );
86
87 let resp = self
88 .http
89 .get(url)
90 .header("Authorization", token)
91 .send()
92 .context("registry owners request failed")?;
93
94 match resp.status() {
95 StatusCode::OK => {
96 let parsed: OwnersResponse = resp.json().context("failed to parse owners JSON")?;
97 Ok(parsed)
98 }
99 StatusCode::NOT_FOUND => bail!("crate not found when querying owners: {crate_name}"),
100 StatusCode::FORBIDDEN => bail!(
101 "forbidden when querying owners; token may be invalid or missing required scope"
102 ),
103 s => bail!("unexpected status while querying owners: {s}"),
104 }
105 }
106
107 pub fn check_new_crate(&self, crate_name: &str) -> Result<bool> {
111 let exists = self.crate_exists(crate_name)?;
112 Ok(!exists)
113 }
114
115 pub fn check_index_visibility(&self, crate_name: &str, version: &str) -> Result<bool> {
120 let index_path = self.calculate_index_path(crate_name);
122
123 let content = match self.fetch_index_file(&index_path) {
125 Ok(content) => content,
126 Err(_e) => {
127 return Ok(false);
131 }
132 };
133
134 match self.parse_version_from_index(&content, version) {
136 Ok(found) => Ok(found),
137 Err(_) => {
138 Ok(false)
140 }
141 }
142 }
143
144 fn calculate_index_path(&self, crate_name: &str) -> String {
153 shipper_sparse_index::sparse_index_path(crate_name)
154 }
155
156 fn fetch_index_file(&self, index_path: &str) -> Result<String> {
158 let index_base = self.registry.get_index_base();
159 let url = format!("{}/{}", index_base.trim_end_matches('/'), index_path);
160
161 let cache_file = self.cache_dir.as_ref().map(|d| d.join(index_path));
162 let etag_file = cache_file.as_ref().map(|f| f.with_extension("etag"));
163
164 let mut request = self.http.get(&url);
165
166 if let Some(ref path) = etag_file
167 && let Ok(etag) = std::fs::read_to_string(path)
168 {
169 request = request.header(reqwest::header::IF_NONE_MATCH, etag.trim());
170 }
171
172 let resp = request.send().context("index request failed")?;
173
174 match resp.status() {
175 StatusCode::OK => {
176 let etag = resp
177 .headers()
178 .get(reqwest::header::ETAG)
179 .and_then(|h| h.to_str().ok())
180 .map(|s| s.to_string());
181 let content = resp.text().context("failed to read index response body")?;
182
183 if let Some(ref path) = cache_file {
184 if let Some(parent) = path.parent() {
185 let _ = std::fs::create_dir_all(parent);
186 }
187 let _ = std::fs::write(path, &content);
188 if let (Some(ref etag_val), Some(etag_path)) = (etag, etag_file) {
189 let _ = std::fs::write(etag_path, etag_val);
190 }
191 }
192 Ok(content)
193 }
194 StatusCode::NOT_MODIFIED => {
195 if let Some(ref path) = cache_file {
196 std::fs::read_to_string(path).context("failed to read cached index file")
197 } else {
198 bail!("received 304 Not Modified but no cache file available")
199 }
200 }
201 StatusCode::NOT_FOUND => {
202 bail!("index file not found: {}", url)
204 }
205 s => bail!("unexpected status while fetching index: {}", s),
206 }
207 }
208
209 fn parse_version_from_index(&self, content: &str, version: &str) -> Result<bool> {
211 Ok(shipper_sparse_index::contains_version(content, version))
212 }
213
214 pub fn verify_ownership(&self, crate_name: &str, token: &str) -> Result<bool> {
220 match self.list_owners(crate_name, token) {
221 Ok(_) => Ok(true),
222 Err(e) => {
223 let msg = format!("{e:#}");
226 if msg.contains("forbidden")
227 || msg.contains("403")
228 || msg.contains("unauthorized")
229 || msg.contains("401")
230 || msg.contains("not found")
231 || msg.contains("404")
232 {
233 Ok(false)
234 } else {
235 Err(e)
236 }
237 }
238 }
239 }
240
241 pub fn is_version_visible_with_backoff(
246 &self,
247 crate_name: &str,
248 version: &str,
249 config: &ReadinessConfig,
250 ) -> Result<(bool, Vec<ReadinessEvidence>)> {
251 let mut evidence = Vec::new();
252
253 if !config.enabled {
254 let visible = self.version_exists(crate_name, version)?;
256 evidence.push(ReadinessEvidence {
257 attempt: 1,
258 visible,
259 timestamp: Utc::now(),
260 delay_before: Duration::ZERO,
261 });
262 return Ok((visible, evidence));
263 }
264
265 let start = Instant::now();
266 let mut attempt: u32 = 0;
267
268 if config.initial_delay > Duration::ZERO {
270 std::thread::sleep(config.initial_delay);
271 }
272
273 loop {
274 attempt += 1;
275
276 let jittered_delay = if attempt == 1 {
278 Duration::ZERO
279 } else {
280 let base_delay = config.poll_interval;
281 let exponential_delay = base_delay
282 .saturating_mul(2_u32.saturating_pow(attempt.saturating_sub(2).min(16)));
283 let capped_delay = exponential_delay.min(config.max_delay);
284 let jitter_range = config.jitter_factor;
285 let jitter = 1.0 + (rand::random::<f64>() * 2.0 * jitter_range - jitter_range);
286 Duration::from_millis((capped_delay.as_millis() as f64 * jitter).round() as u64)
287 };
288
289 let visible = match config.method {
292 ReadinessMethod::Api => self.version_exists(crate_name, version).unwrap_or(false),
293 ReadinessMethod::Index => self
294 .check_index_visibility(crate_name, version)
295 .unwrap_or(false),
296 ReadinessMethod::Both => {
297 if config.prefer_index {
298 match self.check_index_visibility(crate_name, version) {
299 Ok(true) => true,
300 _ => self.version_exists(crate_name, version).unwrap_or(false),
301 }
302 } else {
303 match self.version_exists(crate_name, version) {
304 Ok(true) => true,
305 _ => self
306 .check_index_visibility(crate_name, version)
307 .unwrap_or(false),
308 }
309 }
310 }
311 };
312
313 evidence.push(ReadinessEvidence {
314 attempt,
315 visible,
316 timestamp: Utc::now(),
317 delay_before: jittered_delay,
318 });
319
320 if visible {
321 return Ok((true, evidence));
322 }
323
324 if start.elapsed() >= config.max_total_wait {
326 return Ok((false, evidence));
327 }
328
329 let base_delay = config.poll_interval;
331 let exponential_delay =
332 base_delay.saturating_mul(2_u32.saturating_pow(attempt.saturating_sub(1).min(16)));
333 let capped_delay = exponential_delay.min(config.max_delay);
334
335 let jitter_range = config.jitter_factor;
336 let jitter = 1.0 + (rand::random::<f64>() * 2.0 * jitter_range - jitter_range);
337 let next_delay =
338 Duration::from_millis((capped_delay.as_millis() as f64 * jitter).round() as u64);
339
340 std::thread::sleep(next_delay);
341 }
342 }
343
344 pub fn calculate_backoff_delay(
348 &self,
349 base: Duration,
350 max: Duration,
351 attempt: u32,
352 jitter_factor: f64,
353 ) -> Duration {
354 let pow = attempt.saturating_sub(1).min(16);
355 let mut delay = base.saturating_mul(2_u32.saturating_pow(pow));
356 if delay > max {
357 delay = max;
358 }
359
360 let jitter = 1.0 + (rand::random::<f64>() * 2.0 * jitter_factor - jitter_factor);
363 let millis = (delay.as_millis() as f64 * jitter).round() as u128;
364 Duration::from_millis(millis as u64)
365 }
366}
367
368#[derive(Debug, Deserialize)]
369pub struct OwnersResponse {
370 pub users: Vec<Owner>,
371}
372
373#[derive(Debug, Deserialize)]
374pub struct Owner {
375 pub id: u64,
376 pub login: String,
377 pub name: Option<String>,
378}
379
380#[cfg(test)]
381mod tests {
382 use std::thread;
383
384 use tiny_http::{Response, Server, StatusCode};
385
386 use super::*;
387
388 fn with_server<F>(handler: F) -> (String, thread::JoinHandle<()>)
389 where
390 F: FnOnce(tiny_http::Request) + Send + 'static,
391 {
392 let server = Server::http("127.0.0.1:0").expect("server");
393 let addr = format!("http://{}", server.server_addr());
394 let handle = thread::spawn(move || {
395 let req = server.recv().expect("request");
396 handler(req);
397 });
398 (addr, handle)
399 }
400
401 fn test_registry(api_base: String) -> Registry {
402 Registry {
403 name: "crates-io".to_string(),
404 api_base,
405 index_base: None,
406 }
407 }
408
409 fn test_registry_with_index(api_base: String) -> Registry {
410 Registry {
411 name: "crates-io".to_string(),
412 api_base: api_base.clone(),
413 index_base: Some(api_base),
414 }
415 }
416
417 fn with_multi_server<F>(handler: F, request_count: usize) -> (String, thread::JoinHandle<()>)
418 where
419 F: Fn(tiny_http::Request) + Send + 'static,
420 {
421 let server = Server::http("127.0.0.1:0").expect("server");
422 let addr = format!("http://{}", server.server_addr());
423 let handle = thread::spawn(move || {
424 for _ in 0..request_count {
425 match server.recv_timeout(Duration::from_secs(30)) {
426 Ok(Some(req)) => handler(req),
427 _ => break,
428 }
429 }
430 });
431 (addr, handle)
432 }
433
434 #[test]
435 fn version_exists_true_for_200() {
436 let (api_base, handle) = with_server(|req| {
437 assert_eq!(req.url(), "/api/v1/crates/demo/1.2.3");
438 req.respond(Response::empty(StatusCode(200)))
439 .expect("respond");
440 });
441
442 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
443 assert_eq!(cli.registry().name, "crates-io");
444 let exists = cli.version_exists("demo", "1.2.3").expect("exists");
445 assert!(exists);
446 handle.join().expect("join");
447 }
448
449 #[test]
450 fn version_exists_false_for_404() {
451 let (api_base, handle) = with_server(|req| {
452 req.respond(Response::empty(StatusCode(404)))
453 .expect("respond");
454 });
455
456 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
457 let exists = cli.version_exists("demo", "1.2.3").expect("exists");
458 assert!(!exists);
459 handle.join().expect("join");
460 }
461
462 #[test]
463 fn version_exists_errors_for_unexpected_status() {
464 let (api_base, handle) = with_server(|req| {
465 req.respond(Response::empty(StatusCode(500)))
466 .expect("respond");
467 });
468
469 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
470 let err = cli
471 .version_exists("demo", "1.2.3")
472 .expect_err("unexpected status must fail");
473 assert!(format!("{err:#}").contains("unexpected status while checking version existence"));
474 handle.join().expect("join");
475 }
476
477 #[test]
478 fn crate_exists_true_for_200() {
479 let (api_base, handle) = with_server(|req| {
480 assert_eq!(req.url(), "/api/v1/crates/demo");
481 req.respond(Response::empty(StatusCode(200)))
482 .expect("respond");
483 });
484
485 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
486 let exists = cli.crate_exists("demo").expect("exists");
487 assert!(exists);
488 handle.join().expect("join");
489 }
490
491 #[test]
492 fn crate_exists_false_for_404() {
493 let (api_base, handle) = with_server(|req| {
494 req.respond(Response::empty(StatusCode(404)))
495 .expect("respond");
496 });
497
498 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
499 let exists = cli.crate_exists("demo").expect("exists");
500 assert!(!exists);
501 handle.join().expect("join");
502 }
503
504 #[test]
505 fn crate_exists_errors_for_unexpected_status() {
506 let (api_base, handle) = with_server(|req| {
507 req.respond(Response::empty(StatusCode(500)))
508 .expect("respond");
509 });
510
511 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
512 let err = cli
513 .crate_exists("demo")
514 .expect_err("unexpected status must fail");
515 assert!(format!("{err:#}").contains("unexpected status while checking crate existence"));
516 handle.join().expect("join");
517 }
518
519 #[test]
520 fn list_owners_parses_success_response() {
521 let (api_base, handle) = with_server(|req| {
522 assert_eq!(req.url(), "/api/v1/crates/demo/owners");
523 let auth = req
524 .headers()
525 .iter()
526 .find(|h| h.field.equiv("Authorization"))
527 .map(|h| h.value.as_str().to_string());
528 assert_eq!(auth.as_deref(), Some("token-abc"));
529
530 let body = r#"{"users":[{"id":7,"login":"alice","name":"Alice"}]}"#;
531 let resp = Response::from_string(body)
532 .with_status_code(StatusCode(200))
533 .with_header(
534 tiny_http::Header::from_bytes("Content-Type", "application/json")
535 .expect("header"),
536 );
537 req.respond(resp).expect("respond");
538 });
539
540 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
541 let owners = cli.list_owners("demo", "token-abc").expect("owners");
542 assert_eq!(owners.users.len(), 1);
543 assert_eq!(owners.users[0].login, "alice");
544 handle.join().expect("join");
545 }
546
547 #[test]
548 fn list_owners_errors_for_404_403_and_other_statuses() {
549 let (api_base_404, h1) = with_server(|req| {
550 req.respond(Response::empty(StatusCode(404)))
551 .expect("respond");
552 });
553 let cli_404 = RegistryClient::new(test_registry(api_base_404)).expect("client");
554 let err_404 = cli_404
555 .list_owners("missing", "token")
556 .expect_err("404 must fail");
557 assert!(format!("{err_404:#}").contains("crate not found when querying owners"));
558 h1.join().expect("join");
559
560 let (api_base_403, h2) = with_server(|req| {
561 req.respond(Response::empty(StatusCode(403)))
562 .expect("respond");
563 });
564 let cli_403 = RegistryClient::new(test_registry(api_base_403)).expect("client");
565 let err_403 = cli_403
566 .list_owners("demo", "token")
567 .expect_err("403 must fail");
568 assert!(format!("{err_403:#}").contains("forbidden when querying owners"));
569 h2.join().expect("join");
570
571 let (api_base_500, h3) = with_server(|req| {
572 req.respond(Response::empty(StatusCode(500)))
573 .expect("respond");
574 });
575 let cli_500 = RegistryClient::new(test_registry(api_base_500)).expect("client");
576 let err_500 = cli_500
577 .list_owners("demo", "token")
578 .expect_err("500 must fail");
579 assert!(format!("{err_500:#}").contains("unexpected status while querying owners"));
580 h3.join().expect("join");
581 }
582
583 #[test]
584 fn calculate_backoff_delay_is_bounded_with_jitter() {
585 let (api_base, _handle) = with_server(|req| {
586 req.respond(Response::empty(StatusCode(200)))
587 .expect("respond");
588 });
589
590 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
591 let base = Duration::from_millis(100);
592 let max = Duration::from_millis(500);
593 let jitter_factor = 0.5;
594
595 let d1 = cli.calculate_backoff_delay(base, max, 1, jitter_factor);
597 assert!(d1 >= Duration::from_millis(50));
599 assert!(d1 <= Duration::from_millis(150));
600
601 let d20 = cli.calculate_backoff_delay(base, max, 20, jitter_factor);
603 assert!(d20 >= Duration::from_millis(250));
605 assert!(d20 <= Duration::from_millis(750));
606
607 let d_no_jitter = cli.calculate_backoff_delay(base, max, 2, 0.0);
609 assert_eq!(d_no_jitter, Duration::from_millis(200));
611 }
612
613 #[test]
614 fn is_version_visible_with_backoff_disabled_returns_immediate() {
615 let (api_base, handle) = with_server(|req| {
616 req.respond(Response::empty(StatusCode(200)))
617 .expect("respond");
618 });
619
620 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
621 let config = ReadinessConfig {
622 enabled: false,
623 method: ReadinessMethod::Api,
624 initial_delay: Duration::from_secs(10),
625 max_delay: Duration::from_secs(60),
626 max_total_wait: Duration::from_secs(300),
627 poll_interval: Duration::from_secs(2),
628 jitter_factor: 0.5,
629 index_path: None,
630 prefer_index: false,
631 };
632
633 let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
634 assert!(result.is_ok());
635 let (visible, evidence) = result.unwrap();
636 assert!(visible);
637 assert_eq!(evidence.len(), 1);
638 assert!(evidence[0].visible);
639 handle.join().expect("join");
640 }
641
642 #[test]
645 fn calculate_index_path_for_standard_crate() {
646 let (api_base, _handle) = with_server(|req| {
647 req.respond(Response::empty(StatusCode(200)))
648 .expect("respond");
649 });
650
651 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
652
653 assert_eq!(cli.calculate_index_path("serde"), "se/rd/serde");
655 assert_eq!(cli.calculate_index_path("tokio"), "to/ki/tokio");
656 assert_eq!(cli.calculate_index_path("rand"), "ra/nd/rand");
657 assert_eq!(cli.calculate_index_path("http"), "ht/tp/http");
658 }
659
660 #[test]
661 fn calculate_index_path_for_short_crate() {
662 let (api_base, _handle) = with_server(|req| {
663 req.respond(Response::empty(StatusCode(200)))
664 .expect("respond");
665 });
666
667 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
668
669 assert_eq!(cli.calculate_index_path("a"), "1/a");
671
672 assert_eq!(cli.calculate_index_path("ab"), "2/ab");
674
675 assert_eq!(cli.calculate_index_path("abc"), "3/a/abc");
677 }
678
679 #[test]
680 fn calculate_index_path_for_special_chars() {
681 let (api_base, _handle) = with_server(|req| {
682 req.respond(Response::empty(StatusCode(200)))
683 .expect("respond");
684 });
685
686 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
687
688 assert_eq!(cli.calculate_index_path("_serde"), "_s/er/_serde");
690 assert_eq!(cli.calculate_index_path("-tokio"), "-t/ok/-tokio");
691 assert_eq!(cli.calculate_index_path("Serde"), "se/rd/serde");
693 }
694
695 #[test]
696 fn parse_version_from_index_finds_version() {
697 let (api_base, _handle) = with_server(|req| {
698 req.respond(Response::empty(StatusCode(200)))
699 .expect("respond");
700 });
701
702 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
703
704 let index_content = "{\"vers\":\"1.0.0\"}\n{\"vers\":\"1.0.1\"}\n{\"vers\":\"2.0.0\"}\n";
705
706 let found = cli.parse_version_from_index(index_content, "1.0.1");
707 assert!(found.is_ok());
708 assert!(found.unwrap());
709 }
710
711 #[test]
712 fn parse_version_from_index_returns_false_for_missing_version() {
713 let (api_base, _handle) = with_server(|req| {
714 req.respond(Response::empty(StatusCode(200)))
715 .expect("respond");
716 });
717
718 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
719
720 let index_content = "{\"vers\":\"1.0.0\"}\n{\"vers\":\"1.0.1\"}\n";
721
722 let found = cli.parse_version_from_index(index_content, "2.0.0");
723 assert!(found.is_ok());
724 assert!(!found.unwrap());
725 }
726
727 #[test]
728 fn parse_version_from_index_handles_invalid_json() {
729 let (api_base, _handle) = with_server(|req| {
730 req.respond(Response::empty(StatusCode(200)))
731 .expect("respond");
732 });
733
734 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
735
736 let invalid_json = "not valid json";
737
738 let found = cli.parse_version_from_index(invalid_json, "1.0.0");
739 assert!(found.is_ok());
740 assert!(!found.unwrap());
741 }
742
743 #[test]
744 fn check_index_visibility_returns_true_for_existing_version() {
745 let index_content = "{\"vers\":\"1.0.0\"}\n{\"vers\":\"1.0.1\"}\n";
746
747 let (api_base, handle) = with_server(move |req| {
748 assert_eq!(req.url(), "/de/mo/demo");
749 let resp = Response::from_string(index_content)
750 .with_status_code(StatusCode(200))
751 .with_header(
752 tiny_http::Header::from_bytes("Content-Type", "application/json")
753 .expect("header"),
754 );
755 req.respond(resp).expect("respond");
756 });
757
758 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
759 let visible = cli.check_index_visibility("demo", "1.0.1").expect("check");
760 assert!(visible);
761 handle.join().expect("join");
762 }
763
764 #[test]
765 fn check_index_visibility_returns_false_for_missing_version() {
766 let index_content = "{\"vers\":\"1.0.0\"}\n";
767
768 let (api_base, handle) = with_server(move |req| {
769 assert_eq!(req.url(), "/de/mo/demo");
770 let resp = Response::from_string(index_content)
771 .with_status_code(StatusCode(200))
772 .with_header(
773 tiny_http::Header::from_bytes("Content-Type", "application/json")
774 .expect("header"),
775 );
776 req.respond(resp).expect("respond");
777 });
778
779 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
780 let visible = cli.check_index_visibility("demo", "1.0.1").expect("check");
781 assert!(!visible);
782 handle.join().expect("join");
783 }
784
785 #[test]
786 fn check_index_visibility_returns_false_for_404() {
787 let (api_base, handle) = with_server(|req| {
788 req.respond(Response::empty(StatusCode(404)))
789 .expect("respond");
790 });
791
792 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
793 let visible = cli
794 .check_index_visibility("missing", "1.0.0")
795 .expect("check");
796 assert!(!visible);
797 handle.join().expect("join");
798 }
799
800 #[test]
801 fn check_index_visibility_returns_false_for_network_error() {
802 let registry = Registry {
804 name: "test".to_string(),
805 api_base: "http://nonexistent.invalid:9999".to_string(),
806 index_base: Some("http://nonexistent.invalid:9999".to_string()),
807 };
808
809 let cli = RegistryClient::new(registry).expect("client");
810 let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
811 assert!(!visible);
812 }
813
814 #[test]
815 fn check_index_visibility_returns_false_for_invalid_json() {
816 let (api_base, handle) = with_server(move |req| {
817 let resp = Response::from_string("not valid json")
818 .with_status_code(StatusCode(200))
819 .with_header(
820 tiny_http::Header::from_bytes("Content-Type", "application/json")
821 .expect("header"),
822 );
823 req.respond(resp).expect("respond");
824 });
825
826 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
827 let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
828 assert!(!visible);
829 handle.join().expect("join");
830 }
831
832 #[test]
833 fn is_version_visible_with_backoff_uses_index_method() {
834 let index_content = "{\"vers\":\"1.0.0\"}\n";
835
836 let (api_base, handle) = with_server(move |req| {
837 assert_eq!(req.url(), "/de/mo/demo");
838 let resp = Response::from_string(index_content)
839 .with_status_code(StatusCode(200))
840 .with_header(
841 tiny_http::Header::from_bytes("Content-Type", "application/json")
842 .expect("header"),
843 );
844 req.respond(resp).expect("respond");
845 });
846
847 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
848 let config = ReadinessConfig {
849 enabled: true,
850 method: ReadinessMethod::Index,
851 initial_delay: Duration::from_millis(10),
852 max_delay: Duration::from_secs(1),
853 max_total_wait: Duration::from_secs(1),
854 poll_interval: Duration::from_millis(100),
855 jitter_factor: 0.0,
856 index_path: None,
857 prefer_index: false,
858 };
859
860 let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
861 assert!(result.is_ok());
862 let (visible, evidence) = result.unwrap();
863 assert!(visible);
864 assert!(!evidence.is_empty());
865 handle.join().expect("join");
866 }
867
868 #[test]
869 fn is_version_visible_with_backoff_uses_both_method_prefer_index() {
870 let index_content = "{\"vers\":\"1.0.0\"}\n";
871
872 let (api_base, handle) = with_server(move |req| {
873 assert_eq!(req.url(), "/de/mo/demo");
874 let resp = Response::from_string(index_content)
875 .with_status_code(StatusCode(200))
876 .with_header(
877 tiny_http::Header::from_bytes("Content-Type", "application/json")
878 .expect("header"),
879 );
880 req.respond(resp).expect("respond");
881 });
882
883 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
884 let config = ReadinessConfig {
885 enabled: true,
886 method: ReadinessMethod::Both,
887 initial_delay: Duration::from_millis(10),
888 max_delay: Duration::from_secs(1),
889 max_total_wait: Duration::from_secs(1),
890 poll_interval: Duration::from_millis(100),
891 jitter_factor: 0.0,
892 index_path: None,
893 prefer_index: true, };
895
896 let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
897 assert!(result.is_ok());
898 let (visible, evidence) = result.unwrap();
899 assert!(visible);
900 assert!(!evidence.is_empty());
901 handle.join().expect("join");
902 }
903
904 #[test]
905 fn registry_get_index_base_returns_explicit_index_base() {
906 let registry = Registry {
907 name: "test".to_string(),
908 api_base: "https://example.com".to_string(),
909 index_base: Some("https://index.example.com".to_string()),
910 };
911
912 assert_eq!(registry.get_index_base(), "https://index.example.com");
913 }
914
915 #[test]
916 fn registry_get_index_base_derives_from_api_base() {
917 let registry = Registry {
918 name: "test".to_string(),
919 api_base: "https://crates.io".to_string(),
920 index_base: None,
921 };
922
923 assert_eq!(registry.get_index_base(), "https://index.crates.io");
924 }
925
926 #[test]
927 fn registry_get_index_base_derives_from_http_api_base() {
928 let registry = Registry {
929 name: "test".to_string(),
930 api_base: "http://crates.io".to_string(),
931 index_base: None,
932 };
933
934 assert_eq!(registry.get_index_base(), "http://index.crates.io");
935 }
936
937 #[test]
940 fn check_index_visibility_with_empty_index_returns_false() {
941 let index_content = "";
942
943 let (api_base, handle) = with_server(move |req| {
944 let resp = Response::from_string(index_content)
945 .with_status_code(StatusCode(200))
946 .with_header(
947 tiny_http::Header::from_bytes("Content-Type", "application/json")
948 .expect("header"),
949 );
950 req.respond(resp).expect("respond");
951 });
952
953 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
954 let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
955 assert!(!visible);
956 handle.join().expect("join");
957 }
958
959 #[test]
960 fn check_index_visibility_with_multiple_versions_finds_correct() {
961 let index_content = "{\"vers\":\"0.1.0\"}\n{\"vers\":\"0.2.0\"}\n{\"vers\":\"1.0.0\"}\n{\"vers\":\"1.1.0\"}\n";
962
963 let (api_base, handle) = with_multi_server(
964 move |req| {
965 let resp = Response::from_string(index_content)
966 .with_status_code(StatusCode(200))
967 .with_header(
968 tiny_http::Header::from_bytes("Content-Type", "application/json")
969 .expect("header"),
970 );
971 req.respond(resp).expect("respond");
972 },
973 5,
974 );
975
976 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
977
978 assert!(cli.check_index_visibility("demo", "0.1.0").expect("check"));
980 assert!(cli.check_index_visibility("demo", "0.2.0").expect("check"));
981 assert!(cli.check_index_visibility("demo", "1.0.0").expect("check"));
982 assert!(cli.check_index_visibility("demo", "1.1.0").expect("check"));
983
984 assert!(!cli.check_index_visibility("demo", "2.0.0").expect("check"));
986
987 handle.join().expect("join");
988 }
989
990 #[test]
991 fn check_index_visibility_handles_malformed_json_gracefully() {
992 let malformed_json = "{\"vers\":\"1.0.0\"}\n{\"invalid\":\"entry\"}\n";
994
995 let (api_base, handle) = with_server(move |req| {
996 let resp = Response::from_string(malformed_json)
997 .with_status_code(StatusCode(200))
998 .with_header(
999 tiny_http::Header::from_bytes("Content-Type", "application/json")
1000 .expect("header"),
1001 );
1002 req.respond(resp).expect("respond");
1003 });
1004
1005 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
1006 let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
1008 assert!(visible);
1009 handle.join().expect("join");
1010 }
1011
1012 #[test]
1013 fn is_version_visible_with_backoff_with_api_method() {
1014 let (api_base, handle) = with_server(move |req| {
1015 let resp = Response::from_string("{}")
1016 .with_status_code(StatusCode(200))
1017 .with_header(
1018 tiny_http::Header::from_bytes("Content-Type", "application/json")
1019 .expect("header"),
1020 );
1021 req.respond(resp).expect("respond");
1022 });
1023
1024 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1025 let config = ReadinessConfig {
1026 enabled: true,
1027 method: ReadinessMethod::Api,
1028 initial_delay: Duration::from_millis(10),
1029 max_delay: Duration::from_secs(1),
1030 max_total_wait: Duration::from_secs(1),
1031 poll_interval: Duration::from_millis(100),
1032 jitter_factor: 0.0,
1033 index_path: None,
1034 prefer_index: false,
1035 };
1036
1037 let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
1038 assert!(result.is_ok());
1039 let (visible, evidence) = result.unwrap();
1040 assert!(visible);
1041 assert!(!evidence.is_empty());
1042 handle.join().expect("join");
1043 }
1044
1045 #[test]
1046 fn is_version_visible_with_backoff_with_both_method_prefer_api() {
1047 let (api_base, handle) = with_server(move |req| {
1048 let resp = Response::from_string("{}")
1049 .with_status_code(StatusCode(200))
1050 .with_header(
1051 tiny_http::Header::from_bytes("Content-Type", "application/json")
1052 .expect("header"),
1053 );
1054 req.respond(resp).expect("respond");
1055 });
1056
1057 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1058 let config = ReadinessConfig {
1059 enabled: true,
1060 method: ReadinessMethod::Both,
1061 initial_delay: Duration::from_millis(10),
1062 max_delay: Duration::from_secs(1),
1063 max_total_wait: Duration::from_secs(1),
1064 poll_interval: Duration::from_millis(100),
1065 jitter_factor: 0.0,
1066 index_path: None,
1067 prefer_index: false, };
1069
1070 let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
1071 assert!(result.is_ok());
1072 let (visible, evidence) = result.unwrap();
1073 assert!(visible);
1074 assert!(!evidence.is_empty());
1075 handle.join().expect("join");
1076 }
1077
1078 #[test]
1079 fn is_version_visible_with_backoff_returns_false_on_timeout() {
1080 let (api_base, handle) = with_multi_server(
1081 move |req| {
1082 let resp = Response::empty(StatusCode(404));
1084 req.respond(resp).expect("respond");
1085 },
1086 10,
1087 );
1088
1089 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1090 let config = ReadinessConfig {
1091 enabled: true,
1092 method: ReadinessMethod::Api,
1093 initial_delay: Duration::from_millis(10),
1094 max_delay: Duration::from_millis(50),
1095 max_total_wait: Duration::from_millis(100),
1096 poll_interval: Duration::from_millis(25),
1097 jitter_factor: 0.0,
1098 index_path: None,
1099 prefer_index: false,
1100 };
1101
1102 let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
1103 assert!(result.is_ok());
1104 let (visible, evidence) = result.unwrap();
1105 assert!(!visible);
1106 assert!(!evidence.is_empty());
1107 assert!(evidence.iter().all(|e| !e.visible));
1108 handle.join().expect("join");
1109 }
1110
1111 #[test]
1112 fn is_version_visible_with_backoff_handles_network_errors_gracefully() {
1113 let registry = Registry {
1115 name: "test".to_string(),
1116 api_base: "http://nonexistent.invalid:9999".to_string(),
1117 index_base: Some("http://nonexistent.invalid:9999".to_string()),
1118 };
1119
1120 let cli = RegistryClient::new(registry).expect("client");
1121 let config = ReadinessConfig {
1122 enabled: true,
1123 method: ReadinessMethod::Api,
1124 initial_delay: Duration::from_millis(10),
1125 max_delay: Duration::from_millis(50),
1126 max_total_wait: Duration::from_millis(100),
1127 poll_interval: Duration::from_millis(25),
1128 jitter_factor: 0.0,
1129 index_path: None,
1130 prefer_index: false,
1131 };
1132
1133 let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
1134 assert!(result.is_ok());
1135 let (visible, _evidence) = result.unwrap();
1136 assert!(!visible);
1137 }
1138
1139 #[test]
1140 fn is_version_visible_with_backoff_respects_initial_delay() {
1141 let start = std::time::Instant::now();
1142
1143 let (api_base, handle) = with_server(move |req| {
1144 let resp = Response::from_string("{}")
1145 .with_status_code(StatusCode(200))
1146 .with_header(
1147 tiny_http::Header::from_bytes("Content-Type", "application/json")
1148 .expect("header"),
1149 );
1150 req.respond(resp).expect("respond");
1151 });
1152
1153 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1154 let config = ReadinessConfig {
1155 enabled: true,
1156 method: ReadinessMethod::Api,
1157 initial_delay: Duration::from_millis(50),
1158 max_delay: Duration::from_secs(1),
1159 max_total_wait: Duration::from_secs(1),
1160 poll_interval: Duration::from_millis(100),
1161 jitter_factor: 0.0,
1162 index_path: None,
1163 prefer_index: false,
1164 };
1165
1166 let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
1167 let elapsed = start.elapsed();
1168 let (visible, evidence) = result.unwrap();
1169 assert!(visible);
1170 assert!(!evidence.is_empty());
1171
1172 assert!(elapsed >= Duration::from_millis(50));
1174 handle.join().expect("join");
1175 }
1176
1177 #[test]
1178 fn verify_ownership_returns_true_on_success() {
1179 let owners_json = r#"{"users":[{"id":1,"login":"user1","name":null},{"id":2,"login":"user2","name":null}]}"#;
1180
1181 let (api_base, handle) = with_server(move |req| {
1182 assert_eq!(req.url(), "/api/v1/crates/demo/owners");
1183 let resp = Response::from_string(owners_json)
1184 .with_status_code(StatusCode(200))
1185 .with_header(
1186 tiny_http::Header::from_bytes("Content-Type", "application/json")
1187 .expect("header"),
1188 );
1189 req.respond(resp).expect("respond");
1190 });
1191
1192 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1193 let verified = cli.verify_ownership("demo", "fake-token").expect("verify");
1194 assert!(verified);
1195 handle.join().expect("join");
1196 }
1197
1198 #[test]
1199 fn verify_ownership_returns_false_on_forbidden() {
1200 let (api_base, handle) = with_server(move |req| {
1201 let resp = Response::from_string("{}")
1202 .with_status_code(StatusCode(403))
1203 .with_header(
1204 tiny_http::Header::from_bytes("Content-Type", "application/json")
1205 .expect("header"),
1206 );
1207 req.respond(resp).expect("respond");
1208 });
1209
1210 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1211 let verified = cli.verify_ownership("demo", "fake-token").expect("verify");
1212 assert!(!verified);
1213 handle.join().expect("join");
1214 }
1215
1216 #[test]
1217 fn verify_ownership_returns_false_on_not_found() {
1218 let (api_base, handle) = with_server(move |req| {
1219 let resp = Response::from_string("{}")
1220 .with_status_code(StatusCode(404))
1221 .with_header(
1222 tiny_http::Header::from_bytes("Content-Type", "application/json")
1223 .expect("header"),
1224 );
1225 req.respond(resp).expect("respond");
1226 });
1227
1228 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1229 let verified = cli.verify_ownership("demo", "fake-token").expect("verify");
1230 assert!(!verified);
1231 handle.join().expect("join");
1232 }
1233
1234 #[test]
1235 fn check_new_crate_returns_true_for_nonexistent_crate() {
1236 let (api_base, handle) = with_server(move |req| {
1237 let resp = Response::empty(StatusCode(404));
1238 req.respond(resp).expect("respond");
1239 });
1240
1241 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1242 let is_new = cli.check_new_crate("demo").expect("check");
1243 assert!(is_new);
1244 handle.join().expect("join");
1245 }
1246
1247 #[test]
1248 fn check_new_crate_returns_false_for_existing_crate() {
1249 let (api_base, handle) = with_server(move |req| {
1250 let resp = Response::from_string("{}")
1251 .with_status_code(StatusCode(200))
1252 .with_header(
1253 tiny_http::Header::from_bytes("Content-Type", "application/json")
1254 .expect("header"),
1255 );
1256 req.respond(resp).expect("respond");
1257 });
1258
1259 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1260 let is_new = cli.check_new_crate("demo").expect("check");
1261 assert!(!is_new);
1262 handle.join().expect("join");
1263 }
1264
1265 #[test]
1268 fn api_mode_visible_on_first_check() {
1269 let (api_base, handle) = with_server(move |req| {
1270 req.respond(Response::empty(StatusCode(200)))
1271 .expect("respond");
1272 });
1273
1274 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1275 let config = ReadinessConfig {
1276 enabled: true,
1277 method: ReadinessMethod::Api,
1278 initial_delay: Duration::ZERO,
1279 max_delay: Duration::from_secs(1),
1280 max_total_wait: Duration::from_secs(5),
1281 poll_interval: Duration::from_millis(50),
1282 jitter_factor: 0.0,
1283 index_path: None,
1284 prefer_index: false,
1285 };
1286
1287 let (visible, evidence) = cli
1288 .is_version_visible_with_backoff("demo", "1.0.0", &config)
1289 .expect("backoff");
1290 assert!(visible);
1291 assert_eq!(evidence.len(), 1);
1292 assert!(evidence[0].visible);
1293 assert_eq!(evidence[0].attempt, 1);
1294 assert_eq!(evidence[0].delay_before, Duration::ZERO);
1295 handle.join().expect("join");
1296 }
1297
1298 #[test]
1299 fn api_mode_never_visible_times_out() {
1300 let (api_base, handle) = with_multi_server(
1301 move |req| {
1302 req.respond(Response::empty(StatusCode(404)))
1303 .expect("respond");
1304 },
1305 20,
1306 );
1307
1308 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1309 let config = ReadinessConfig {
1310 enabled: true,
1311 method: ReadinessMethod::Api,
1312 initial_delay: Duration::ZERO,
1313 max_delay: Duration::from_millis(20),
1314 max_total_wait: Duration::from_millis(80),
1315 poll_interval: Duration::from_millis(10),
1316 jitter_factor: 0.0,
1317 index_path: None,
1318 prefer_index: false,
1319 };
1320
1321 let (visible, evidence) = cli
1322 .is_version_visible_with_backoff("demo", "1.0.0", &config)
1323 .expect("backoff");
1324 assert!(!visible);
1325 assert!(
1326 evidence.len() >= 2,
1327 "should poll multiple times before timeout"
1328 );
1329 assert!(evidence.iter().all(|e| !e.visible));
1330 handle.join().expect("join");
1331 }
1332
1333 #[test]
1334 fn api_mode_intermittent_failures_then_success() {
1335 use std::sync::Arc;
1336 use std::sync::atomic::{AtomicU32, Ordering};
1337
1338 let counter = Arc::new(AtomicU32::new(0));
1339 let counter_clone = counter.clone();
1340
1341 let (api_base, handle) = with_multi_server(
1342 move |req| {
1343 let n = counter_clone.fetch_add(1, Ordering::SeqCst);
1344 let status = if n < 2 { 500 } else { 200 };
1346 req.respond(Response::empty(StatusCode(status)))
1347 .expect("respond");
1348 },
1349 5,
1350 );
1351
1352 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1353 let config = ReadinessConfig {
1354 enabled: true,
1355 method: ReadinessMethod::Api,
1356 initial_delay: Duration::ZERO,
1357 max_delay: Duration::from_millis(20),
1358 max_total_wait: Duration::from_secs(5),
1359 poll_interval: Duration::from_millis(10),
1360 jitter_factor: 0.0,
1361 index_path: None,
1362 prefer_index: false,
1363 };
1364
1365 let (visible, evidence) = cli
1366 .is_version_visible_with_backoff("demo", "1.0.0", &config)
1367 .expect("backoff");
1368 assert!(visible);
1369 assert!(evidence.len() >= 3);
1371 assert!(!evidence[0].visible);
1372 assert!(!evidence[1].visible);
1373 assert!(evidence.last().unwrap().visible);
1374 handle.join().expect("join");
1375 }
1376
1377 #[test]
1378 fn index_mode_sparse_index_shows_version() {
1379 let index_content = "{\"vers\":\"0.9.0\"}\n{\"vers\":\"1.0.0\"}\n";
1380
1381 let (api_base, handle) = with_server(move |req| {
1382 assert_eq!(req.url(), "/de/mo/demo");
1383 req.respond(Response::from_string(index_content).with_status_code(StatusCode(200)))
1384 .expect("respond");
1385 });
1386
1387 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
1388 let config = ReadinessConfig {
1389 enabled: true,
1390 method: ReadinessMethod::Index,
1391 initial_delay: Duration::ZERO,
1392 max_delay: Duration::from_secs(1),
1393 max_total_wait: Duration::from_secs(5),
1394 poll_interval: Duration::from_millis(50),
1395 jitter_factor: 0.0,
1396 index_path: None,
1397 prefer_index: false,
1398 };
1399
1400 let (visible, evidence) = cli
1401 .is_version_visible_with_backoff("demo", "1.0.0", &config)
1402 .expect("backoff");
1403 assert!(visible);
1404 assert_eq!(evidence.len(), 1);
1405 assert!(evidence[0].visible);
1406 handle.join().expect("join");
1407 }
1408
1409 #[test]
1410 fn index_mode_stale_empty_index() {
1411 let (api_base, handle) = with_multi_server(
1412 move |req| {
1413 req.respond(Response::from_string("").with_status_code(StatusCode(200)))
1415 .expect("respond");
1416 },
1417 10,
1418 );
1419
1420 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
1421 let config = ReadinessConfig {
1422 enabled: true,
1423 method: ReadinessMethod::Index,
1424 initial_delay: Duration::ZERO,
1425 max_delay: Duration::from_millis(20),
1426 max_total_wait: Duration::from_millis(80),
1427 poll_interval: Duration::from_millis(10),
1428 jitter_factor: 0.0,
1429 index_path: None,
1430 prefer_index: false,
1431 };
1432
1433 let (visible, evidence) = cli
1434 .is_version_visible_with_backoff("demo", "1.0.0", &config)
1435 .expect("backoff");
1436 assert!(!visible);
1437 assert!(evidence.len() >= 2);
1438 assert!(evidence.iter().all(|e| !e.visible));
1439 handle.join().expect("join");
1440 }
1441
1442 #[test]
1443 fn index_mode_parse_errors_treated_as_not_visible() {
1444 let (api_base, handle) = with_multi_server(
1445 move |req| {
1446 req.respond(
1447 Response::from_string("<<<not json>>>").with_status_code(StatusCode(200)),
1448 )
1449 .expect("respond");
1450 },
1451 10,
1452 );
1453
1454 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
1455 let config = ReadinessConfig {
1456 enabled: true,
1457 method: ReadinessMethod::Index,
1458 initial_delay: Duration::ZERO,
1459 max_delay: Duration::from_millis(20),
1460 max_total_wait: Duration::from_millis(80),
1461 poll_interval: Duration::from_millis(10),
1462 jitter_factor: 0.0,
1463 index_path: None,
1464 prefer_index: false,
1465 };
1466
1467 let (visible, evidence) = cli
1468 .is_version_visible_with_backoff("demo", "1.0.0", &config)
1469 .expect("backoff");
1470 assert!(!visible);
1471 assert!(evidence.iter().all(|e| !e.visible));
1472 handle.join().expect("join");
1473 }
1474
1475 #[test]
1476 fn both_mode_api_succeeds_index_fails() {
1477 use std::sync::Arc;
1478 use std::sync::atomic::{AtomicU32, Ordering};
1479
1480 let counter = Arc::new(AtomicU32::new(0));
1481 let counter_clone = counter.clone();
1482
1483 let (api_base, handle) = with_multi_server(
1485 move |req| {
1486 let n = counter_clone.fetch_add(1, Ordering::SeqCst);
1487 let url = req.url().to_string();
1488 if url.contains("/api/v1/crates/") {
1489 req.respond(Response::empty(StatusCode(200)))
1491 .expect("respond");
1492 } else {
1493 req.respond(Response::empty(StatusCode(404)))
1495 .expect("respond");
1496 }
1497 let _ = n;
1498 },
1499 5,
1500 );
1501
1502 let cli = RegistryClient::new(test_registry_with_index(api_base.clone())).expect("client");
1503 let config = ReadinessConfig {
1504 enabled: true,
1505 method: ReadinessMethod::Both,
1506 initial_delay: Duration::ZERO,
1507 max_delay: Duration::from_secs(1),
1508 max_total_wait: Duration::from_secs(5),
1509 poll_interval: Duration::from_millis(50),
1510 jitter_factor: 0.0,
1511 index_path: None,
1512 prefer_index: true, };
1514
1515 let (visible, evidence) = cli
1516 .is_version_visible_with_backoff("demo", "1.0.0", &config)
1517 .expect("backoff");
1518 assert!(visible);
1519 assert_eq!(evidence.len(), 1);
1520 assert!(evidence[0].visible);
1521 handle.join().expect("join");
1522 }
1523
1524 #[test]
1525 fn both_mode_index_succeeds_api_fails() {
1526 let index_content = "{\"vers\":\"1.0.0\"}\n";
1527
1528 let (api_base, handle) = with_multi_server(
1530 move |req| {
1531 let url = req.url().to_string();
1532 if url.contains("/api/v1/crates/") {
1533 req.respond(Response::empty(StatusCode(404)))
1535 .expect("respond");
1536 } else {
1537 req.respond(
1539 Response::from_string(index_content).with_status_code(StatusCode(200)),
1540 )
1541 .expect("respond");
1542 }
1543 },
1544 5,
1545 );
1546
1547 let cli = RegistryClient::new(test_registry_with_index(api_base.clone())).expect("client");
1548 let config = ReadinessConfig {
1549 enabled: true,
1550 method: ReadinessMethod::Both,
1551 initial_delay: Duration::ZERO,
1552 max_delay: Duration::from_secs(1),
1553 max_total_wait: Duration::from_secs(5),
1554 poll_interval: Duration::from_millis(50),
1555 jitter_factor: 0.0,
1556 index_path: None,
1557 prefer_index: false, };
1559
1560 let (visible, evidence) = cli
1561 .is_version_visible_with_backoff("demo", "1.0.0", &config)
1562 .expect("backoff");
1563 assert!(visible);
1564 assert_eq!(evidence.len(), 1);
1565 assert!(evidence[0].visible);
1566 handle.join().expect("join");
1567 }
1568
1569 #[test]
1570 fn zero_timeout_returns_immediately() {
1571 let (api_base, handle) = with_server(move |req| {
1572 req.respond(Response::empty(StatusCode(404)))
1573 .expect("respond");
1574 });
1575
1576 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1577 let config = ReadinessConfig {
1578 enabled: true,
1579 method: ReadinessMethod::Api,
1580 initial_delay: Duration::ZERO,
1581 max_delay: Duration::from_secs(1),
1582 max_total_wait: Duration::ZERO,
1583 poll_interval: Duration::from_millis(50),
1584 jitter_factor: 0.0,
1585 index_path: None,
1586 prefer_index: false,
1587 };
1588
1589 let start = Instant::now();
1590 let (visible, evidence) = cli
1591 .is_version_visible_with_backoff("demo", "1.0.0", &config)
1592 .expect("backoff");
1593 let elapsed = start.elapsed();
1594
1595 assert!(!visible);
1596 assert_eq!(evidence.len(), 1);
1598 assert!(!evidence[0].visible);
1599 assert!(elapsed < Duration::from_secs(1));
1601 handle.join().expect("join");
1602 }
1603
1604 #[test]
1605 fn evidence_records_populated_correctly() {
1606 use std::sync::Arc;
1607 use std::sync::atomic::{AtomicU32, Ordering};
1608
1609 let counter = Arc::new(AtomicU32::new(0));
1610 let counter_clone = counter.clone();
1611
1612 let (api_base, handle) = with_multi_server(
1613 move |req| {
1614 let n = counter_clone.fetch_add(1, Ordering::SeqCst);
1615 let status = if n < 2 { 404 } else { 200 };
1617 req.respond(Response::empty(StatusCode(status)))
1618 .expect("respond");
1619 },
1620 5,
1621 );
1622
1623 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1624 let config = ReadinessConfig {
1625 enabled: true,
1626 method: ReadinessMethod::Api,
1627 initial_delay: Duration::ZERO,
1628 max_delay: Duration::from_millis(50),
1629 max_total_wait: Duration::from_secs(5),
1630 poll_interval: Duration::from_millis(10),
1631 jitter_factor: 0.0,
1632 index_path: None,
1633 prefer_index: false,
1634 };
1635
1636 let (visible, evidence) = cli
1637 .is_version_visible_with_backoff("demo", "1.0.0", &config)
1638 .expect("backoff");
1639 assert!(visible);
1640 assert_eq!(evidence.len(), 3);
1641
1642 assert_eq!(evidence[0].attempt, 1);
1644 assert_eq!(evidence[1].attempt, 2);
1645 assert_eq!(evidence[2].attempt, 3);
1646
1647 assert!(!evidence[0].visible);
1649 assert!(!evidence[1].visible);
1650 assert!(evidence[2].visible);
1651
1652 assert_eq!(evidence[0].delay_before, Duration::ZERO);
1654
1655 assert!(evidence[1].delay_before > Duration::ZERO);
1657 assert!(evidence[2].delay_before > Duration::ZERO);
1658
1659 assert!(evidence[0].timestamp <= evidence[1].timestamp);
1661 assert!(evidence[1].timestamp <= evidence[2].timestamp);
1662
1663 handle.join().expect("join");
1664 }
1665
1666 #[test]
1667 fn backoff_delays_increase_exponentially() {
1668 let (api_base, _handle) = with_server(|req| {
1669 req.respond(Response::empty(StatusCode(200)))
1670 .expect("respond");
1671 });
1672
1673 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1674 let base = Duration::from_millis(100);
1675 let max = Duration::from_secs(10);
1676
1677 let d1 = cli.calculate_backoff_delay(base, max, 1, 0.0);
1679 let d2 = cli.calculate_backoff_delay(base, max, 2, 0.0);
1680 let d3 = cli.calculate_backoff_delay(base, max, 3, 0.0);
1681 let d4 = cli.calculate_backoff_delay(base, max, 4, 0.0);
1682
1683 assert_eq!(d1, Duration::from_millis(100));
1685 assert_eq!(d2, Duration::from_millis(200));
1687 assert_eq!(d3, Duration::from_millis(400));
1689 assert_eq!(d4, Duration::from_millis(800));
1691
1692 assert_eq!(d2, d1 * 2);
1694 assert_eq!(d3, d2 * 2);
1695 assert_eq!(d4, d3 * 2);
1696 }
1697
1698 #[test]
1699 fn backoff_delays_capped_at_max() {
1700 let (api_base, _handle) = with_server(|req| {
1701 req.respond(Response::empty(StatusCode(200)))
1702 .expect("respond");
1703 });
1704
1705 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1706 let base = Duration::from_millis(100);
1707 let max = Duration::from_millis(500);
1708
1709 let d4 = cli.calculate_backoff_delay(base, max, 4, 0.0);
1711 assert_eq!(d4, Duration::from_millis(500));
1712
1713 let d20 = cli.calculate_backoff_delay(base, max, 20, 0.0);
1715 assert_eq!(d20, Duration::from_millis(500));
1716 }
1717
1718 #[test]
1719 fn disabled_readiness_with_not_found_returns_false() {
1720 let (api_base, handle) = with_server(|req| {
1721 req.respond(Response::empty(StatusCode(404)))
1722 .expect("respond");
1723 });
1724
1725 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1726 let config = ReadinessConfig {
1727 enabled: false,
1728 method: ReadinessMethod::Api,
1729 initial_delay: Duration::from_secs(999), max_delay: Duration::from_secs(999),
1731 max_total_wait: Duration::from_secs(999),
1732 poll_interval: Duration::from_secs(999),
1733 jitter_factor: 0.5,
1734 index_path: None,
1735 prefer_index: false,
1736 };
1737
1738 let (visible, evidence) = cli
1739 .is_version_visible_with_backoff("demo", "1.0.0", &config)
1740 .expect("backoff");
1741 assert!(!visible);
1742 assert_eq!(evidence.len(), 1);
1743 assert!(!evidence[0].visible);
1744 assert_eq!(evidence[0].attempt, 1);
1745 assert_eq!(evidence[0].delay_before, Duration::ZERO);
1746 handle.join().expect("join");
1747 }
1748
1749 #[test]
1750 fn both_mode_both_fail_times_out() {
1751 let (api_base, handle) = with_multi_server(
1752 move |req| {
1753 req.respond(Response::empty(StatusCode(404)))
1755 .expect("respond");
1756 },
1757 20,
1758 );
1759
1760 let cli = RegistryClient::new(test_registry_with_index(api_base.clone())).expect("client");
1761 let config = ReadinessConfig {
1762 enabled: true,
1763 method: ReadinessMethod::Both,
1764 initial_delay: Duration::ZERO,
1765 max_delay: Duration::from_millis(20),
1766 max_total_wait: Duration::from_millis(80),
1767 poll_interval: Duration::from_millis(10),
1768 jitter_factor: 0.0,
1769 index_path: None,
1770 prefer_index: false,
1771 };
1772
1773 let (visible, evidence) = cli
1774 .is_version_visible_with_backoff("demo", "1.0.0", &config)
1775 .expect("backoff");
1776 assert!(!visible);
1777 assert!(evidence.len() >= 2);
1778 assert!(evidence.iter().all(|e| !e.visible));
1779 handle.join().expect("join");
1780 }
1781
1782 #[test]
1785 fn version_exists_errors_for_429_rate_limit() {
1786 let (api_base, handle) = with_server(|req| {
1787 req.respond(Response::empty(StatusCode(429)))
1788 .expect("respond");
1789 });
1790
1791 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1792 let err = cli
1793 .version_exists("demo", "1.0.0")
1794 .expect_err("429 must fail");
1795 assert!(format!("{err:#}").contains("unexpected status"));
1796 handle.join().expect("join");
1797 }
1798
1799 #[test]
1800 fn version_exists_errors_for_502_bad_gateway() {
1801 let (api_base, handle) = with_server(|req| {
1802 req.respond(Response::empty(StatusCode(502)))
1803 .expect("respond");
1804 });
1805
1806 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1807 let err = cli
1808 .version_exists("demo", "1.0.0")
1809 .expect_err("502 must fail");
1810 assert!(format!("{err:#}").contains("unexpected status"));
1811 handle.join().expect("join");
1812 }
1813
1814 #[test]
1815 fn version_exists_errors_for_503_service_unavailable() {
1816 let (api_base, handle) = with_server(|req| {
1817 req.respond(Response::empty(StatusCode(503)))
1818 .expect("respond");
1819 });
1820
1821 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1822 let err = cli
1823 .version_exists("demo", "1.0.0")
1824 .expect_err("503 must fail");
1825 assert!(format!("{err:#}").contains("unexpected status"));
1826 handle.join().expect("join");
1827 }
1828
1829 #[test]
1830 fn crate_exists_errors_for_429_rate_limit() {
1831 let (api_base, handle) = with_server(|req| {
1832 req.respond(Response::empty(StatusCode(429)))
1833 .expect("respond");
1834 });
1835
1836 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1837 let err = cli.crate_exists("demo").expect_err("429 must fail");
1838 assert!(format!("{err:#}").contains("unexpected status"));
1839 handle.join().expect("join");
1840 }
1841
1842 #[test]
1843 fn list_owners_errors_for_429_rate_limit() {
1844 let (api_base, handle) = with_server(|req| {
1845 req.respond(Response::empty(StatusCode(429)))
1846 .expect("respond");
1847 });
1848
1849 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1850 let err = cli.list_owners("demo", "token").expect_err("429 must fail");
1851 assert!(format!("{err:#}").contains("unexpected status while querying owners"));
1852 handle.join().expect("join");
1853 }
1854
1855 #[test]
1858 fn list_owners_errors_on_non_json_response() {
1859 let (api_base, handle) = with_server(|req| {
1860 let resp = Response::from_string("this is not json at all")
1861 .with_status_code(StatusCode(200))
1862 .with_header(
1863 tiny_http::Header::from_bytes("Content-Type", "text/plain").expect("header"),
1864 );
1865 req.respond(resp).expect("respond");
1866 });
1867
1868 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1869 let err = cli
1870 .list_owners("demo", "token")
1871 .expect_err("non-json must fail");
1872 assert!(format!("{err:#}").contains("failed to parse owners JSON"));
1873 handle.join().expect("join");
1874 }
1875
1876 #[test]
1877 fn list_owners_errors_on_truncated_json() {
1878 let (api_base, handle) = with_server(|req| {
1879 let resp = Response::from_string(r#"{"users":[{"id":1,"login":"al"#)
1880 .with_status_code(StatusCode(200))
1881 .with_header(
1882 tiny_http::Header::from_bytes("Content-Type", "application/json")
1883 .expect("header"),
1884 );
1885 req.respond(resp).expect("respond");
1886 });
1887
1888 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1889 let err = cli
1890 .list_owners("demo", "token")
1891 .expect_err("truncated json must fail");
1892 assert!(format!("{err:#}").contains("failed to parse owners JSON"));
1893 handle.join().expect("join");
1894 }
1895
1896 #[test]
1897 fn list_owners_errors_on_wrong_schema_json() {
1898 let (api_base, handle) = with_server(|req| {
1899 let resp = Response::from_string(r#"{"data": [1, 2, 3]}"#)
1901 .with_status_code(StatusCode(200))
1902 .with_header(
1903 tiny_http::Header::from_bytes("Content-Type", "application/json")
1904 .expect("header"),
1905 );
1906 req.respond(resp).expect("respond");
1907 });
1908
1909 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1910 let err = cli
1911 .list_owners("demo", "token")
1912 .expect_err("wrong schema must fail");
1913 assert!(format!("{err:#}").contains("failed to parse owners JSON"));
1914 handle.join().expect("join");
1915 }
1916
1917 #[test]
1920 fn parse_version_from_index_exact_match_only() {
1921 let (api_base, _handle) = with_server(|req| {
1922 req.respond(Response::empty(StatusCode(200)))
1923 .expect("respond");
1924 });
1925
1926 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1927
1928 let content = "{\"vers\":\"1.0.0\"}\n{\"vers\":\"1.0.10\"}\n{\"vers\":\"1.0.0-beta.1\"}\n";
1929
1930 assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
1932 assert!(cli.parse_version_from_index(content, "1.0.10").unwrap());
1933
1934 assert!(!cli.parse_version_from_index(content, "1.0.1").unwrap());
1937 }
1938
1939 #[test]
1940 fn parse_version_from_index_prerelease_versions() {
1941 let (api_base, _handle) = with_server(|req| {
1942 req.respond(Response::empty(StatusCode(200)))
1943 .expect("respond");
1944 });
1945
1946 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1947
1948 let content = "{\"vers\":\"1.0.0-alpha.1\"}\n{\"vers\":\"1.0.0-beta.2\"}\n{\"vers\":\"1.0.0-rc.1\"}\n{\"vers\":\"1.0.0\"}\n";
1949
1950 assert!(
1951 cli.parse_version_from_index(content, "1.0.0-alpha.1")
1952 .unwrap()
1953 );
1954 assert!(
1955 cli.parse_version_from_index(content, "1.0.0-beta.2")
1956 .unwrap()
1957 );
1958 assert!(cli.parse_version_from_index(content, "1.0.0-rc.1").unwrap());
1959 assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
1960
1961 assert!(
1963 !cli.parse_version_from_index(content, "1.0.0-alpha.2")
1964 .unwrap()
1965 );
1966 }
1967
1968 #[test]
1969 fn parse_version_from_index_empty_content() {
1970 let (api_base, _handle) = with_server(|req| {
1971 req.respond(Response::empty(StatusCode(200)))
1972 .expect("respond");
1973 });
1974
1975 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1976
1977 assert!(!cli.parse_version_from_index("", "1.0.0").unwrap());
1978 assert!(!cli.parse_version_from_index("\n\n\n", "1.0.0").unwrap());
1979 }
1980
1981 #[test]
1984 fn version_exists_slow_response_still_succeeds() {
1985 let (api_base, handle) = with_server(|req| {
1986 std::thread::sleep(Duration::from_millis(200));
1988 req.respond(Response::empty(StatusCode(200)))
1989 .expect("respond");
1990 });
1991
1992 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1993 let exists = cli.version_exists("demo", "1.0.0").expect("exists");
1994 assert!(exists);
1995 handle.join().expect("join");
1996 }
1997
1998 #[test]
2001 fn api_mode_500_treated_as_not_visible_in_backoff() {
2002 let (api_base, handle) = with_multi_server(
2003 move |req| {
2004 req.respond(Response::empty(StatusCode(500)))
2005 .expect("respond");
2006 },
2007 10,
2008 );
2009
2010 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2011 let config = ReadinessConfig {
2012 enabled: true,
2013 method: ReadinessMethod::Api,
2014 initial_delay: Duration::ZERO,
2015 max_delay: Duration::from_millis(20),
2016 max_total_wait: Duration::from_millis(80),
2017 poll_interval: Duration::from_millis(10),
2018 jitter_factor: 0.0,
2019 index_path: None,
2020 prefer_index: false,
2021 };
2022
2023 let (visible, evidence) = cli
2024 .is_version_visible_with_backoff("demo", "1.0.0", &config)
2025 .expect("backoff");
2026 assert!(!visible);
2028 assert!(evidence.iter().all(|e| !e.visible));
2029 handle.join().expect("join");
2030 }
2031
2032 #[test]
2033 fn index_mode_502_treated_as_not_visible_in_backoff() {
2034 let (api_base, handle) = with_multi_server(
2035 move |req| {
2036 req.respond(Response::empty(StatusCode(502)))
2037 .expect("respond");
2038 },
2039 10,
2040 );
2041
2042 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
2043 let config = ReadinessConfig {
2044 enabled: true,
2045 method: ReadinessMethod::Index,
2046 initial_delay: Duration::ZERO,
2047 max_delay: Duration::from_millis(20),
2048 max_total_wait: Duration::from_millis(80),
2049 poll_interval: Duration::from_millis(10),
2050 jitter_factor: 0.0,
2051 index_path: None,
2052 prefer_index: false,
2053 };
2054
2055 let (visible, evidence) = cli
2056 .is_version_visible_with_backoff("demo", "1.0.0", &config)
2057 .expect("backoff");
2058 assert!(!visible);
2059 assert!(evidence.iter().all(|e| !e.visible));
2060 handle.join().expect("join");
2061 }
2062
2063 #[test]
2064 fn both_mode_prefer_index_true_checks_index_first() {
2065 use std::sync::Arc;
2066
2067 let call_order = Arc::new(std::sync::Mutex::new(Vec::new()));
2068 let call_order_clone = call_order.clone();
2069
2070 let (api_base, handle) = with_multi_server(
2071 move |req| {
2072 let url = req.url().to_string();
2073 let mut order = call_order_clone.lock().unwrap();
2074 if url.contains("/api/v1/crates/") {
2075 order.push("api".to_string());
2076 req.respond(Response::empty(StatusCode(200)))
2077 .expect("respond");
2078 } else {
2079 order.push("index".to_string());
2080 req.respond(Response::empty(StatusCode(404)))
2082 .expect("respond");
2083 }
2084 },
2085 5,
2086 );
2087
2088 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
2089 let config = ReadinessConfig {
2090 enabled: true,
2091 method: ReadinessMethod::Both,
2092 initial_delay: Duration::ZERO,
2093 max_delay: Duration::from_secs(1),
2094 max_total_wait: Duration::from_secs(5),
2095 poll_interval: Duration::from_millis(50),
2096 jitter_factor: 0.0,
2097 index_path: None,
2098 prefer_index: true,
2099 };
2100
2101 let (visible, _) = cli
2102 .is_version_visible_with_backoff("demo", "1.0.0", &config)
2103 .expect("backoff");
2104 assert!(visible);
2105
2106 let order = call_order.lock().unwrap();
2107 assert!(order.len() >= 2);
2109 assert_eq!(order[0], "index");
2110 assert_eq!(order[1], "api");
2111 handle.join().expect("join");
2112 }
2113
2114 #[test]
2115 fn both_mode_prefer_index_false_checks_api_first() {
2116 use std::sync::Arc;
2117
2118 let call_order = Arc::new(std::sync::Mutex::new(Vec::new()));
2119 let call_order_clone = call_order.clone();
2120 let index_content = "{\"vers\":\"1.0.0\"}\n";
2121
2122 let (api_base, handle) = with_multi_server(
2123 move |req| {
2124 let url = req.url().to_string();
2125 let mut order = call_order_clone.lock().unwrap();
2126 if url.contains("/api/v1/crates/") {
2127 order.push("api".to_string());
2128 req.respond(Response::empty(StatusCode(404)))
2130 .expect("respond");
2131 } else {
2132 order.push("index".to_string());
2133 req.respond(
2134 Response::from_string(index_content).with_status_code(StatusCode(200)),
2135 )
2136 .expect("respond");
2137 }
2138 },
2139 5,
2140 );
2141
2142 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
2143 let config = ReadinessConfig {
2144 enabled: true,
2145 method: ReadinessMethod::Both,
2146 initial_delay: Duration::ZERO,
2147 max_delay: Duration::from_secs(1),
2148 max_total_wait: Duration::from_secs(5),
2149 poll_interval: Duration::from_millis(50),
2150 jitter_factor: 0.0,
2151 index_path: None,
2152 prefer_index: false,
2153 };
2154
2155 let (visible, _) = cli
2156 .is_version_visible_with_backoff("demo", "1.0.0", &config)
2157 .expect("backoff");
2158 assert!(visible);
2159
2160 let order = call_order.lock().unwrap();
2161 assert!(order.len() >= 2);
2162 assert_eq!(order[0], "api");
2163 assert_eq!(order[1], "index");
2164 handle.join().expect("join");
2165 }
2166
2167 #[test]
2170 fn snapshot_owners_response_parsed() {
2171 let (api_base, handle) = with_server(|req| {
2172 let body = r#"{"users":[{"id":42,"login":"alice","name":"Alice Wonderland"},{"id":99,"login":"bob","name":null}]}"#;
2173 let resp = Response::from_string(body)
2174 .with_status_code(StatusCode(200))
2175 .with_header(
2176 tiny_http::Header::from_bytes("Content-Type", "application/json")
2177 .expect("header"),
2178 );
2179 req.respond(resp).expect("respond");
2180 });
2181
2182 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2183 let owners = cli.list_owners("demo", "token").expect("owners");
2184 insta::assert_debug_snapshot!("owners_response_parsed", owners);
2185 handle.join().expect("join");
2186 }
2187
2188 #[test]
2189 fn snapshot_readiness_evidence_single_attempt() {
2190 let (api_base, handle) = with_server(|req| {
2191 req.respond(Response::empty(StatusCode(200)))
2192 .expect("respond");
2193 });
2194
2195 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2196 let config = ReadinessConfig {
2197 enabled: true,
2198 method: ReadinessMethod::Api,
2199 initial_delay: Duration::ZERO,
2200 max_delay: Duration::from_secs(1),
2201 max_total_wait: Duration::from_secs(5),
2202 poll_interval: Duration::from_millis(50),
2203 jitter_factor: 0.0,
2204 index_path: None,
2205 prefer_index: false,
2206 };
2207
2208 let (visible, evidence) = cli
2209 .is_version_visible_with_backoff("demo", "1.0.0", &config)
2210 .expect("backoff");
2211 assert!(visible);
2212 assert_eq!(evidence.len(), 1);
2213
2214 insta::assert_debug_snapshot!(
2216 "readiness_evidence_single_attempt",
2217 evidence
2218 .iter()
2219 .map(|e| {
2220 format!(
2221 "attempt={} visible={} delay_before={}ms",
2222 e.attempt,
2223 e.visible,
2224 e.delay_before.as_millis()
2225 )
2226 })
2227 .collect::<Vec<_>>()
2228 );
2229 handle.join().expect("join");
2230 }
2231
2232 mod property_tests_registry {
2235 use proptest::prelude::*;
2236
2237 fn crate_name_strategy() -> impl Strategy<Value = String> {
2240 "[a-z][a-z0-9_-]{0,63}".prop_map(|s| s)
2241 }
2242
2243 proptest! {
2244 #[test]
2245 fn random_crate_names_produce_valid_api_url(name in crate_name_strategy()) {
2246 let api_base = "https://crates.io";
2247 let url = format!(
2248 "{}/api/v1/crates/{}/{}",
2249 api_base.trim_end_matches('/'),
2250 name,
2251 "1.0.0"
2252 );
2253 prop_assert!(!url.contains(' '));
2255 prop_assert!(url.starts_with("https://"));
2256 prop_assert!(url.contains("/api/v1/crates/"));
2257 prop_assert!(url.parse::<reqwest::Url>().is_ok());
2259 }
2260
2261 #[test]
2262 fn random_crate_names_produce_valid_index_path(name in crate_name_strategy()) {
2263 let path = shipper_sparse_index::sparse_index_path(&name);
2264 prop_assert!(!path.is_empty());
2266 prop_assert!(path.contains(&name.to_lowercase()));
2268 let segments: Vec<&str> = path.split('/').collect();
2271 match name.len() {
2272 1 => {
2273 prop_assert_eq!(segments.len(), 2);
2274 prop_assert_eq!(segments[0], "1");
2275 }
2276 2 => {
2277 prop_assert_eq!(segments.len(), 2);
2278 prop_assert_eq!(segments[0], "2");
2279 }
2280 3 => {
2281 prop_assert_eq!(segments.len(), 3);
2282 prop_assert_eq!(segments[0], "3");
2283 }
2284 _ => {
2285 prop_assert_eq!(segments.len(), 3);
2286 prop_assert_eq!(segments[0].len(), 2);
2287 prop_assert_eq!(segments[1].len(), 2);
2288 }
2289 }
2290 }
2291 }
2292 }
2293
2294 #[test]
2297 fn version_exists_errors_for_401_unauthorized() {
2298 let (api_base, handle) = with_server(|req| {
2299 req.respond(Response::empty(StatusCode(401)))
2300 .expect("respond");
2301 });
2302
2303 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2304 let err = cli
2305 .version_exists("demo", "1.0.0")
2306 .expect_err("401 must fail");
2307 assert!(format!("{err:#}").contains("unexpected status"));
2308 handle.join().expect("join");
2309 }
2310
2311 #[test]
2312 fn version_exists_errors_for_403_forbidden() {
2313 let (api_base, handle) = with_server(|req| {
2314 req.respond(Response::empty(StatusCode(403)))
2315 .expect("respond");
2316 });
2317
2318 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2319 let err = cli
2320 .version_exists("demo", "1.0.0")
2321 .expect_err("403 must fail");
2322 assert!(format!("{err:#}").contains("unexpected status"));
2323 handle.join().expect("join");
2324 }
2325
2326 #[test]
2327 fn crate_exists_errors_for_401_unauthorized() {
2328 let (api_base, handle) = with_server(|req| {
2329 req.respond(Response::empty(StatusCode(401)))
2330 .expect("respond");
2331 });
2332
2333 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2334 let err = cli.crate_exists("demo").expect_err("401 must fail");
2335 assert!(format!("{err:#}").contains("unexpected status"));
2336 handle.join().expect("join");
2337 }
2338
2339 #[test]
2340 fn crate_exists_errors_for_502_bad_gateway() {
2341 let (api_base, handle) = with_server(|req| {
2342 req.respond(Response::empty(StatusCode(502)))
2343 .expect("respond");
2344 });
2345
2346 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2347 let err = cli.crate_exists("demo").expect_err("502 must fail");
2348 assert!(format!("{err:#}").contains("unexpected status"));
2349 handle.join().expect("join");
2350 }
2351
2352 #[test]
2353 fn crate_exists_errors_for_503_service_unavailable() {
2354 let (api_base, handle) = with_server(|req| {
2355 req.respond(Response::empty(StatusCode(503)))
2356 .expect("respond");
2357 });
2358
2359 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2360 let err = cli.crate_exists("demo").expect_err("503 must fail");
2361 assert!(format!("{err:#}").contains("unexpected status"));
2362 handle.join().expect("join");
2363 }
2364
2365 #[test]
2366 fn list_owners_errors_for_401_unauthorized() {
2367 let (api_base, handle) = with_server(|req| {
2368 req.respond(Response::empty(StatusCode(401)))
2369 .expect("respond");
2370 });
2371
2372 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2373 let err = cli.list_owners("demo", "token").expect_err("401 must fail");
2374 assert!(format!("{err:#}").contains("unexpected status while querying owners"));
2375 handle.join().expect("join");
2376 }
2377
2378 #[test]
2379 fn list_owners_errors_for_502_bad_gateway() {
2380 let (api_base, handle) = with_server(|req| {
2381 req.respond(Response::empty(StatusCode(502)))
2382 .expect("respond");
2383 });
2384
2385 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2386 let err = cli.list_owners("demo", "token").expect_err("502 must fail");
2387 assert!(format!("{err:#}").contains("unexpected status while querying owners"));
2388 handle.join().expect("join");
2389 }
2390
2391 #[test]
2394 fn rate_limit_429_treated_as_not_visible_in_api_backoff() {
2395 let (api_base, handle) = with_multi_server(
2396 move |req| {
2397 req.respond(Response::empty(StatusCode(429)))
2398 .expect("respond");
2399 },
2400 10,
2401 );
2402
2403 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2404 let config = ReadinessConfig {
2405 enabled: true,
2406 method: ReadinessMethod::Api,
2407 initial_delay: Duration::ZERO,
2408 max_delay: Duration::from_millis(20),
2409 max_total_wait: Duration::from_millis(80),
2410 poll_interval: Duration::from_millis(10),
2411 jitter_factor: 0.0,
2412 index_path: None,
2413 prefer_index: false,
2414 };
2415
2416 let (visible, evidence) = cli
2417 .is_version_visible_with_backoff("demo", "1.0.0", &config)
2418 .expect("backoff");
2419 assert!(!visible);
2420 assert!(evidence.len() >= 2);
2421 assert!(evidence.iter().all(|e| !e.visible));
2422 handle.join().expect("join");
2423 }
2424
2425 #[test]
2426 fn rate_limit_429_then_success_in_backoff() {
2427 use std::sync::Arc;
2428 use std::sync::atomic::{AtomicU32, Ordering};
2429
2430 let counter = Arc::new(AtomicU32::new(0));
2431 let counter_clone = counter.clone();
2432
2433 let (api_base, handle) = with_multi_server(
2434 move |req| {
2435 let n = counter_clone.fetch_add(1, Ordering::SeqCst);
2436 let status = if n < 2 { 429 } else { 200 };
2437 req.respond(Response::empty(StatusCode(status)))
2438 .expect("respond");
2439 },
2440 5,
2441 );
2442
2443 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2444 let config = ReadinessConfig {
2445 enabled: true,
2446 method: ReadinessMethod::Api,
2447 initial_delay: Duration::ZERO,
2448 max_delay: Duration::from_millis(20),
2449 max_total_wait: Duration::from_secs(5),
2450 poll_interval: Duration::from_millis(10),
2451 jitter_factor: 0.0,
2452 index_path: None,
2453 prefer_index: false,
2454 };
2455
2456 let (visible, evidence) = cli
2457 .is_version_visible_with_backoff("demo", "1.0.0", &config)
2458 .expect("backoff");
2459 assert!(visible);
2460 assert!(evidence.len() >= 3);
2461 assert!(!evidence[0].visible);
2462 assert!(!evidence[1].visible);
2463 assert!(evidence.last().unwrap().visible);
2464 handle.join().expect("join");
2465 }
2466
2467 #[test]
2470 fn list_owners_errors_on_empty_response_body() {
2471 let (api_base, handle) = with_server(|req| {
2472 let resp = Response::from_string("")
2473 .with_status_code(StatusCode(200))
2474 .with_header(
2475 tiny_http::Header::from_bytes("Content-Type", "application/json")
2476 .expect("header"),
2477 );
2478 req.respond(resp).expect("respond");
2479 });
2480
2481 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2482 let err = cli
2483 .list_owners("demo", "token")
2484 .expect_err("empty body must fail");
2485 assert!(format!("{err:#}").contains("failed to parse owners JSON"));
2486 handle.join().expect("join");
2487 }
2488
2489 #[test]
2490 fn list_owners_errors_on_html_error_page() {
2491 let (api_base, handle) = with_server(|req| {
2492 let resp = Response::from_string("<html><body>503 Service Unavailable</body></html>")
2493 .with_status_code(StatusCode(200))
2494 .with_header(
2495 tiny_http::Header::from_bytes("Content-Type", "text/html").expect("header"),
2496 );
2497 req.respond(resp).expect("respond");
2498 });
2499
2500 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2501 let err = cli
2502 .list_owners("demo", "token")
2503 .expect_err("html must fail");
2504 assert!(format!("{err:#}").contains("failed to parse owners JSON"));
2505 handle.join().expect("join");
2506 }
2507
2508 #[test]
2509 fn list_owners_parses_response_with_multiple_owners() {
2510 let body = r#"{"users":[
2511 {"id":1,"login":"alice","name":"Alice"},
2512 {"id":2,"login":"bob","name":null},
2513 {"id":3,"login":"charlie","name":"Charlie D."}
2514 ]}"#;
2515
2516 let (api_base, handle) = with_server(move |req| {
2517 let resp = Response::from_string(body)
2518 .with_status_code(StatusCode(200))
2519 .with_header(
2520 tiny_http::Header::from_bytes("Content-Type", "application/json")
2521 .expect("header"),
2522 );
2523 req.respond(resp).expect("respond");
2524 });
2525
2526 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2527 let owners = cli.list_owners("demo", "token").expect("owners");
2528 assert_eq!(owners.users.len(), 3);
2529 assert_eq!(owners.users[0].login, "alice");
2530 assert_eq!(owners.users[1].login, "bob");
2531 assert_eq!(owners.users[2].login, "charlie");
2532 assert!(owners.users[1].name.is_none());
2533 handle.join().expect("join");
2534 }
2535
2536 #[test]
2537 fn list_owners_parses_empty_users_array() {
2538 let (api_base, handle) = with_server(|req| {
2539 let resp = Response::from_string(r#"{"users":[]}"#)
2540 .with_status_code(StatusCode(200))
2541 .with_header(
2542 tiny_http::Header::from_bytes("Content-Type", "application/json")
2543 .expect("header"),
2544 );
2545 req.respond(resp).expect("respond");
2546 });
2547
2548 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2549 let owners = cli.list_owners("demo", "token").expect("owners");
2550 assert!(owners.users.is_empty());
2551 handle.join().expect("join");
2552 }
2553
2554 #[test]
2557 fn list_owners_parses_large_response() {
2558 let mut users = Vec::new();
2559 for i in 0..100 {
2560 users.push(format!(
2561 r#"{{"id":{},"login":"user{}","name":"User {}"}}"#,
2562 i, i, i
2563 ));
2564 }
2565 let body = format!(r#"{{"users":[{}]}}"#, users.join(","));
2566
2567 let (api_base, handle) = with_server(move |req| {
2568 let resp = Response::from_string(body.as_str())
2569 .with_status_code(StatusCode(200))
2570 .with_header(
2571 tiny_http::Header::from_bytes("Content-Type", "application/json")
2572 .expect("header"),
2573 );
2574 req.respond(resp).expect("respond");
2575 });
2576
2577 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2578 let owners = cli.list_owners("demo", "token").expect("owners");
2579 assert_eq!(owners.users.len(), 100);
2580 assert_eq!(owners.users[99].login, "user99");
2581 handle.join().expect("join");
2582 }
2583
2584 #[test]
2585 fn check_index_visibility_with_large_index() {
2586 let mut lines = Vec::new();
2587 for i in 0..500 {
2588 lines.push(format!(r#"{{"vers":"{}.0.0"}}"#, i));
2589 }
2590 let index_content: String = lines.join("\n") + "\n";
2591
2592 let (api_base, handle) = with_server(move |req| {
2593 let resp = Response::from_string(index_content.as_str())
2594 .with_status_code(StatusCode(200))
2595 .with_header(
2596 tiny_http::Header::from_bytes("Content-Type", "application/json")
2597 .expect("header"),
2598 );
2599 req.respond(resp).expect("respond");
2600 });
2601
2602 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
2603 assert!(
2605 cli.check_index_visibility("demo", "499.0.0")
2606 .expect("check")
2607 );
2608 handle.join().expect("join");
2609 }
2610
2611 #[test]
2612 fn parse_version_from_index_with_large_content() {
2613 let (api_base, _handle) = with_server(|req| {
2614 req.respond(Response::empty(StatusCode(200)))
2615 .expect("respond");
2616 });
2617
2618 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2619
2620 let mut lines = Vec::new();
2621 for i in 0..1000 {
2622 lines.push(format!(r#"{{"vers":"0.{}.0"}}"#, i));
2623 }
2624 let content = lines.join("\n") + "\n";
2625
2626 assert!(cli.parse_version_from_index(&content, "0.999.0").unwrap());
2627 assert!(!cli.parse_version_from_index(&content, "0.1000.0").unwrap());
2628 }
2629
2630 #[test]
2633 fn version_exists_errors_on_connection_refused() {
2634 let server = tiny_http::Server::http("127.0.0.1:0").expect("server");
2636 let addr = format!("http://{}", server.server_addr());
2637 drop(server);
2638
2639 let cli = RegistryClient::new(test_registry(addr)).expect("client");
2640 let err = cli
2641 .version_exists("demo", "1.0.0")
2642 .expect_err("connection refused must fail");
2643 assert!(format!("{err:#}").contains("registry request failed"));
2644 }
2645
2646 #[test]
2647 fn crate_exists_errors_on_connection_refused() {
2648 let server = tiny_http::Server::http("127.0.0.1:0").expect("server");
2649 let addr = format!("http://{}", server.server_addr());
2650 drop(server);
2651
2652 let cli = RegistryClient::new(test_registry(addr)).expect("client");
2653 let err = cli
2654 .crate_exists("demo")
2655 .expect_err("connection refused must fail");
2656 assert!(format!("{err:#}").contains("registry request failed"));
2657 }
2658
2659 #[test]
2660 fn list_owners_errors_on_connection_refused() {
2661 let server = tiny_http::Server::http("127.0.0.1:0").expect("server");
2662 let addr = format!("http://{}", server.server_addr());
2663 drop(server);
2664
2665 let cli = RegistryClient::new(test_registry(addr)).expect("client");
2666 let err = cli
2667 .list_owners("demo", "token")
2668 .expect_err("connection refused must fail");
2669 assert!(format!("{err:#}").contains("registry owners request failed"));
2670 }
2671
2672 #[test]
2675 fn fetch_index_file_errors_for_unexpected_status_code() {
2676 let (api_base, handle) = with_server(|req| {
2677 req.respond(Response::empty(StatusCode(500)))
2678 .expect("respond");
2679 });
2680
2681 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
2682 let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
2684 assert!(!visible);
2685 handle.join().expect("join");
2686 }
2687
2688 #[test]
2689 fn check_index_visibility_returns_false_for_429() {
2690 let (api_base, handle) = with_server(|req| {
2691 req.respond(Response::empty(StatusCode(429)))
2692 .expect("respond");
2693 });
2694
2695 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
2696 let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
2697 assert!(!visible);
2698 handle.join().expect("join");
2699 }
2700
2701 #[test]
2702 fn check_index_visibility_returns_false_for_503() {
2703 let (api_base, handle) = with_server(|req| {
2704 req.respond(Response::empty(StatusCode(503)))
2705 .expect("respond");
2706 });
2707
2708 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
2709 let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
2710 assert!(!visible);
2711 handle.join().expect("join");
2712 }
2713
2714 #[test]
2715 fn index_with_304_not_modified_without_cache_returns_error_gracefully() {
2716 let (api_base, handle) = with_server(|req| {
2717 req.respond(Response::empty(StatusCode(304)))
2718 .expect("respond");
2719 });
2720
2721 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
2723 let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
2724 assert!(!visible);
2725 handle.join().expect("join");
2726 }
2727
2728 #[test]
2729 fn index_with_304_not_modified_uses_cache() {
2730 let cache_dir = tempfile::tempdir().expect("tempdir");
2731 let cache_path = cache_dir.path().join("de").join("mo").join("demo");
2732 std::fs::create_dir_all(cache_path.parent().unwrap()).expect("mkdir");
2733 std::fs::write(&cache_path, "{\"vers\":\"2.0.0\"}\n").expect("write cache");
2734
2735 let (api_base, handle) = with_server(|req| {
2736 req.respond(Response::empty(StatusCode(304)))
2737 .expect("respond");
2738 });
2739
2740 let cli = RegistryClient::new(test_registry_with_index(api_base))
2741 .expect("client")
2742 .with_cache_dir(cache_dir.path().to_path_buf());
2743
2744 let visible = cli.check_index_visibility("demo", "2.0.0").expect("check");
2745 assert!(visible);
2746 handle.join().expect("join");
2747 }
2748
2749 #[test]
2750 fn index_200_writes_cache_and_etag() {
2751 let cache_dir = tempfile::tempdir().expect("tempdir");
2752 let index_content = "{\"vers\":\"3.0.0\"}\n";
2753
2754 let (api_base, handle) = with_server(move |req| {
2755 let resp = Response::from_string(index_content)
2756 .with_status_code(StatusCode(200))
2757 .with_header(tiny_http::Header::from_bytes("ETag", "\"abc123\"").expect("header"));
2758 req.respond(resp).expect("respond");
2759 });
2760
2761 let cli = RegistryClient::new(test_registry_with_index(api_base))
2762 .expect("client")
2763 .with_cache_dir(cache_dir.path().to_path_buf());
2764
2765 let visible = cli.check_index_visibility("demo", "3.0.0").expect("check");
2766 assert!(visible);
2767
2768 let cache_path = cache_dir.path().join("de").join("mo").join("demo");
2770 assert!(cache_path.exists());
2771 let cached = std::fs::read_to_string(&cache_path).expect("read cache");
2772 assert!(cached.contains("3.0.0"));
2773
2774 let etag_path = cache_path.with_extension("etag");
2776 assert!(etag_path.exists());
2777 let etag = std::fs::read_to_string(&etag_path).expect("read etag");
2778 assert_eq!(etag, "\"abc123\"");
2779
2780 handle.join().expect("join");
2781 }
2782
2783 #[test]
2784 fn index_sends_etag_as_if_none_match() {
2785 use std::sync::Arc;
2786 use std::sync::Mutex;
2787
2788 let cache_dir = tempfile::tempdir().expect("tempdir");
2789 let cache_path = cache_dir.path().join("de").join("mo").join("demo");
2791 std::fs::create_dir_all(cache_path.parent().unwrap()).expect("mkdir");
2792 std::fs::write(&cache_path, "{\"vers\":\"1.0.0\"}\n").expect("write");
2793 std::fs::write(cache_path.with_extension("etag"), "\"etag-val\"").expect("write etag");
2794
2795 let received_header = Arc::new(Mutex::new(None));
2796 let received_header_clone = received_header.clone();
2797
2798 let (api_base, handle) = with_server(move |req| {
2799 let inm = req
2800 .headers()
2801 .iter()
2802 .find(|h| h.field.equiv("If-None-Match"))
2803 .map(|h| h.value.as_str().to_string());
2804 *received_header_clone.lock().unwrap() = inm;
2805 req.respond(Response::empty(StatusCode(304)))
2806 .expect("respond");
2807 });
2808
2809 let cli = RegistryClient::new(test_registry_with_index(api_base))
2810 .expect("client")
2811 .with_cache_dir(cache_dir.path().to_path_buf());
2812
2813 let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
2814 assert!(visible);
2815
2816 let header = received_header.lock().unwrap().clone();
2817 assert_eq!(header, Some("\"etag-val\"".to_string()));
2818 handle.join().expect("join");
2819 }
2820
2821 #[test]
2824 fn version_exists_with_hyphenated_crate_name() {
2825 let (api_base, handle) = with_server(|req| {
2826 assert_eq!(req.url(), "/api/v1/crates/my-crate/1.0.0");
2827 req.respond(Response::empty(StatusCode(200)))
2828 .expect("respond");
2829 });
2830
2831 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2832 assert!(cli.version_exists("my-crate", "1.0.0").expect("exists"));
2833 handle.join().expect("join");
2834 }
2835
2836 #[test]
2837 fn version_exists_with_underscore_crate_name() {
2838 let (api_base, handle) = with_server(|req| {
2839 assert_eq!(req.url(), "/api/v1/crates/my_crate/2.0.0");
2840 req.respond(Response::empty(StatusCode(200)))
2841 .expect("respond");
2842 });
2843
2844 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2845 assert!(cli.version_exists("my_crate", "2.0.0").expect("exists"));
2846 handle.join().expect("join");
2847 }
2848
2849 #[test]
2850 fn calculate_index_path_for_hyphenated_crate() {
2851 let (api_base, _handle) = with_server(|req| {
2852 req.respond(Response::empty(StatusCode(200)))
2853 .expect("respond");
2854 });
2855
2856 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2857 assert_eq!(cli.calculate_index_path("my-crate"), "my/-c/my-crate");
2858 }
2859
2860 #[test]
2861 fn calculate_index_path_lowercases_mixed_case() {
2862 let (api_base, _handle) = with_server(|req| {
2863 req.respond(Response::empty(StatusCode(200)))
2864 .expect("respond");
2865 });
2866
2867 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2868 assert_eq!(cli.calculate_index_path("MyLib"), "my/li/mylib");
2869 assert_eq!(cli.calculate_index_path("UPPER"), "up/pe/upper");
2870 }
2871
2872 #[test]
2875 fn concurrent_version_exists_checks() {
2876 let (api_base, handle) = with_multi_server(
2877 |req| {
2878 req.respond(Response::empty(StatusCode(200)))
2879 .expect("respond");
2880 },
2881 5,
2882 );
2883
2884 let cli =
2885 std::sync::Arc::new(RegistryClient::new(test_registry(api_base)).expect("client"));
2886
2887 let handles: Vec<_> = (0..5)
2888 .map(|i| {
2889 let cli = cli.clone();
2890 let version = format!("{i}.0.0");
2891 thread::spawn(move || cli.version_exists("demo", &version))
2892 })
2893 .collect();
2894
2895 for h in handles {
2896 let result = h.join().expect("thread join");
2897 assert!(result.expect("version_exists").eq(&true));
2898 }
2899
2900 handle.join().expect("server join");
2901 }
2902
2903 #[test]
2904 fn concurrent_crate_exists_checks() {
2905 let (api_base, handle) = with_multi_server(
2906 |req| {
2907 let url = req.url().to_string();
2908 if url.contains("missing") {
2909 req.respond(Response::empty(StatusCode(404)))
2910 .expect("respond");
2911 } else {
2912 req.respond(Response::empty(StatusCode(200)))
2913 .expect("respond");
2914 }
2915 },
2916 4,
2917 );
2918
2919 let cli =
2920 std::sync::Arc::new(RegistryClient::new(test_registry(api_base)).expect("client"));
2921
2922 let names = ["found1", "found2", "missing1", "missing2"];
2923 let handles: Vec<_> = names
2924 .iter()
2925 .map(|name| {
2926 let cli = cli.clone();
2927 let name = name.to_string();
2928 thread::spawn(move || (name.clone(), cli.crate_exists(&name)))
2929 })
2930 .collect();
2931
2932 for h in handles {
2933 let (name, result) = h.join().expect("thread join");
2934 let exists = result.expect("crate_exists");
2935 if name.contains("missing") {
2936 assert!(!exists, "{name} should not exist");
2937 } else {
2938 assert!(exists, "{name} should exist");
2939 }
2940 }
2941
2942 handle.join().expect("server join");
2943 }
2944
2945 #[test]
2948 fn verify_ownership_returns_false_on_401_unauthorized() {
2949 let (api_base, handle) = with_server(move |req| {
2950 req.respond(Response::empty(StatusCode(401)))
2951 .expect("respond");
2952 });
2953
2954 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2955 let verified = cli.verify_ownership("demo", "fake-token").expect("verify");
2956 assert!(!verified);
2957 handle.join().expect("join");
2958 }
2959
2960 #[test]
2961 fn verify_ownership_propagates_network_error() {
2962 let server = tiny_http::Server::http("127.0.0.1:0").expect("server");
2964 let addr = format!("http://{}", server.server_addr());
2965 drop(server);
2966
2967 let cli = RegistryClient::new(test_registry(addr)).expect("client");
2968 let result = cli.verify_ownership("demo", "token");
2971 assert!(result.is_err());
2972 }
2973
2974 #[test]
2977 fn parse_version_from_index_only_whitespace_lines() {
2978 let (api_base, _handle) = with_server(|req| {
2979 req.respond(Response::empty(StatusCode(200)))
2980 .expect("respond");
2981 });
2982
2983 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2984 let content = " \n \n\t\n";
2985 assert!(!cli.parse_version_from_index(content, "1.0.0").unwrap());
2986 }
2987
2988 #[test]
2989 fn parse_version_from_index_mixed_valid_and_garbage_lines() {
2990 let (api_base, _handle) = with_server(|req| {
2991 req.respond(Response::empty(StatusCode(200)))
2992 .expect("respond");
2993 });
2994
2995 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2996 let content = "garbage\n{\"vers\":\"1.0.0\"}\n<<invalid>>\n{\"vers\":\"2.0.0\"}\nnull\n";
2997
2998 assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
2999 assert!(cli.parse_version_from_index(content, "2.0.0").unwrap());
3000 assert!(!cli.parse_version_from_index(content, "3.0.0").unwrap());
3001 }
3002
3003 #[test]
3004 fn parse_version_from_index_json_array_instead_of_jsonl() {
3005 let (api_base, _handle) = with_server(|req| {
3006 req.respond(Response::empty(StatusCode(200)))
3007 .expect("respond");
3008 });
3009
3010 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3011 let content = r#"[{"vers":"1.0.0"},{"vers":"2.0.0"}]"#;
3013 assert!(!cli.parse_version_from_index(content, "1.0.0").unwrap());
3014 }
3015
3016 #[test]
3017 fn parse_version_from_index_extra_fields_ignored() {
3018 let (api_base, _handle) = with_server(|req| {
3019 req.respond(Response::empty(StatusCode(200)))
3020 .expect("respond");
3021 });
3022
3023 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3024 let content = r#"{"name":"demo","vers":"1.0.0","cksum":"abc123","deps":[],"features":{},"yanked":false}"#;
3025 assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
3027 }
3028
3029 #[test]
3032 fn calculate_backoff_delay_zero_base() {
3033 let (api_base, _handle) = with_server(|req| {
3034 req.respond(Response::empty(StatusCode(200)))
3035 .expect("respond");
3036 });
3037
3038 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3039 let delay = cli.calculate_backoff_delay(Duration::ZERO, Duration::from_secs(10), 5, 0.0);
3040 assert_eq!(delay, Duration::ZERO);
3041 }
3042
3043 #[test]
3044 fn calculate_backoff_delay_zero_max() {
3045 let (api_base, _handle) = with_server(|req| {
3046 req.respond(Response::empty(StatusCode(200)))
3047 .expect("respond");
3048 });
3049
3050 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3051 let delay = cli.calculate_backoff_delay(Duration::from_millis(100), Duration::ZERO, 3, 0.0);
3052 assert_eq!(delay, Duration::ZERO);
3053 }
3054
3055 #[test]
3056 fn calculate_backoff_delay_attempt_overflow_is_safe() {
3057 let (api_base, _handle) = with_server(|req| {
3058 req.respond(Response::empty(StatusCode(200)))
3059 .expect("respond");
3060 });
3061
3062 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3063 let delay = cli.calculate_backoff_delay(
3065 Duration::from_millis(100),
3066 Duration::from_secs(60),
3067 u32::MAX,
3068 0.0,
3069 );
3070 assert!(delay <= Duration::from_secs(60));
3071 }
3072
3073 #[test]
3074 fn calculate_backoff_delay_full_jitter_stays_in_range() {
3075 let (api_base, _handle) = with_server(|req| {
3076 req.respond(Response::empty(StatusCode(200)))
3077 .expect("respond");
3078 });
3079
3080 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3081 for _ in 0..50 {
3083 let delay = cli.calculate_backoff_delay(
3084 Duration::from_millis(100),
3085 Duration::from_secs(10),
3086 1,
3087 1.0,
3088 );
3089 assert!(delay <= Duration::from_millis(200));
3090 }
3091 }
3092
3093 #[test]
3096 fn version_exists_normalizes_trailing_slash() {
3097 let (api_base, handle) = with_server(|req| {
3098 assert_eq!(req.url(), "/api/v1/crates/demo/1.0.0");
3099 req.respond(Response::empty(StatusCode(200)))
3100 .expect("respond");
3101 });
3102
3103 let registry = Registry {
3104 name: "test".to_string(),
3105 api_base: format!("{}/", api_base),
3106 index_base: None,
3107 };
3108
3109 let cli = RegistryClient::new(registry).expect("client");
3110 assert!(cli.version_exists("demo", "1.0.0").expect("exists"));
3111 handle.join().expect("join");
3112 }
3113
3114 #[test]
3115 fn crate_exists_normalizes_trailing_slash() {
3116 let (api_base, handle) = with_server(|req| {
3117 assert_eq!(req.url(), "/api/v1/crates/demo");
3118 req.respond(Response::empty(StatusCode(200)))
3119 .expect("respond");
3120 });
3121
3122 let registry = Registry {
3123 name: "test".to_string(),
3124 api_base: format!("{}/", api_base),
3125 index_base: None,
3126 };
3127
3128 let cli = RegistryClient::new(registry).expect("client");
3129 assert!(cli.crate_exists("demo").expect("exists"));
3130 handle.join().expect("join");
3131 }
3132
3133 #[test]
3136 fn check_new_crate_propagates_server_errors() {
3137 let (api_base, handle) = with_server(|req| {
3138 req.respond(Response::empty(StatusCode(500)))
3139 .expect("respond");
3140 });
3141
3142 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3143 let err = cli.check_new_crate("demo").expect_err("500 must propagate");
3144 assert!(format!("{err:#}").contains("unexpected status"));
3145 handle.join().expect("join");
3146 }
3147
3148 #[test]
3151 fn backoff_handles_alternating_404_and_500_then_success() {
3152 use std::sync::Arc;
3153 use std::sync::atomic::{AtomicU32, Ordering};
3154
3155 let counter = Arc::new(AtomicU32::new(0));
3156 let counter_clone = counter.clone();
3157
3158 let (api_base, handle) = with_multi_server(
3159 move |req| {
3160 let n = counter_clone.fetch_add(1, Ordering::SeqCst);
3161 let status = match n {
3162 0 => 404,
3163 1 => 500,
3164 2 => 404,
3165 _ => 200,
3166 };
3167 req.respond(Response::empty(StatusCode(status)))
3168 .expect("respond");
3169 },
3170 6,
3171 );
3172
3173 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3174 let config = ReadinessConfig {
3175 enabled: true,
3176 method: ReadinessMethod::Api,
3177 initial_delay: Duration::ZERO,
3178 max_delay: Duration::from_millis(20),
3179 max_total_wait: Duration::from_secs(5),
3180 poll_interval: Duration::from_millis(10),
3181 jitter_factor: 0.0,
3182 index_path: None,
3183 prefer_index: false,
3184 };
3185
3186 let (visible, evidence) = cli
3187 .is_version_visible_with_backoff("demo", "1.0.0", &config)
3188 .expect("backoff");
3189 assert!(visible);
3190 assert!(evidence.len() >= 4);
3191 assert!(!evidence[0].visible);
3192 assert!(!evidence[1].visible);
3193 assert!(!evidence[2].visible);
3194 assert!(evidence.last().unwrap().visible);
3195 handle.join().expect("join");
3196 }
3197
3198 #[test]
3201 fn index_mode_backoff_uses_cached_content_on_304() {
3202 use std::sync::Arc;
3203 use std::sync::atomic::{AtomicU32, Ordering};
3204
3205 let cache_dir = tempfile::tempdir().expect("tempdir");
3206 let counter = Arc::new(AtomicU32::new(0));
3207 let counter_clone = counter.clone();
3208
3209 let (api_base, handle) = with_multi_server(
3210 move |req| {
3211 let n = counter_clone.fetch_add(1, Ordering::SeqCst);
3212 if n == 0 {
3213 let resp = Response::from_string("{\"vers\":\"0.9.0\"}\n")
3215 .with_status_code(StatusCode(200))
3216 .with_header(
3217 tiny_http::Header::from_bytes("ETag", "\"v1\"").expect("header"),
3218 );
3219 req.respond(resp).expect("respond");
3220 } else {
3221 let resp =
3223 Response::from_string("{\"vers\":\"0.9.0\"}\n{\"vers\":\"1.0.0\"}\n")
3224 .with_status_code(StatusCode(200))
3225 .with_header(
3226 tiny_http::Header::from_bytes("ETag", "\"v2\"").expect("header"),
3227 );
3228 req.respond(resp).expect("respond");
3229 }
3230 },
3231 5,
3232 );
3233
3234 let cli = RegistryClient::new(test_registry_with_index(api_base))
3235 .expect("client")
3236 .with_cache_dir(cache_dir.path().to_path_buf());
3237
3238 let config = ReadinessConfig {
3239 enabled: true,
3240 method: ReadinessMethod::Index,
3241 initial_delay: Duration::ZERO,
3242 max_delay: Duration::from_millis(30),
3243 max_total_wait: Duration::from_secs(5),
3244 poll_interval: Duration::from_millis(10),
3245 jitter_factor: 0.0,
3246 index_path: None,
3247 prefer_index: false,
3248 };
3249
3250 let (visible, evidence) = cli
3251 .is_version_visible_with_backoff("demo", "1.0.0", &config)
3252 .expect("backoff");
3253 assert!(visible);
3254 assert!(evidence.len() >= 2);
3255 assert!(!evidence[0].visible);
3256 assert!(evidence.last().unwrap().visible);
3257 handle.join().expect("join");
3258 }
3259
3260 #[test]
3263 fn with_cache_dir_sets_cache_directory() {
3264 let registry = Registry {
3265 name: "test".to_string(),
3266 api_base: "https://example.com".to_string(),
3267 index_base: None,
3268 };
3269 let cli = RegistryClient::new(registry)
3270 .expect("client")
3271 .with_cache_dir(std::path::PathBuf::from("/tmp/test-cache"));
3272 assert_eq!(cli.registry().name, "test");
3274 }
3275
3276 #[test]
3277 fn registry_accessor_returns_correct_values() {
3278 let (api_base, _handle) = with_server(|req| {
3279 req.respond(Response::empty(StatusCode(200)))
3280 .expect("respond");
3281 });
3282
3283 let registry = Registry {
3284 name: "custom-registry".to_string(),
3285 api_base: api_base.clone(),
3286 index_base: Some("https://index.custom.io".to_string()),
3287 };
3288
3289 let cli = RegistryClient::new(registry).expect("client");
3290 assert_eq!(cli.registry().name, "custom-registry");
3291 assert_eq!(cli.registry().api_base, api_base);
3292 assert_eq!(
3293 cli.registry().index_base.as_deref(),
3294 Some("https://index.custom.io")
3295 );
3296 }
3297
3298 #[test]
3301 fn registry_get_index_base_strips_sparse_prefix() {
3302 let registry = Registry {
3303 name: "test".to_string(),
3304 api_base: "https://example.com".to_string(),
3305 index_base: Some("sparse+https://index.example.com".to_string()),
3306 };
3307
3308 assert_eq!(registry.get_index_base(), "https://index.example.com");
3309 }
3310
3311 #[test]
3312 fn registry_get_index_base_leaves_non_sparse_prefix() {
3313 let registry = Registry {
3314 name: "test".to_string(),
3315 api_base: "https://example.com".to_string(),
3316 index_base: Some("https://index.example.com".to_string()),
3317 };
3318
3319 assert_eq!(registry.get_index_base(), "https://index.example.com");
3320 }
3321
3322 #[test]
3329 fn version_exists_429_with_retry_after_header_still_errors() {
3330 let (api_base, handle) = with_server(|req| {
3331 let resp = Response::from_string("rate limited")
3332 .with_status_code(StatusCode(429))
3333 .with_header(tiny_http::Header::from_bytes("Retry-After", "30").expect("header"));
3334 req.respond(resp).expect("respond");
3335 });
3336
3337 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3338 let err = cli
3339 .version_exists("demo", "1.0.0")
3340 .expect_err("429 with Retry-After must fail");
3341 let msg = format!("{err:#}");
3342 assert!(msg.contains("unexpected status"));
3343 assert!(msg.contains("429"));
3344 handle.join().expect("join");
3345 }
3346
3347 #[test]
3348 fn crate_exists_429_with_retry_after_header_still_errors() {
3349 let (api_base, handle) = with_server(|req| {
3350 let resp = Response::from_string("rate limited")
3351 .with_status_code(StatusCode(429))
3352 .with_header(tiny_http::Header::from_bytes("Retry-After", "60").expect("header"));
3353 req.respond(resp).expect("respond");
3354 });
3355
3356 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3357 let err = cli
3358 .crate_exists("demo")
3359 .expect_err("429 with Retry-After must fail");
3360 let msg = format!("{err:#}");
3361 assert!(msg.contains("unexpected status"));
3362 handle.join().expect("join");
3363 }
3364
3365 #[test]
3366 fn list_owners_429_with_retry_after_header_still_errors() {
3367 let (api_base, handle) = with_server(|req| {
3368 let resp = Response::from_string(r#"{"errors":[{"detail":"rate limited"}]}"#)
3369 .with_status_code(StatusCode(429))
3370 .with_header(tiny_http::Header::from_bytes("Retry-After", "120").expect("header"));
3371 req.respond(resp).expect("respond");
3372 });
3373
3374 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3375 let err = cli
3376 .list_owners("demo", "token")
3377 .expect_err("429 with Retry-After must fail");
3378 assert!(format!("{err:#}").contains("unexpected status while querying owners"));
3379 handle.join().expect("join");
3380 }
3381
3382 #[test]
3385 fn version_exists_error_message_includes_status_code_text() {
3386 for code in [500, 502, 503] {
3387 let (api_base, handle) = with_server(move |req| {
3388 req.respond(Response::empty(StatusCode(code)))
3389 .expect("respond");
3390 });
3391
3392 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3393 let err = cli
3394 .version_exists("demo", "1.0.0")
3395 .expect_err("server error must fail");
3396 let msg = format!("{err:#}");
3397 assert!(
3398 msg.contains("unexpected status"),
3399 "error for {code} should mention unexpected status: {msg}"
3400 );
3401 handle.join().expect("join");
3402 }
3403 }
3404
3405 #[test]
3406 fn crate_exists_error_message_includes_status_code_text() {
3407 for code in [500, 502, 503] {
3408 let (api_base, handle) = with_server(move |req| {
3409 req.respond(Response::empty(StatusCode(code)))
3410 .expect("respond");
3411 });
3412
3413 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3414 let err = cli
3415 .crate_exists("demo")
3416 .expect_err("server error must fail");
3417 let msg = format!("{err:#}");
3418 assert!(
3419 msg.contains("unexpected status"),
3420 "error for {code} should mention unexpected status: {msg}"
3421 );
3422 handle.join().expect("join");
3423 }
3424 }
3425
3426 #[test]
3427 fn list_owners_errors_for_503_service_unavailable() {
3428 let (api_base, handle) = with_server(|req| {
3429 req.respond(Response::empty(StatusCode(503)))
3430 .expect("respond");
3431 });
3432
3433 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3434 let err = cli.list_owners("demo", "token").expect_err("503 must fail");
3435 assert!(format!("{err:#}").contains("unexpected status while querying owners"));
3436 handle.join().expect("join");
3437 }
3438
3439 #[test]
3442 fn list_owners_ignores_unknown_extra_fields_in_json() {
3443 let body = r#"{"users":[{"id":1,"login":"alice","name":"Alice","avatar":"http://img.example.com/a.png","kind":"user","url":"https://crates.io/users/alice"}],"meta":{"total":1}}"#;
3444
3445 let (api_base, handle) = with_server(move |req| {
3446 let resp = Response::from_string(body)
3447 .with_status_code(StatusCode(200))
3448 .with_header(
3449 tiny_http::Header::from_bytes("Content-Type", "application/json")
3450 .expect("header"),
3451 );
3452 req.respond(resp).expect("respond");
3453 });
3454
3455 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3456 let owners = cli
3457 .list_owners("demo", "token")
3458 .expect("should parse despite extra fields");
3459 assert_eq!(owners.users.len(), 1);
3460 assert_eq!(owners.users[0].login, "alice");
3461 handle.join().expect("join");
3462 }
3463
3464 #[test]
3465 fn list_owners_parses_response_with_special_chars_in_name() {
3466 let body = r#"{"users":[{"id":1,"login":"user-ñ","name":"José García 日本語"}]}"#;
3467
3468 let (api_base, handle) = with_server(move |req| {
3469 let resp = Response::from_string(body)
3470 .with_status_code(StatusCode(200))
3471 .with_header(
3472 tiny_http::Header::from_bytes("Content-Type", "application/json")
3473 .expect("header"),
3474 );
3475 req.respond(resp).expect("respond");
3476 });
3477
3478 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3479 let owners = cli.list_owners("demo", "token").expect("owners");
3480 assert_eq!(owners.users[0].login, "user-ñ");
3481 assert_eq!(owners.users[0].name.as_deref(), Some("José García 日本語"));
3482 handle.join().expect("join");
3483 }
3484
3485 #[test]
3486 fn list_owners_errors_on_null_json_body() {
3487 let (api_base, handle) = with_server(|req| {
3488 let resp = Response::from_string("null")
3489 .with_status_code(StatusCode(200))
3490 .with_header(
3491 tiny_http::Header::from_bytes("Content-Type", "application/json")
3492 .expect("header"),
3493 );
3494 req.respond(resp).expect("respond");
3495 });
3496
3497 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3498 let err = cli
3499 .list_owners("demo", "token")
3500 .expect_err("null body must fail");
3501 assert!(format!("{err:#}").contains("failed to parse owners JSON"));
3502 handle.join().expect("join");
3503 }
3504
3505 #[test]
3506 fn list_owners_errors_on_json_array_instead_of_object() {
3507 let (api_base, handle) = with_server(|req| {
3508 let resp = Response::from_string(r#"[{"id":1,"login":"alice","name":null}]"#)
3509 .with_status_code(StatusCode(200))
3510 .with_header(
3511 tiny_http::Header::from_bytes("Content-Type", "application/json")
3512 .expect("header"),
3513 );
3514 req.respond(resp).expect("respond");
3515 });
3516
3517 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3518 let err = cli
3519 .list_owners("demo", "token")
3520 .expect_err("array body must fail");
3521 assert!(format!("{err:#}").contains("failed to parse owners JSON"));
3522 handle.join().expect("join");
3523 }
3524
3525 #[test]
3528 fn parse_version_from_index_build_metadata_versions() {
3529 let (api_base, _handle) = with_server(|req| {
3530 req.respond(Response::empty(StatusCode(200)))
3531 .expect("respond");
3532 });
3533
3534 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3535
3536 let content =
3537 "{\"vers\":\"1.0.0+build.1\"}\n{\"vers\":\"1.0.0+build.2\"}\n{\"vers\":\"1.0.0\"}\n";
3538
3539 assert!(
3540 cli.parse_version_from_index(content, "1.0.0+build.1")
3541 .unwrap()
3542 );
3543 assert!(
3544 cli.parse_version_from_index(content, "1.0.0+build.2")
3545 .unwrap()
3546 );
3547 assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
3548 assert!(
3550 !cli.parse_version_from_index(content, "1.0.0+build.3")
3551 .unwrap()
3552 );
3553 }
3554
3555 #[test]
3556 fn parse_version_from_index_leading_v_not_matched() {
3557 let (api_base, _handle) = with_server(|req| {
3558 req.respond(Response::empty(StatusCode(200)))
3559 .expect("respond");
3560 });
3561
3562 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3563
3564 let content = "{\"vers\":\"1.0.0\"}\n";
3565 assert!(!cli.parse_version_from_index(content, "v1.0.0").unwrap());
3567 }
3568
3569 #[test]
3570 fn parse_version_from_index_yanked_field_does_not_affect_match() {
3571 let (api_base, _handle) = with_server(|req| {
3572 req.respond(Response::empty(StatusCode(200)))
3573 .expect("respond");
3574 });
3575
3576 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3577
3578 let content =
3579 "{\"vers\":\"1.0.0\",\"yanked\":true}\n{\"vers\":\"2.0.0\",\"yanked\":false}\n";
3580 assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
3582 assert!(cli.parse_version_from_index(content, "2.0.0").unwrap());
3583 }
3584
3585 #[test]
3586 fn parse_version_from_index_many_prerelease_identifiers() {
3587 let (api_base, _handle) = with_server(|req| {
3588 req.respond(Response::empty(StatusCode(200)))
3589 .expect("respond");
3590 });
3591
3592 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3593
3594 let content = "{\"vers\":\"1.0.0-alpha.1.beta.2.rc.3\"}\n";
3595 assert!(
3596 cli.parse_version_from_index(content, "1.0.0-alpha.1.beta.2.rc.3")
3597 .unwrap()
3598 );
3599 assert!(
3600 !cli.parse_version_from_index(content, "1.0.0-alpha.1.beta.2.rc.4")
3601 .unwrap()
3602 );
3603 }
3604
3605 #[test]
3606 fn parse_version_from_index_null_vers_field_skipped() {
3607 let (api_base, _handle) = with_server(|req| {
3608 req.respond(Response::empty(StatusCode(200)))
3609 .expect("respond");
3610 });
3611
3612 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3613
3614 let content = "{\"vers\":null}\n{\"vers\":\"1.0.0\"}\n";
3615 assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
3616 assert!(!cli.parse_version_from_index(content, "null").unwrap());
3617 }
3618
3619 #[test]
3620 fn parse_version_from_index_numeric_vers_field_skipped() {
3621 let (api_base, _handle) = with_server(|req| {
3622 req.respond(Response::empty(StatusCode(200)))
3623 .expect("respond");
3624 });
3625
3626 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3627
3628 let content = "{\"vers\":100}\n{\"vers\":\"2.0.0\"}\n";
3630 assert!(cli.parse_version_from_index(content, "2.0.0").unwrap());
3631 assert!(!cli.parse_version_from_index(content, "100").unwrap());
3632 }
3633
3634 #[test]
3637 fn verify_ownership_propagates_500_server_error() {
3638 let (api_base, handle) = with_server(move |req| {
3639 req.respond(Response::empty(StatusCode(500)))
3640 .expect("respond");
3641 });
3642
3643 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3644 let result = cli.verify_ownership("demo", "token");
3646 assert!(result.is_err());
3647 handle.join().expect("join");
3648 }
3649
3650 #[test]
3651 fn verify_ownership_propagates_429_rate_limit() {
3652 let (api_base, handle) = with_server(move |req| {
3653 req.respond(Response::empty(StatusCode(429)))
3654 .expect("respond");
3655 });
3656
3657 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3658 let result = cli.verify_ownership("demo", "token");
3660 assert!(result.is_err());
3661 handle.join().expect("join");
3662 }
3663
3664 #[test]
3665 fn verify_ownership_returns_true_with_single_owner() {
3666 let body = r#"{"users":[{"id":42,"login":"sole-owner","name":"Only Me"}]}"#;
3667
3668 let (api_base, handle) = with_server(move |req| {
3669 let resp = Response::from_string(body)
3670 .with_status_code(StatusCode(200))
3671 .with_header(
3672 tiny_http::Header::from_bytes("Content-Type", "application/json")
3673 .expect("header"),
3674 );
3675 req.respond(resp).expect("respond");
3676 });
3677
3678 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3679 assert!(cli.verify_ownership("demo", "token").expect("verify"));
3680 handle.join().expect("join");
3681 }
3682
3683 #[test]
3684 fn verify_ownership_returns_true_with_many_owners() {
3685 let mut users = Vec::new();
3686 for i in 0..20 {
3687 users.push(format!(
3688 r#"{{"id":{},"login":"owner{}","name":null}}"#,
3689 i, i
3690 ));
3691 }
3692 let body = format!(r#"{{"users":[{}]}}"#, users.join(","));
3693
3694 let (api_base, handle) = with_server(move |req| {
3695 let resp = Response::from_string(body.as_str())
3696 .with_status_code(StatusCode(200))
3697 .with_header(
3698 tiny_http::Header::from_bytes("Content-Type", "application/json")
3699 .expect("header"),
3700 );
3701 req.respond(resp).expect("respond");
3702 });
3703
3704 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3705 assert!(cli.verify_ownership("demo", "token").expect("verify"));
3706 handle.join().expect("join");
3707 }
3708
3709 #[test]
3710 fn verify_ownership_returns_true_even_with_empty_owners_list() {
3711 let body = r#"{"users":[]}"#;
3712
3713 let (api_base, handle) = with_server(move |req| {
3714 let resp = Response::from_string(body)
3715 .with_status_code(StatusCode(200))
3716 .with_header(
3717 tiny_http::Header::from_bytes("Content-Type", "application/json")
3718 .expect("header"),
3719 );
3720 req.respond(resp).expect("respond");
3721 });
3722
3723 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3724 assert!(cli.verify_ownership("demo", "token").expect("verify"));
3726 handle.join().expect("join");
3727 }
3728
3729 #[test]
3732 fn index_200_without_etag_header_still_caches_content() {
3733 let cache_dir = tempfile::tempdir().expect("tempdir");
3734 let index_content = "{\"vers\":\"1.0.0\"}\n";
3735
3736 let (api_base, handle) = with_server(move |req| {
3737 let resp = Response::from_string(index_content).with_status_code(StatusCode(200));
3739 req.respond(resp).expect("respond");
3740 });
3741
3742 let cli = RegistryClient::new(test_registry_with_index(api_base))
3743 .expect("client")
3744 .with_cache_dir(cache_dir.path().to_path_buf());
3745
3746 let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
3747 assert!(visible);
3748
3749 let cache_path = cache_dir.path().join("de").join("mo").join("demo");
3751 assert!(cache_path.exists());
3752
3753 let etag_path = cache_path.with_extension("etag");
3755 assert!(!etag_path.exists());
3756
3757 handle.join().expect("join");
3758 }
3759
3760 #[test]
3761 fn index_cache_populated_on_first_200_used_on_subsequent_304() {
3762 use std::sync::Arc;
3763 use std::sync::atomic::{AtomicU32, Ordering};
3764
3765 let cache_dir = tempfile::tempdir().expect("tempdir");
3766 let counter = Arc::new(AtomicU32::new(0));
3767 let counter_clone = counter.clone();
3768
3769 let (api_base, handle) = with_multi_server(
3770 move |req| {
3771 let n = counter_clone.fetch_add(1, Ordering::SeqCst);
3772 if n == 0 {
3773 let resp = Response::from_string("{\"vers\":\"1.0.0\"}\n")
3775 .with_status_code(StatusCode(200))
3776 .with_header(
3777 tiny_http::Header::from_bytes("ETag", "\"first\"").expect("header"),
3778 );
3779 req.respond(resp).expect("respond");
3780 } else {
3781 req.respond(Response::empty(StatusCode(304)))
3783 .expect("respond");
3784 }
3785 },
3786 3,
3787 );
3788
3789 let cli = RegistryClient::new(test_registry_with_index(api_base))
3790 .expect("client")
3791 .with_cache_dir(cache_dir.path().to_path_buf());
3792
3793 assert!(cli.check_index_visibility("demo", "1.0.0").expect("1st"));
3795 assert!(cli.check_index_visibility("demo", "1.0.0").expect("2nd"));
3797
3798 handle.join().expect("join");
3799 }
3800
3801 #[test]
3804 fn backoff_429_then_500_then_success() {
3805 use std::sync::Arc;
3806 use std::sync::atomic::{AtomicU32, Ordering};
3807
3808 let counter = Arc::new(AtomicU32::new(0));
3809 let counter_clone = counter.clone();
3810
3811 let (api_base, handle) = with_multi_server(
3812 move |req| {
3813 let n = counter_clone.fetch_add(1, Ordering::SeqCst);
3814 let status = match n {
3815 0 => 429, 1 => 500, _ => 200, };
3819 req.respond(Response::empty(StatusCode(status)))
3820 .expect("respond");
3821 },
3822 5,
3823 );
3824
3825 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3826 let config = ReadinessConfig {
3827 enabled: true,
3828 method: ReadinessMethod::Api,
3829 initial_delay: Duration::ZERO,
3830 max_delay: Duration::from_millis(20),
3831 max_total_wait: Duration::from_secs(5),
3832 poll_interval: Duration::from_millis(10),
3833 jitter_factor: 0.0,
3834 index_path: None,
3835 prefer_index: false,
3836 };
3837
3838 let (visible, evidence) = cli
3839 .is_version_visible_with_backoff("demo", "1.0.0", &config)
3840 .expect("backoff");
3841 assert!(visible);
3842 assert!(evidence.len() >= 3);
3843 assert!(!evidence[0].visible);
3845 assert!(!evidence[1].visible);
3846 assert!(evidence.last().unwrap().visible);
3847 handle.join().expect("join");
3848 }
3849
3850 #[test]
3851 fn backoff_index_mode_with_server_errors_gracefully_degrades() {
3852 let (api_base, handle) = with_multi_server(
3853 move |req| {
3854 req.respond(Response::empty(StatusCode(502)))
3856 .expect("respond");
3857 },
3858 10,
3859 );
3860
3861 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
3862 let config = ReadinessConfig {
3863 enabled: true,
3864 method: ReadinessMethod::Index,
3865 initial_delay: Duration::ZERO,
3866 max_delay: Duration::from_millis(20),
3867 max_total_wait: Duration::from_millis(80),
3868 poll_interval: Duration::from_millis(10),
3869 jitter_factor: 0.0,
3870 index_path: None,
3871 prefer_index: false,
3872 };
3873
3874 let (visible, evidence) = cli
3875 .is_version_visible_with_backoff("demo", "1.0.0", &config)
3876 .expect("should not error");
3877 assert!(!visible);
3878 assert!(evidence.len() >= 2);
3880 handle.join().expect("join");
3881 }
3882
3883 #[test]
3886 fn snapshot_readiness_evidence_multi_attempt() {
3887 use std::sync::Arc;
3888 use std::sync::atomic::{AtomicU32, Ordering};
3889
3890 let counter = Arc::new(AtomicU32::new(0));
3891 let counter_clone = counter.clone();
3892
3893 let (api_base, handle) = with_multi_server(
3894 move |req| {
3895 let n = counter_clone.fetch_add(1, Ordering::SeqCst);
3896 let status = if n < 2 { 404 } else { 200 };
3897 req.respond(Response::empty(StatusCode(status)))
3898 .expect("respond");
3899 },
3900 5,
3901 );
3902
3903 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3904 let config = ReadinessConfig {
3905 enabled: true,
3906 method: ReadinessMethod::Api,
3907 initial_delay: Duration::ZERO,
3908 max_delay: Duration::from_millis(50),
3909 max_total_wait: Duration::from_secs(5),
3910 poll_interval: Duration::from_millis(10),
3911 jitter_factor: 0.0,
3912 index_path: None,
3913 prefer_index: false,
3914 };
3915
3916 let (visible, evidence) = cli
3917 .is_version_visible_with_backoff("demo", "1.0.0", &config)
3918 .expect("backoff");
3919 assert!(visible);
3920 assert_eq!(evidence.len(), 3);
3921
3922 insta::assert_debug_snapshot!(
3923 "readiness_evidence_multi_attempt",
3924 evidence
3925 .iter()
3926 .map(|e| {
3927 format!(
3928 "attempt={} visible={} delay_before={}ms",
3929 e.attempt,
3930 e.visible,
3931 e.delay_before.as_millis()
3932 )
3933 })
3934 .collect::<Vec<_>>()
3935 );
3936 handle.join().expect("join");
3937 }
3938
3939 #[test]
3940 fn snapshot_owners_empty_users() {
3941 let (api_base, handle) = with_server(|req| {
3942 let resp = Response::from_string(r#"{"users":[]}"#)
3943 .with_status_code(StatusCode(200))
3944 .with_header(
3945 tiny_http::Header::from_bytes("Content-Type", "application/json")
3946 .expect("header"),
3947 );
3948 req.respond(resp).expect("respond");
3949 });
3950
3951 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3952 let owners = cli.list_owners("demo", "token").expect("owners");
3953 insta::assert_debug_snapshot!("owners_empty_users", owners);
3954 handle.join().expect("join");
3955 }
3956
3957 #[test]
3958 fn snapshot_owners_multiple_with_mixed_names() {
3959 let body = r#"{"users":[{"id":1,"login":"alice","name":"Alice A."},{"id":2,"login":"bob","name":null},{"id":3,"login":"team:rust-lang","name":"Rust Team"}]}"#;
3960
3961 let (api_base, handle) = with_server(move |req| {
3962 let resp = Response::from_string(body)
3963 .with_status_code(StatusCode(200))
3964 .with_header(
3965 tiny_http::Header::from_bytes("Content-Type", "application/json")
3966 .expect("header"),
3967 );
3968 req.respond(resp).expect("respond");
3969 });
3970
3971 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3972 let owners = cli.list_owners("demo", "token").expect("owners");
3973 insta::assert_debug_snapshot!("owners_multiple_with_mixed_names", owners);
3974 handle.join().expect("join");
3975 }
3976
3977 #[test]
3978 fn snapshot_registry_debug_repr() {
3979 let registry = Registry {
3980 name: "crates-io".to_string(),
3981 api_base: "https://crates.io".to_string(),
3982 index_base: Some("https://index.crates.io".to_string()),
3983 };
3984 insta::assert_debug_snapshot!("registry_debug_repr", registry);
3985 }
3986
3987 #[test]
3988 fn snapshot_registry_debug_repr_no_index() {
3989 let registry = Registry {
3990 name: "private".to_string(),
3991 api_base: "https://registry.example.com".to_string(),
3992 index_base: None,
3993 };
3994 insta::assert_debug_snapshot!("registry_debug_repr_no_index", registry);
3995 }
3996
3997 #[test]
4004 fn rate_limit_429_backoff_retries_multiple_times_before_success() {
4005 use std::sync::Arc;
4006 use std::sync::atomic::{AtomicU32, Ordering};
4007
4008 let counter = Arc::new(AtomicU32::new(0));
4009 let counter_clone = counter.clone();
4010
4011 let (api_base, handle) = with_multi_server(
4012 move |req| {
4013 let n = counter_clone.fetch_add(1, Ordering::SeqCst);
4014 let status = if n < 4 { 429 } else { 200 };
4016 req.respond(Response::empty(StatusCode(status)))
4017 .expect("respond");
4018 },
4019 8,
4020 );
4021
4022 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4023 let config = ReadinessConfig {
4024 enabled: true,
4025 method: ReadinessMethod::Api,
4026 initial_delay: Duration::ZERO,
4027 max_delay: Duration::from_millis(20),
4028 max_total_wait: Duration::from_secs(5),
4029 poll_interval: Duration::from_millis(10),
4030 jitter_factor: 0.0,
4031 index_path: None,
4032 prefer_index: false,
4033 };
4034
4035 let (visible, evidence) = cli
4036 .is_version_visible_with_backoff("demo", "1.0.0", &config)
4037 .expect("backoff");
4038 assert!(visible);
4039 assert!(
4041 evidence.len() >= 5,
4042 "expected >=5 attempts, got {}",
4043 evidence.len()
4044 );
4045 assert!(evidence[..4].iter().all(|e| !e.visible));
4046 assert!(evidence.last().unwrap().visible);
4047 handle.join().expect("join");
4048 }
4049
4050 #[test]
4051 fn rate_limit_429_continuous_causes_timeout() {
4052 use std::sync::Arc;
4053 use std::sync::atomic::{AtomicU32, Ordering};
4054
4055 let request_count = Arc::new(AtomicU32::new(0));
4056 let request_count_clone = request_count.clone();
4057
4058 let (api_base, handle) = with_multi_server(
4059 move |req| {
4060 request_count_clone.fetch_add(1, Ordering::SeqCst);
4061 req.respond(Response::empty(StatusCode(429)))
4062 .expect("respond");
4063 },
4064 30,
4065 );
4066
4067 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4068 let config = ReadinessConfig {
4069 enabled: true,
4070 method: ReadinessMethod::Api,
4071 initial_delay: Duration::ZERO,
4072 max_delay: Duration::from_millis(15),
4073 max_total_wait: Duration::from_millis(60),
4074 poll_interval: Duration::from_millis(10),
4075 jitter_factor: 0.0,
4076 index_path: None,
4077 prefer_index: false,
4078 };
4079
4080 let (visible, evidence) = cli
4081 .is_version_visible_with_backoff("demo", "1.0.0", &config)
4082 .expect("backoff");
4083 assert!(!visible);
4084 let total_requests = request_count.load(Ordering::SeqCst);
4086 assert!(
4087 total_requests >= 2,
4088 "expected at least 2 requests during rate limiting, got {}",
4089 total_requests
4090 );
4091 assert!(evidence.iter().all(|e| !e.visible));
4092 handle.join().expect("join");
4093 }
4094
4095 #[test]
4098 fn server_errors_500_502_503_all_classified_as_not_visible_in_backoff() {
4099 for error_code in [500u16, 502, 503] {
4100 let (api_base, handle) = with_multi_server(
4101 move |req| {
4102 req.respond(Response::empty(StatusCode(error_code)))
4103 .expect("respond");
4104 },
4105 10,
4106 );
4107
4108 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4109 let config = ReadinessConfig {
4110 enabled: true,
4111 method: ReadinessMethod::Api,
4112 initial_delay: Duration::ZERO,
4113 max_delay: Duration::from_millis(15),
4114 max_total_wait: Duration::from_millis(60),
4115 poll_interval: Duration::from_millis(10),
4116 jitter_factor: 0.0,
4117 index_path: None,
4118 prefer_index: false,
4119 };
4120
4121 let (visible, evidence) = cli
4122 .is_version_visible_with_backoff("demo", "1.0.0", &config)
4123 .unwrap_or_else(|_| panic!("backoff with {error_code}"));
4124 assert!(
4125 !visible,
4126 "{error_code} should be treated as not-visible in backoff"
4127 );
4128 assert!(
4129 evidence.len() >= 2,
4130 "{error_code} should trigger retries, got {} attempts",
4131 evidence.len()
4132 );
4133 handle.join().expect("join");
4134 }
4135 }
4136
4137 #[test]
4138 fn server_error_then_recovery_succeeds_for_each_5xx() {
4139 use std::sync::Arc;
4140 use std::sync::atomic::{AtomicU32, Ordering};
4141
4142 for error_code in [500u16, 502, 503] {
4143 let counter = Arc::new(AtomicU32::new(0));
4144 let counter_clone = counter.clone();
4145
4146 let (api_base, handle) = with_multi_server(
4147 move |req| {
4148 let n = counter_clone.fetch_add(1, Ordering::SeqCst);
4149 let status = if n < 1 { error_code } else { 200 };
4150 req.respond(Response::empty(StatusCode(status)))
4151 .expect("respond");
4152 },
4153 5,
4154 );
4155
4156 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4157 let config = ReadinessConfig {
4158 enabled: true,
4159 method: ReadinessMethod::Api,
4160 initial_delay: Duration::ZERO,
4161 max_delay: Duration::from_millis(20),
4162 max_total_wait: Duration::from_secs(5),
4163 poll_interval: Duration::from_millis(10),
4164 jitter_factor: 0.0,
4165 index_path: None,
4166 prefer_index: false,
4167 };
4168
4169 let (visible, evidence) = cli
4170 .is_version_visible_with_backoff("demo", "1.0.0", &config)
4171 .unwrap_or_else(|_| panic!("recovery after {error_code}"));
4172 assert!(visible, "should recover after transient {error_code} error");
4173 assert!(evidence.len() >= 2);
4174 assert!(!evidence[0].visible);
4175 assert!(evidence.last().unwrap().visible);
4176 handle.join().expect("join");
4177 }
4178 }
4179
4180 #[test]
4183 fn list_owners_errors_on_200_with_binary_garbage() {
4184 let (api_base, handle) = with_server(|req| {
4185 let resp = Response::from_string("\x00\x01\x02\x7e\x7f")
4186 .with_status_code(StatusCode(200))
4187 .with_header(
4188 tiny_http::Header::from_bytes("Content-Type", "application/octet-stream")
4189 .expect("header"),
4190 );
4191 req.respond(resp).expect("respond");
4192 });
4193
4194 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4195 let err = cli
4196 .list_owners("demo", "token")
4197 .expect_err("binary garbage must fail");
4198 assert!(format!("{err:#}").contains("failed to parse owners JSON"));
4199 handle.join().expect("join");
4200 }
4201
4202 #[test]
4203 fn list_owners_errors_on_200_with_valid_json_wrong_types() {
4204 let (api_base, handle) = with_server(|req| {
4205 let resp = Response::from_string(r#"{"users":"not-an-array"}"#)
4207 .with_status_code(StatusCode(200))
4208 .with_header(
4209 tiny_http::Header::from_bytes("Content-Type", "application/json")
4210 .expect("header"),
4211 );
4212 req.respond(resp).expect("respond");
4213 });
4214
4215 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4216 let err = cli
4217 .list_owners("demo", "token")
4218 .expect_err("wrong types must fail");
4219 assert!(format!("{err:#}").contains("failed to parse owners JSON"));
4220 handle.join().expect("join");
4221 }
4222
4223 #[test]
4224 fn list_owners_errors_on_200_with_nested_invalid_user_object() {
4225 let (api_base, handle) = with_server(|req| {
4226 let resp = Response::from_string(
4228 r#"{"users":[{"id":"not-a-number","login":"alice","name":null}]}"#,
4229 )
4230 .with_status_code(StatusCode(200))
4231 .with_header(
4232 tiny_http::Header::from_bytes("Content-Type", "application/json").expect("header"),
4233 );
4234 req.respond(resp).expect("respond");
4235 });
4236
4237 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4238 let err = cli
4239 .list_owners("demo", "token")
4240 .expect_err("bad id type must fail");
4241 assert!(format!("{err:#}").contains("failed to parse owners JSON"));
4242 handle.join().expect("join");
4243 }
4244
4245 #[test]
4248 fn version_exists_false_for_nonexistent_version_with_prerelease() {
4249 let (api_base, handle) = with_server(|req| {
4250 assert_eq!(req.url(), "/api/v1/crates/demo/0.1.0-alpha.1");
4251 req.respond(Response::empty(StatusCode(404)))
4252 .expect("respond");
4253 });
4254
4255 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4256 let exists = cli.version_exists("demo", "0.1.0-alpha.1").expect("exists");
4257 assert!(!exists);
4258 handle.join().expect("join");
4259 }
4260
4261 #[test]
4262 fn backoff_version_appears_after_initial_not_found() {
4263 use std::sync::Arc;
4264 use std::sync::atomic::{AtomicU32, Ordering};
4265
4266 let counter = Arc::new(AtomicU32::new(0));
4267 let counter_clone = counter.clone();
4268
4269 let (api_base, handle) = with_multi_server(
4270 move |req| {
4271 let n = counter_clone.fetch_add(1, Ordering::SeqCst);
4272 let status = if n < 3 { 404 } else { 200 };
4274 req.respond(Response::empty(StatusCode(status)))
4275 .expect("respond");
4276 },
4277 6,
4278 );
4279
4280 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4281 let config = ReadinessConfig {
4282 enabled: true,
4283 method: ReadinessMethod::Api,
4284 initial_delay: Duration::ZERO,
4285 max_delay: Duration::from_millis(20),
4286 max_total_wait: Duration::from_secs(5),
4287 poll_interval: Duration::from_millis(10),
4288 jitter_factor: 0.0,
4289 index_path: None,
4290 prefer_index: false,
4291 };
4292
4293 let (visible, evidence) = cli
4294 .is_version_visible_with_backoff("demo", "1.0.0", &config)
4295 .expect("backoff");
4296 assert!(visible);
4297 assert_eq!(evidence.len(), 4);
4298 for e in &evidence[..3] {
4299 assert!(!e.visible, "should be not-found before appearing");
4300 }
4301 assert!(evidence[3].visible, "should become visible on 4th attempt");
4302 handle.join().expect("join");
4303 }
4304
4305 #[test]
4308 fn index_path_normalizes_hyphens_and_underscores_independently() {
4309 let (api_base, _handle) = with_server(|req| {
4310 req.respond(Response::empty(StatusCode(200)))
4311 .expect("respond");
4312 });
4313
4314 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4315
4316 let hyphen_path = cli.calculate_index_path("my-crate-lib");
4318 let underscore_path = cli.calculate_index_path("my_crate_lib");
4319
4320 assert!(!hyphen_path.is_empty());
4322 assert!(!underscore_path.is_empty());
4323
4324 assert_ne!(
4326 hyphen_path, underscore_path,
4327 "index paths for hyphen/underscore crates should differ"
4328 );
4329 }
4330
4331 #[test]
4332 fn version_exists_passes_hyphenated_name_in_url() {
4333 let (api_base, handle) = with_server(|req| {
4334 assert_eq!(req.url(), "/api/v1/crates/my-crate-name/1.0.0");
4335 req.respond(Response::empty(StatusCode(200)))
4336 .expect("respond");
4337 });
4338
4339 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4340 assert!(cli.version_exists("my-crate-name", "1.0.0").expect("ok"));
4341 handle.join().expect("join");
4342 }
4343
4344 #[test]
4345 fn version_exists_passes_underscored_name_in_url() {
4346 let (api_base, handle) = with_server(|req| {
4347 assert_eq!(req.url(), "/api/v1/crates/my_crate_name/1.0.0");
4348 req.respond(Response::empty(StatusCode(200)))
4349 .expect("respond");
4350 });
4351
4352 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4353 assert!(cli.version_exists("my_crate_name", "1.0.0").expect("ok"));
4354 handle.join().expect("join");
4355 }
4356
4357 #[test]
4358 fn crate_exists_preserves_hyphenated_name_in_url() {
4359 let (api_base, handle) = with_server(|req| {
4360 assert_eq!(req.url(), "/api/v1/crates/serde-json");
4361 req.respond(Response::empty(StatusCode(200)))
4362 .expect("respond");
4363 });
4364
4365 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4366 assert!(cli.crate_exists("serde-json").expect("ok"));
4367 handle.join().expect("join");
4368 }
4369
4370 #[test]
4373 fn version_exists_with_max_length_crate_name() {
4374 let long_name = "a".repeat(64);
4376 let expected_url = format!("/api/v1/crates/{}/1.0.0", long_name);
4377
4378 let (api_base, handle) = with_server(move |req| {
4379 assert_eq!(req.url(), expected_url);
4380 req.respond(Response::empty(StatusCode(200)))
4381 .expect("respond");
4382 });
4383
4384 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4385 assert!(cli.version_exists(&"a".repeat(64), "1.0.0").expect("ok"));
4386 handle.join().expect("join");
4387 }
4388
4389 #[test]
4390 fn calculate_index_path_for_long_crate_name() {
4391 let (api_base, _handle) = with_server(|req| {
4392 req.respond(Response::empty(StatusCode(200)))
4393 .expect("respond");
4394 });
4395
4396 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4397 let long_name = "abcdefghijklmnopqrstuvwxyz01234567890123456789012345678901234567";
4398 let path = cli.calculate_index_path(long_name);
4399
4400 assert!(path.starts_with("ab/cd/"));
4402 assert!(path.ends_with(long_name));
4403 }
4404
4405 #[test]
4406 fn crate_exists_with_long_name_sends_correct_url() {
4407 let long_name = format!("{}-{}", "x".repeat(30), "y".repeat(30));
4408 let expected_url = format!("/api/v1/crates/{}", long_name);
4409
4410 let (api_base, handle) = with_server(move |req| {
4411 assert_eq!(req.url(), expected_url);
4412 req.respond(Response::empty(StatusCode(200)))
4413 .expect("respond");
4414 });
4415
4416 let name = format!("{}-{}", "x".repeat(30), "y".repeat(30));
4417 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4418 assert!(cli.crate_exists(&name).expect("ok"));
4419 handle.join().expect("join");
4420 }
4421
4422 #[test]
4425 fn version_exists_sends_prerelease_version_in_url() {
4426 let (api_base, handle) = with_server(|req| {
4427 assert_eq!(req.url(), "/api/v1/crates/demo/0.1.0-alpha.1");
4428 req.respond(Response::empty(StatusCode(200)))
4429 .expect("respond");
4430 });
4431
4432 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4433 assert!(cli.version_exists("demo", "0.1.0-alpha.1").expect("ok"));
4434 handle.join().expect("join");
4435 }
4436
4437 #[test]
4438 fn check_index_visibility_finds_prerelease_version() {
4439 let index_content =
4440 "{\"vers\":\"0.1.0-alpha.1\"}\n{\"vers\":\"0.1.0-beta.1\"}\n{\"vers\":\"0.1.0\"}\n";
4441
4442 let (api_base, handle) = with_server(move |req| {
4443 let resp = Response::from_string(index_content)
4444 .with_status_code(StatusCode(200))
4445 .with_header(
4446 tiny_http::Header::from_bytes("Content-Type", "application/json")
4447 .expect("header"),
4448 );
4449 req.respond(resp).expect("respond");
4450 });
4451
4452 let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
4453 assert!(
4454 cli.check_index_visibility("demo", "0.1.0-alpha.1")
4455 .expect("check")
4456 );
4457 handle.join().expect("join");
4458 }
4459
4460 #[test]
4461 fn backoff_with_prerelease_version_succeeds() {
4462 use std::sync::Arc;
4463 use std::sync::atomic::{AtomicU32, Ordering};
4464
4465 let counter = Arc::new(AtomicU32::new(0));
4466 let counter_clone = counter.clone();
4467
4468 let (api_base, handle) = with_multi_server(
4469 move |req| {
4470 let n = counter_clone.fetch_add(1, Ordering::SeqCst);
4471 let status = if n < 1 { 404 } else { 200 };
4472 req.respond(Response::empty(StatusCode(status)))
4473 .expect("respond");
4474 },
4475 5,
4476 );
4477
4478 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4479 let config = ReadinessConfig {
4480 enabled: true,
4481 method: ReadinessMethod::Api,
4482 initial_delay: Duration::ZERO,
4483 max_delay: Duration::from_millis(20),
4484 max_total_wait: Duration::from_secs(5),
4485 poll_interval: Duration::from_millis(10),
4486 jitter_factor: 0.0,
4487 index_path: None,
4488 prefer_index: false,
4489 };
4490
4491 let (visible, evidence) = cli
4492 .is_version_visible_with_backoff("demo", "0.1.0-alpha.1", &config)
4493 .expect("backoff");
4494 assert!(visible);
4495 assert!(evidence.len() >= 2);
4496 handle.join().expect("join");
4497 }
4498
4499 #[test]
4502 fn empty_owners_response_verify_ownership_still_returns_true() {
4503 let (api_base, handle) = with_server(|req| {
4504 let resp = Response::from_string(r#"{"users":[]}"#)
4505 .with_status_code(StatusCode(200))
4506 .with_header(
4507 tiny_http::Header::from_bytes("Content-Type", "application/json")
4508 .expect("header"),
4509 );
4510 req.respond(resp).expect("respond");
4511 });
4512
4513 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4514 let verified = cli.verify_ownership("demo", "token").expect("verify");
4516 assert!(verified);
4517 handle.join().expect("join");
4518 }
4519
4520 #[test]
4521 fn snapshot_empty_owner_list_detail() {
4522 let (api_base, handle) = with_server(|req| {
4523 let resp = Response::from_string(r#"{"users":[]}"#)
4524 .with_status_code(StatusCode(200))
4525 .with_header(
4526 tiny_http::Header::from_bytes("Content-Type", "application/json")
4527 .expect("header"),
4528 );
4529 req.respond(resp).expect("respond");
4530 });
4531
4532 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4533 let owners = cli.list_owners("demo", "token").expect("owners");
4534 assert!(owners.users.is_empty());
4535 insta::assert_debug_snapshot!("empty_owner_list_detail", owners);
4536 handle.join().expect("join");
4537 }
4538
4539 #[test]
4542 fn list_owners_with_team_and_individual_owners() {
4543 let body = r#"{"users":[
4544 {"id":1,"login":"alice","name":"Alice"},
4545 {"id":2,"login":"bob","name":"Bob"},
4546 {"id":3,"login":"github:rust-lang:core","name":"Rust Core Team"},
4547 {"id":4,"login":"github:my-org:devs","name":null}
4548 ]}"#;
4549
4550 let (api_base, handle) = with_server(move |req| {
4551 let resp = Response::from_string(body)
4552 .with_status_code(StatusCode(200))
4553 .with_header(
4554 tiny_http::Header::from_bytes("Content-Type", "application/json")
4555 .expect("header"),
4556 );
4557 req.respond(resp).expect("respond");
4558 });
4559
4560 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4561 let owners = cli.list_owners("demo", "token").expect("owners");
4562 assert_eq!(owners.users.len(), 4);
4563 assert_eq!(owners.users[0].login, "alice");
4564 assert_eq!(owners.users[2].login, "github:rust-lang:core");
4565 assert!(owners.users[3].name.is_none());
4566 handle.join().expect("join");
4567 }
4568
4569 #[test]
4570 fn snapshot_owners_with_teams() {
4571 let body = r#"{"users":[
4572 {"id":10,"login":"maintainer","name":"Main Tainer"},
4573 {"id":20,"login":"github:org:team","name":"Org Team"}
4574 ]}"#;
4575
4576 let (api_base, handle) = with_server(move |req| {
4577 let resp = Response::from_string(body)
4578 .with_status_code(StatusCode(200))
4579 .with_header(
4580 tiny_http::Header::from_bytes("Content-Type", "application/json")
4581 .expect("header"),
4582 );
4583 req.respond(resp).expect("respond");
4584 });
4585
4586 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4587 let owners = cli.list_owners("demo", "token").expect("owners");
4588 insta::assert_debug_snapshot!("owners_with_teams", owners);
4589 handle.join().expect("join");
4590 }
4591
4592 #[test]
4595 fn backoff_poll_interval_increases_between_attempts() {
4596 use std::sync::Arc;
4597 use std::sync::atomic::{AtomicU32, Ordering};
4598
4599 let counter = Arc::new(AtomicU32::new(0));
4600 let counter_clone = counter.clone();
4601
4602 let (api_base, handle) = with_multi_server(
4603 move |req| {
4604 let n = counter_clone.fetch_add(1, Ordering::SeqCst);
4605 let status = if n < 3 { 404 } else { 200 };
4607 req.respond(Response::empty(StatusCode(status)))
4608 .expect("respond");
4609 },
4610 6,
4611 );
4612
4613 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4614 let config = ReadinessConfig {
4615 enabled: true,
4616 method: ReadinessMethod::Api,
4617 initial_delay: Duration::ZERO,
4618 max_delay: Duration::from_secs(5),
4619 max_total_wait: Duration::from_secs(30),
4620 poll_interval: Duration::from_millis(10),
4621 jitter_factor: 0.0, index_path: None,
4623 prefer_index: false,
4624 };
4625
4626 let (visible, evidence) = cli
4627 .is_version_visible_with_backoff("demo", "1.0.0", &config)
4628 .expect("backoff");
4629 assert!(visible);
4630 assert!(evidence.len() >= 4);
4631
4632 assert_eq!(evidence[0].delay_before, Duration::ZERO);
4634
4635 assert_eq!(evidence[1].delay_before, Duration::from_millis(10));
4640 assert_eq!(evidence[2].delay_before, Duration::from_millis(20));
4641 assert_eq!(evidence[3].delay_before, Duration::from_millis(40));
4642 handle.join().expect("join");
4643 }
4644
4645 #[test]
4646 fn backoff_total_elapsed_time_respects_max_total_wait() {
4647 let (api_base, handle) = with_multi_server(
4648 move |req| {
4649 req.respond(Response::empty(StatusCode(404)))
4650 .expect("respond");
4651 },
4652 30,
4653 );
4654
4655 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4656 let max_wait = Duration::from_millis(200);
4657 let config = ReadinessConfig {
4658 enabled: true,
4659 method: ReadinessMethod::Api,
4660 initial_delay: Duration::ZERO,
4661 max_delay: Duration::from_millis(30),
4662 max_total_wait: max_wait,
4663 poll_interval: Duration::from_millis(10),
4664 jitter_factor: 0.0,
4665 index_path: None,
4666 prefer_index: false,
4667 };
4668
4669 let start = Instant::now();
4670 let (visible, _evidence) = cli
4671 .is_version_visible_with_backoff("demo", "1.0.0", &config)
4672 .expect("backoff");
4673 let elapsed = start.elapsed();
4674
4675 assert!(!visible);
4676 assert!(
4678 elapsed < max_wait + Duration::from_millis(200),
4679 "elapsed {:?} exceeded max_total_wait {:?} by too much",
4680 elapsed,
4681 max_wait
4682 );
4683 handle.join().expect("join");
4684 }
4685
4686 #[test]
4687 fn backoff_initial_delay_is_honored() {
4688 let (api_base, handle) = with_server(move |req| {
4689 req.respond(Response::empty(StatusCode(200)))
4690 .expect("respond");
4691 });
4692
4693 let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4694 let initial_delay = Duration::from_millis(100);
4695 let config = ReadinessConfig {
4696 enabled: true,
4697 method: ReadinessMethod::Api,
4698 initial_delay,
4699 max_delay: Duration::from_secs(1),
4700 max_total_wait: Duration::from_secs(5),
4701 poll_interval: Duration::from_millis(50),
4702 jitter_factor: 0.0,
4703 index_path: None,
4704 prefer_index: false,
4705 };
4706
4707 let start = Instant::now();
4708 let (visible, evidence) = cli
4709 .is_version_visible_with_backoff("demo", "1.0.0", &config)
4710 .expect("backoff");
4711 let elapsed = start.elapsed();
4712
4713 assert!(visible);
4714 assert_eq!(evidence.len(), 1);
4715 assert!(
4717 elapsed >= initial_delay,
4718 "elapsed {:?} should be >= initial_delay {:?}",
4719 elapsed,
4720 initial_delay
4721 );
4722 handle.join().expect("join");
4723 }
4724
4725 mod property_tests_version_strings {
4728 use proptest::prelude::*;
4729
4730 fn semver_strategy() -> impl Strategy<Value = String> {
4732 (0..100u32, 0..100u32, 0..100u32)
4733 .prop_map(|(major, minor, patch)| format!("{major}.{minor}.{patch}"))
4734 }
4735
4736 fn semver_with_prerelease_strategy() -> impl Strategy<Value = String> {
4738 (
4739 0..50u32,
4740 0..50u32,
4741 0..50u32,
4742 proptest::option::of("[a-z]{1,5}\\.[0-9]{1,2}"),
4743 )
4744 .prop_map(|(major, minor, patch, pre)| match pre {
4745 Some(p) => format!("{major}.{minor}.{patch}-{p}"),
4746 None => format!("{major}.{minor}.{patch}"),
4747 })
4748 }
4749
4750 proptest! {
4751 #[test]
4752 fn version_found_when_present_in_index(version in semver_strategy()) {
4753 let content = format!("{{\"vers\":\"{version}\"}}\n");
4754 let found = shipper_sparse_index::contains_version(&content, &version);
4755 prop_assert!(found, "version {version} should be found in index");
4756 }
4757
4758 #[test]
4759 fn version_not_found_when_absent_from_index(
4760 needle in semver_strategy(),
4761 haystack in semver_strategy(),
4762 ) {
4763 prop_assume!(needle != haystack);
4765 let content = format!("{{\"vers\":\"{haystack}\"}}\n");
4766 let found = shipper_sparse_index::contains_version(&content, &needle);
4767 prop_assert!(!found, "version {needle} should NOT be found (only {haystack} in index)");
4768 }
4769
4770 #[test]
4771 fn prerelease_version_found_in_index(version in semver_with_prerelease_strategy()) {
4772 let content = format!("{{\"vers\":\"{version}\"}}\n");
4773 let found = shipper_sparse_index::contains_version(&content, &version);
4774 prop_assert!(found, "pre-release version {version} should be found in index");
4775 }
4776
4777 #[test]
4778 fn version_string_in_multi_line_index(
4779 target in semver_strategy(),
4780 other1 in semver_strategy(),
4781 other2 in semver_strategy(),
4782 ) {
4783 let content = format!(
4784 "{{\"vers\":\"{other1}\"}}\n{{\"vers\":\"{target}\"}}\n{{\"vers\":\"{other2}\"}}\n"
4785 );
4786 let found = shipper_sparse_index::contains_version(&content, &target);
4787 prop_assert!(found, "version {target} should be found in multi-line index");
4788 }
4789 }
4790 }
4791}