use anyhow::{Context, Result, bail};
use chrono::Utc;
use reqwest::StatusCode;
use reqwest::blocking::Client;
use serde::Deserialize;
use std::time::{Duration, Instant};
use shipper_types::{ReadinessConfig, ReadinessEvidence, ReadinessMethod, Registry};
#[derive(Debug, Clone)]
pub struct RegistryClient {
registry: Registry,
http: Client,
cache_dir: Option<std::path::PathBuf>,
}
impl RegistryClient {
pub fn new(registry: Registry) -> Result<Self> {
let http = Client::builder()
.user_agent(format!("shipper/{}", env!("CARGO_PKG_VERSION")))
.build()
.context("failed to build HTTP client")?;
Ok(Self {
registry,
http,
cache_dir: None,
})
}
pub fn with_cache_dir(mut self, cache_dir: std::path::PathBuf) -> Self {
self.cache_dir = Some(cache_dir);
self
}
pub fn registry(&self) -> &Registry {
&self.registry
}
pub fn version_exists(&self, crate_name: &str, version: &str) -> Result<bool> {
let url = format!(
"{}/api/v1/crates/{}/{}",
self.registry.api_base.trim_end_matches('/'),
crate_name,
version
);
let resp = self
.http
.get(url)
.send()
.context("registry request failed")?;
match resp.status() {
StatusCode::OK => Ok(true),
StatusCode::NOT_FOUND => Ok(false),
s => bail!("unexpected status while checking version existence: {s}"),
}
}
pub fn crate_exists(&self, crate_name: &str) -> Result<bool> {
let url = format!(
"{}/api/v1/crates/{}",
self.registry.api_base.trim_end_matches('/'),
crate_name
);
let resp = self
.http
.get(url)
.send()
.context("registry request failed")?;
match resp.status() {
StatusCode::OK => Ok(true),
StatusCode::NOT_FOUND => Ok(false),
s => bail!("unexpected status while checking crate existence: {s}"),
}
}
pub fn list_owners(&self, crate_name: &str, token: &str) -> Result<OwnersResponse> {
let url = format!(
"{}/api/v1/crates/{}/owners",
self.registry.api_base.trim_end_matches('/'),
crate_name
);
let resp = self
.http
.get(url)
.header("Authorization", token)
.send()
.context("registry owners request failed")?;
match resp.status() {
StatusCode::OK => {
let parsed: OwnersResponse = resp.json().context("failed to parse owners JSON")?;
Ok(parsed)
}
StatusCode::NOT_FOUND => bail!("crate not found when querying owners: {crate_name}"),
StatusCode::FORBIDDEN => bail!(
"forbidden when querying owners; token may be invalid or missing required scope"
),
s => bail!("unexpected status while querying owners: {s}"),
}
}
pub fn check_new_crate(&self, crate_name: &str) -> Result<bool> {
let exists = self.crate_exists(crate_name)?;
Ok(!exists)
}
pub fn check_index_visibility(&self, crate_name: &str, version: &str) -> Result<bool> {
let index_path = self.calculate_index_path(crate_name);
let content = match self.fetch_index_file(&index_path) {
Ok(content) => content,
Err(_e) => {
return Ok(false);
}
};
match self.parse_version_from_index(&content, version) {
Ok(found) => Ok(found),
Err(_) => {
Ok(false)
}
}
}
fn calculate_index_path(&self, crate_name: &str) -> String {
shipper_sparse_index::sparse_index_path(crate_name)
}
fn fetch_index_file(&self, index_path: &str) -> Result<String> {
let index_base = self.registry.get_index_base();
let url = format!("{}/{}", index_base.trim_end_matches('/'), index_path);
let cache_file = self.cache_dir.as_ref().map(|d| d.join(index_path));
let etag_file = cache_file.as_ref().map(|f| f.with_extension("etag"));
let mut request = self.http.get(&url);
if let Some(ref path) = etag_file
&& let Ok(etag) = std::fs::read_to_string(path)
{
request = request.header(reqwest::header::IF_NONE_MATCH, etag.trim());
}
let resp = request.send().context("index request failed")?;
match resp.status() {
StatusCode::OK => {
let etag = resp
.headers()
.get(reqwest::header::ETAG)
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
let content = resp.text().context("failed to read index response body")?;
if let Some(ref path) = cache_file {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, &content);
if let (Some(ref etag_val), Some(etag_path)) = (etag, etag_file) {
let _ = std::fs::write(etag_path, etag_val);
}
}
Ok(content)
}
StatusCode::NOT_MODIFIED => {
if let Some(ref path) = cache_file {
std::fs::read_to_string(path).context("failed to read cached index file")
} else {
bail!("received 304 Not Modified but no cache file available")
}
}
StatusCode::NOT_FOUND => {
bail!("index file not found: {}", url)
}
s => bail!("unexpected status while fetching index: {}", s),
}
}
fn parse_version_from_index(&self, content: &str, version: &str) -> Result<bool> {
Ok(shipper_sparse_index::contains_version(content, version))
}
pub fn verify_ownership(&self, crate_name: &str, token: &str) -> Result<bool> {
match self.list_owners(crate_name, token) {
Ok(_) => Ok(true),
Err(e) => {
let msg = format!("{e:#}");
if msg.contains("forbidden")
|| msg.contains("403")
|| msg.contains("unauthorized")
|| msg.contains("401")
|| msg.contains("not found")
|| msg.contains("404")
{
Ok(false)
} else {
Err(e)
}
}
}
}
pub fn is_version_visible_with_backoff(
&self,
crate_name: &str,
version: &str,
config: &ReadinessConfig,
) -> Result<(bool, Vec<ReadinessEvidence>)> {
let mut evidence = Vec::new();
if !config.enabled {
let visible = self.version_exists(crate_name, version)?;
evidence.push(ReadinessEvidence {
attempt: 1,
visible,
timestamp: Utc::now(),
delay_before: Duration::ZERO,
});
return Ok((visible, evidence));
}
let start = Instant::now();
let mut attempt: u32 = 0;
if config.initial_delay > Duration::ZERO {
std::thread::sleep(config.initial_delay);
}
loop {
attempt += 1;
let jittered_delay = if attempt == 1 {
Duration::ZERO
} else {
let base_delay = config.poll_interval;
let exponential_delay = base_delay
.saturating_mul(2_u32.saturating_pow(attempt.saturating_sub(2).min(16)));
let capped_delay = exponential_delay.min(config.max_delay);
let jitter_range = config.jitter_factor;
let jitter = 1.0 + (rand::random::<f64>() * 2.0 * jitter_range - jitter_range);
Duration::from_millis((capped_delay.as_millis() as f64 * jitter).round() as u64)
};
let visible = match config.method {
ReadinessMethod::Api => self.version_exists(crate_name, version).unwrap_or(false),
ReadinessMethod::Index => self
.check_index_visibility(crate_name, version)
.unwrap_or(false),
ReadinessMethod::Both => {
if config.prefer_index {
match self.check_index_visibility(crate_name, version) {
Ok(true) => true,
_ => self.version_exists(crate_name, version).unwrap_or(false),
}
} else {
match self.version_exists(crate_name, version) {
Ok(true) => true,
_ => self
.check_index_visibility(crate_name, version)
.unwrap_or(false),
}
}
}
};
evidence.push(ReadinessEvidence {
attempt,
visible,
timestamp: Utc::now(),
delay_before: jittered_delay,
});
if visible {
return Ok((true, evidence));
}
if start.elapsed() >= config.max_total_wait {
return Ok((false, evidence));
}
let base_delay = config.poll_interval;
let exponential_delay =
base_delay.saturating_mul(2_u32.saturating_pow(attempt.saturating_sub(1).min(16)));
let capped_delay = exponential_delay.min(config.max_delay);
let jitter_range = config.jitter_factor;
let jitter = 1.0 + (rand::random::<f64>() * 2.0 * jitter_range - jitter_range);
let next_delay =
Duration::from_millis((capped_delay.as_millis() as f64 * jitter).round() as u64);
std::thread::sleep(next_delay);
}
}
pub fn calculate_backoff_delay(
&self,
base: Duration,
max: Duration,
attempt: u32,
jitter_factor: f64,
) -> Duration {
let pow = attempt.saturating_sub(1).min(16);
let mut delay = base.saturating_mul(2_u32.saturating_pow(pow));
if delay > max {
delay = max;
}
let jitter = 1.0 + (rand::random::<f64>() * 2.0 * jitter_factor - jitter_factor);
let millis = (delay.as_millis() as f64 * jitter).round() as u128;
Duration::from_millis(millis as u64)
}
}
#[derive(Debug, Deserialize)]
pub struct OwnersResponse {
pub users: Vec<Owner>,
}
#[derive(Debug, Deserialize)]
pub struct Owner {
pub id: u64,
pub login: String,
pub name: Option<String>,
}
#[cfg(test)]
mod tests {
use std::thread;
use tiny_http::{Response, Server, StatusCode};
use super::*;
fn with_server<F>(handler: F) -> (String, thread::JoinHandle<()>)
where
F: FnOnce(tiny_http::Request) + Send + 'static,
{
let server = Server::http("127.0.0.1:0").expect("server");
let addr = format!("http://{}", server.server_addr());
let handle = thread::spawn(move || {
let req = server.recv().expect("request");
handler(req);
});
(addr, handle)
}
fn test_registry(api_base: String) -> Registry {
Registry {
name: "crates-io".to_string(),
api_base,
index_base: None,
}
}
fn test_registry_with_index(api_base: String) -> Registry {
Registry {
name: "crates-io".to_string(),
api_base: api_base.clone(),
index_base: Some(api_base),
}
}
fn with_multi_server<F>(handler: F, request_count: usize) -> (String, thread::JoinHandle<()>)
where
F: Fn(tiny_http::Request) + Send + 'static,
{
let server = Server::http("127.0.0.1:0").expect("server");
let addr = format!("http://{}", server.server_addr());
let handle = thread::spawn(move || {
for _ in 0..request_count {
match server.recv_timeout(Duration::from_secs(30)) {
Ok(Some(req)) => handler(req),
_ => break,
}
}
});
(addr, handle)
}
#[test]
fn version_exists_true_for_200() {
let (api_base, handle) = with_server(|req| {
assert_eq!(req.url(), "/api/v1/crates/demo/1.2.3");
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert_eq!(cli.registry().name, "crates-io");
let exists = cli.version_exists("demo", "1.2.3").expect("exists");
assert!(exists);
handle.join().expect("join");
}
#[test]
fn version_exists_false_for_404() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(404)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let exists = cli.version_exists("demo", "1.2.3").expect("exists");
assert!(!exists);
handle.join().expect("join");
}
#[test]
fn version_exists_errors_for_unexpected_status() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(500)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.version_exists("demo", "1.2.3")
.expect_err("unexpected status must fail");
assert!(format!("{err:#}").contains("unexpected status while checking version existence"));
handle.join().expect("join");
}
#[test]
fn crate_exists_true_for_200() {
let (api_base, handle) = with_server(|req| {
assert_eq!(req.url(), "/api/v1/crates/demo");
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let exists = cli.crate_exists("demo").expect("exists");
assert!(exists);
handle.join().expect("join");
}
#[test]
fn crate_exists_false_for_404() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(404)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let exists = cli.crate_exists("demo").expect("exists");
assert!(!exists);
handle.join().expect("join");
}
#[test]
fn crate_exists_errors_for_unexpected_status() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(500)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.crate_exists("demo")
.expect_err("unexpected status must fail");
assert!(format!("{err:#}").contains("unexpected status while checking crate existence"));
handle.join().expect("join");
}
#[test]
fn list_owners_parses_success_response() {
let (api_base, handle) = with_server(|req| {
assert_eq!(req.url(), "/api/v1/crates/demo/owners");
let auth = req
.headers()
.iter()
.find(|h| h.field.equiv("Authorization"))
.map(|h| h.value.as_str().to_string());
assert_eq!(auth.as_deref(), Some("token-abc"));
let body = r#"{"users":[{"id":7,"login":"alice","name":"Alice"}]}"#;
let resp = Response::from_string(body)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let owners = cli.list_owners("demo", "token-abc").expect("owners");
assert_eq!(owners.users.len(), 1);
assert_eq!(owners.users[0].login, "alice");
handle.join().expect("join");
}
#[test]
fn list_owners_errors_for_404_403_and_other_statuses() {
let (api_base_404, h1) = with_server(|req| {
req.respond(Response::empty(StatusCode(404)))
.expect("respond");
});
let cli_404 = RegistryClient::new(test_registry(api_base_404)).expect("client");
let err_404 = cli_404
.list_owners("missing", "token")
.expect_err("404 must fail");
assert!(format!("{err_404:#}").contains("crate not found when querying owners"));
h1.join().expect("join");
let (api_base_403, h2) = with_server(|req| {
req.respond(Response::empty(StatusCode(403)))
.expect("respond");
});
let cli_403 = RegistryClient::new(test_registry(api_base_403)).expect("client");
let err_403 = cli_403
.list_owners("demo", "token")
.expect_err("403 must fail");
assert!(format!("{err_403:#}").contains("forbidden when querying owners"));
h2.join().expect("join");
let (api_base_500, h3) = with_server(|req| {
req.respond(Response::empty(StatusCode(500)))
.expect("respond");
});
let cli_500 = RegistryClient::new(test_registry(api_base_500)).expect("client");
let err_500 = cli_500
.list_owners("demo", "token")
.expect_err("500 must fail");
assert!(format!("{err_500:#}").contains("unexpected status while querying owners"));
h3.join().expect("join");
}
#[test]
fn calculate_backoff_delay_is_bounded_with_jitter() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let base = Duration::from_millis(100);
let max = Duration::from_millis(500);
let jitter_factor = 0.5;
let d1 = cli.calculate_backoff_delay(base, max, 1, jitter_factor);
assert!(d1 >= Duration::from_millis(50));
assert!(d1 <= Duration::from_millis(150));
let d20 = cli.calculate_backoff_delay(base, max, 20, jitter_factor);
assert!(d20 >= Duration::from_millis(250));
assert!(d20 <= Duration::from_millis(750));
let d_no_jitter = cli.calculate_backoff_delay(base, max, 2, 0.0);
assert_eq!(d_no_jitter, Duration::from_millis(200));
}
#[test]
fn is_version_visible_with_backoff_disabled_returns_immediate() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: false,
method: ReadinessMethod::Api,
initial_delay: Duration::from_secs(10),
max_delay: Duration::from_secs(60),
max_total_wait: Duration::from_secs(300),
poll_interval: Duration::from_secs(2),
jitter_factor: 0.5,
index_path: None,
prefer_index: false,
};
let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
assert!(result.is_ok());
let (visible, evidence) = result.unwrap();
assert!(visible);
assert_eq!(evidence.len(), 1);
assert!(evidence[0].visible);
handle.join().expect("join");
}
#[test]
fn calculate_index_path_for_standard_crate() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert_eq!(cli.calculate_index_path("serde"), "se/rd/serde");
assert_eq!(cli.calculate_index_path("tokio"), "to/ki/tokio");
assert_eq!(cli.calculate_index_path("rand"), "ra/nd/rand");
assert_eq!(cli.calculate_index_path("http"), "ht/tp/http");
}
#[test]
fn calculate_index_path_for_short_crate() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert_eq!(cli.calculate_index_path("a"), "1/a");
assert_eq!(cli.calculate_index_path("ab"), "2/ab");
assert_eq!(cli.calculate_index_path("abc"), "3/a/abc");
}
#[test]
fn calculate_index_path_for_special_chars() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert_eq!(cli.calculate_index_path("_serde"), "_s/er/_serde");
assert_eq!(cli.calculate_index_path("-tokio"), "-t/ok/-tokio");
assert_eq!(cli.calculate_index_path("Serde"), "se/rd/serde");
}
#[test]
fn parse_version_from_index_finds_version() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let index_content = "{\"vers\":\"1.0.0\"}\n{\"vers\":\"1.0.1\"}\n{\"vers\":\"2.0.0\"}\n";
let found = cli.parse_version_from_index(index_content, "1.0.1");
assert!(found.is_ok());
assert!(found.unwrap());
}
#[test]
fn parse_version_from_index_returns_false_for_missing_version() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let index_content = "{\"vers\":\"1.0.0\"}\n{\"vers\":\"1.0.1\"}\n";
let found = cli.parse_version_from_index(index_content, "2.0.0");
assert!(found.is_ok());
assert!(!found.unwrap());
}
#[test]
fn parse_version_from_index_handles_invalid_json() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let invalid_json = "not valid json";
let found = cli.parse_version_from_index(invalid_json, "1.0.0");
assert!(found.is_ok());
assert!(!found.unwrap());
}
#[test]
fn check_index_visibility_returns_true_for_existing_version() {
let index_content = "{\"vers\":\"1.0.0\"}\n{\"vers\":\"1.0.1\"}\n";
let (api_base, handle) = with_server(move |req| {
assert_eq!(req.url(), "/de/mo/demo");
let resp = Response::from_string(index_content)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let visible = cli.check_index_visibility("demo", "1.0.1").expect("check");
assert!(visible);
handle.join().expect("join");
}
#[test]
fn check_index_visibility_returns_false_for_missing_version() {
let index_content = "{\"vers\":\"1.0.0\"}\n";
let (api_base, handle) = with_server(move |req| {
assert_eq!(req.url(), "/de/mo/demo");
let resp = Response::from_string(index_content)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let visible = cli.check_index_visibility("demo", "1.0.1").expect("check");
assert!(!visible);
handle.join().expect("join");
}
#[test]
fn check_index_visibility_returns_false_for_404() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(404)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let visible = cli
.check_index_visibility("missing", "1.0.0")
.expect("check");
assert!(!visible);
handle.join().expect("join");
}
#[test]
fn check_index_visibility_returns_false_for_network_error() {
let registry = Registry {
name: "test".to_string(),
api_base: "http://nonexistent.invalid:9999".to_string(),
index_base: Some("http://nonexistent.invalid:9999".to_string()),
};
let cli = RegistryClient::new(registry).expect("client");
let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
assert!(!visible);
}
#[test]
fn check_index_visibility_returns_false_for_invalid_json() {
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string("not valid json")
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
assert!(!visible);
handle.join().expect("join");
}
#[test]
fn is_version_visible_with_backoff_uses_index_method() {
let index_content = "{\"vers\":\"1.0.0\"}\n";
let (api_base, handle) = with_server(move |req| {
assert_eq!(req.url(), "/de/mo/demo");
let resp = Response::from_string(index_content)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Index,
initial_delay: Duration::from_millis(10),
max_delay: Duration::from_secs(1),
max_total_wait: Duration::from_secs(1),
poll_interval: Duration::from_millis(100),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
assert!(result.is_ok());
let (visible, evidence) = result.unwrap();
assert!(visible);
assert!(!evidence.is_empty());
handle.join().expect("join");
}
#[test]
fn is_version_visible_with_backoff_uses_both_method_prefer_index() {
let index_content = "{\"vers\":\"1.0.0\"}\n";
let (api_base, handle) = with_server(move |req| {
assert_eq!(req.url(), "/de/mo/demo");
let resp = Response::from_string(index_content)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Both,
initial_delay: Duration::from_millis(10),
max_delay: Duration::from_secs(1),
max_total_wait: Duration::from_secs(1),
poll_interval: Duration::from_millis(100),
jitter_factor: 0.0,
index_path: None,
prefer_index: true, };
let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
assert!(result.is_ok());
let (visible, evidence) = result.unwrap();
assert!(visible);
assert!(!evidence.is_empty());
handle.join().expect("join");
}
#[test]
fn registry_get_index_base_returns_explicit_index_base() {
let registry = Registry {
name: "test".to_string(),
api_base: "https://example.com".to_string(),
index_base: Some("https://index.example.com".to_string()),
};
assert_eq!(registry.get_index_base(), "https://index.example.com");
}
#[test]
fn registry_get_index_base_derives_from_api_base() {
let registry = Registry {
name: "test".to_string(),
api_base: "https://crates.io".to_string(),
index_base: None,
};
assert_eq!(registry.get_index_base(), "https://index.crates.io");
}
#[test]
fn registry_get_index_base_derives_from_http_api_base() {
let registry = Registry {
name: "test".to_string(),
api_base: "http://crates.io".to_string(),
index_base: None,
};
assert_eq!(registry.get_index_base(), "http://index.crates.io");
}
#[test]
fn check_index_visibility_with_empty_index_returns_false() {
let index_content = "";
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string(index_content)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
assert!(!visible);
handle.join().expect("join");
}
#[test]
fn check_index_visibility_with_multiple_versions_finds_correct() {
let index_content = "{\"vers\":\"0.1.0\"}\n{\"vers\":\"0.2.0\"}\n{\"vers\":\"1.0.0\"}\n{\"vers\":\"1.1.0\"}\n";
let (api_base, handle) = with_multi_server(
move |req| {
let resp = Response::from_string(index_content)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
},
5,
);
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
assert!(cli.check_index_visibility("demo", "0.1.0").expect("check"));
assert!(cli.check_index_visibility("demo", "0.2.0").expect("check"));
assert!(cli.check_index_visibility("demo", "1.0.0").expect("check"));
assert!(cli.check_index_visibility("demo", "1.1.0").expect("check"));
assert!(!cli.check_index_visibility("demo", "2.0.0").expect("check"));
handle.join().expect("join");
}
#[test]
fn check_index_visibility_handles_malformed_json_gracefully() {
let malformed_json = "{\"vers\":\"1.0.0\"}\n{\"invalid\":\"entry\"}\n";
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string(malformed_json)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
assert!(visible);
handle.join().expect("join");
}
#[test]
fn is_version_visible_with_backoff_with_api_method() {
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string("{}")
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::from_millis(10),
max_delay: Duration::from_secs(1),
max_total_wait: Duration::from_secs(1),
poll_interval: Duration::from_millis(100),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
assert!(result.is_ok());
let (visible, evidence) = result.unwrap();
assert!(visible);
assert!(!evidence.is_empty());
handle.join().expect("join");
}
#[test]
fn is_version_visible_with_backoff_with_both_method_prefer_api() {
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string("{}")
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Both,
initial_delay: Duration::from_millis(10),
max_delay: Duration::from_secs(1),
max_total_wait: Duration::from_secs(1),
poll_interval: Duration::from_millis(100),
jitter_factor: 0.0,
index_path: None,
prefer_index: false, };
let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
assert!(result.is_ok());
let (visible, evidence) = result.unwrap();
assert!(visible);
assert!(!evidence.is_empty());
handle.join().expect("join");
}
#[test]
fn is_version_visible_with_backoff_returns_false_on_timeout() {
let (api_base, handle) = with_multi_server(
move |req| {
let resp = Response::empty(StatusCode(404));
req.respond(resp).expect("respond");
},
10,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::from_millis(10),
max_delay: Duration::from_millis(50),
max_total_wait: Duration::from_millis(100),
poll_interval: Duration::from_millis(25),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
assert!(result.is_ok());
let (visible, evidence) = result.unwrap();
assert!(!visible);
assert!(!evidence.is_empty());
assert!(evidence.iter().all(|e| !e.visible));
handle.join().expect("join");
}
#[test]
fn is_version_visible_with_backoff_handles_network_errors_gracefully() {
let registry = Registry {
name: "test".to_string(),
api_base: "http://nonexistent.invalid:9999".to_string(),
index_base: Some("http://nonexistent.invalid:9999".to_string()),
};
let cli = RegistryClient::new(registry).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::from_millis(10),
max_delay: Duration::from_millis(50),
max_total_wait: Duration::from_millis(100),
poll_interval: Duration::from_millis(25),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
assert!(result.is_ok());
let (visible, _evidence) = result.unwrap();
assert!(!visible);
}
#[test]
fn is_version_visible_with_backoff_respects_initial_delay() {
let start = std::time::Instant::now();
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string("{}")
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::from_millis(50),
max_delay: Duration::from_secs(1),
max_total_wait: Duration::from_secs(1),
poll_interval: Duration::from_millis(100),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
let elapsed = start.elapsed();
let (visible, evidence) = result.unwrap();
assert!(visible);
assert!(!evidence.is_empty());
assert!(elapsed >= Duration::from_millis(50));
handle.join().expect("join");
}
#[test]
fn verify_ownership_returns_true_on_success() {
let owners_json = r#"{"users":[{"id":1,"login":"user1","name":null},{"id":2,"login":"user2","name":null}]}"#;
let (api_base, handle) = with_server(move |req| {
assert_eq!(req.url(), "/api/v1/crates/demo/owners");
let resp = Response::from_string(owners_json)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let verified = cli.verify_ownership("demo", "fake-token").expect("verify");
assert!(verified);
handle.join().expect("join");
}
#[test]
fn verify_ownership_returns_false_on_forbidden() {
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string("{}")
.with_status_code(StatusCode(403))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let verified = cli.verify_ownership("demo", "fake-token").expect("verify");
assert!(!verified);
handle.join().expect("join");
}
#[test]
fn verify_ownership_returns_false_on_not_found() {
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string("{}")
.with_status_code(StatusCode(404))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let verified = cli.verify_ownership("demo", "fake-token").expect("verify");
assert!(!verified);
handle.join().expect("join");
}
#[test]
fn check_new_crate_returns_true_for_nonexistent_crate() {
let (api_base, handle) = with_server(move |req| {
let resp = Response::empty(StatusCode(404));
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let is_new = cli.check_new_crate("demo").expect("check");
assert!(is_new);
handle.join().expect("join");
}
#[test]
fn check_new_crate_returns_false_for_existing_crate() {
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string("{}")
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let is_new = cli.check_new_crate("demo").expect("check");
assert!(!is_new);
handle.join().expect("join");
}
#[test]
fn api_mode_visible_on_first_check() {
let (api_base, handle) = with_server(move |req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_secs(1),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(50),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
assert_eq!(evidence.len(), 1);
assert!(evidence[0].visible);
assert_eq!(evidence[0].attempt, 1);
assert_eq!(evidence[0].delay_before, Duration::ZERO);
handle.join().expect("join");
}
#[test]
fn api_mode_never_visible_times_out() {
let (api_base, handle) = with_multi_server(
move |req| {
req.respond(Response::empty(StatusCode(404)))
.expect("respond");
},
20,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(20),
max_total_wait: Duration::from_millis(80),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(!visible);
assert!(
evidence.len() >= 2,
"should poll multiple times before timeout"
);
assert!(evidence.iter().all(|e| !e.visible));
handle.join().expect("join");
}
#[test]
fn api_mode_intermittent_failures_then_success() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let (api_base, handle) = with_multi_server(
move |req| {
let n = counter_clone.fetch_add(1, Ordering::SeqCst);
let status = if n < 2 { 500 } else { 200 };
req.respond(Response::empty(StatusCode(status)))
.expect("respond");
},
5,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(20),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
assert!(evidence.len() >= 3);
assert!(!evidence[0].visible);
assert!(!evidence[1].visible);
assert!(evidence.last().unwrap().visible);
handle.join().expect("join");
}
#[test]
fn index_mode_sparse_index_shows_version() {
let index_content = "{\"vers\":\"0.9.0\"}\n{\"vers\":\"1.0.0\"}\n";
let (api_base, handle) = with_server(move |req| {
assert_eq!(req.url(), "/de/mo/demo");
req.respond(Response::from_string(index_content).with_status_code(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Index,
initial_delay: Duration::ZERO,
max_delay: Duration::from_secs(1),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(50),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
assert_eq!(evidence.len(), 1);
assert!(evidence[0].visible);
handle.join().expect("join");
}
#[test]
fn index_mode_stale_empty_index() {
let (api_base, handle) = with_multi_server(
move |req| {
req.respond(Response::from_string("").with_status_code(StatusCode(200)))
.expect("respond");
},
10,
);
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Index,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(20),
max_total_wait: Duration::from_millis(80),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(!visible);
assert!(evidence.len() >= 2);
assert!(evidence.iter().all(|e| !e.visible));
handle.join().expect("join");
}
#[test]
fn index_mode_parse_errors_treated_as_not_visible() {
let (api_base, handle) = with_multi_server(
move |req| {
req.respond(
Response::from_string("<<<not json>>>").with_status_code(StatusCode(200)),
)
.expect("respond");
},
10,
);
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Index,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(20),
max_total_wait: Duration::from_millis(80),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(!visible);
assert!(evidence.iter().all(|e| !e.visible));
handle.join().expect("join");
}
#[test]
fn both_mode_api_succeeds_index_fails() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let (api_base, handle) = with_multi_server(
move |req| {
let n = counter_clone.fetch_add(1, Ordering::SeqCst);
let url = req.url().to_string();
if url.contains("/api/v1/crates/") {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
} else {
req.respond(Response::empty(StatusCode(404)))
.expect("respond");
}
let _ = n;
},
5,
);
let cli = RegistryClient::new(test_registry_with_index(api_base.clone())).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Both,
initial_delay: Duration::ZERO,
max_delay: Duration::from_secs(1),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(50),
jitter_factor: 0.0,
index_path: None,
prefer_index: true, };
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
assert_eq!(evidence.len(), 1);
assert!(evidence[0].visible);
handle.join().expect("join");
}
#[test]
fn both_mode_index_succeeds_api_fails() {
let index_content = "{\"vers\":\"1.0.0\"}\n";
let (api_base, handle) = with_multi_server(
move |req| {
let url = req.url().to_string();
if url.contains("/api/v1/crates/") {
req.respond(Response::empty(StatusCode(404)))
.expect("respond");
} else {
req.respond(
Response::from_string(index_content).with_status_code(StatusCode(200)),
)
.expect("respond");
}
},
5,
);
let cli = RegistryClient::new(test_registry_with_index(api_base.clone())).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Both,
initial_delay: Duration::ZERO,
max_delay: Duration::from_secs(1),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(50),
jitter_factor: 0.0,
index_path: None,
prefer_index: false, };
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
assert_eq!(evidence.len(), 1);
assert!(evidence[0].visible);
handle.join().expect("join");
}
#[test]
fn zero_timeout_returns_immediately() {
let (api_base, handle) = with_server(move |req| {
req.respond(Response::empty(StatusCode(404)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_secs(1),
max_total_wait: Duration::ZERO,
poll_interval: Duration::from_millis(50),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let start = Instant::now();
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
let elapsed = start.elapsed();
assert!(!visible);
assert_eq!(evidence.len(), 1);
assert!(!evidence[0].visible);
assert!(elapsed < Duration::from_secs(1));
handle.join().expect("join");
}
#[test]
fn evidence_records_populated_correctly() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let (api_base, handle) = with_multi_server(
move |req| {
let n = counter_clone.fetch_add(1, Ordering::SeqCst);
let status = if n < 2 { 404 } else { 200 };
req.respond(Response::empty(StatusCode(status)))
.expect("respond");
},
5,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(50),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
assert_eq!(evidence.len(), 3);
assert_eq!(evidence[0].attempt, 1);
assert_eq!(evidence[1].attempt, 2);
assert_eq!(evidence[2].attempt, 3);
assert!(!evidence[0].visible);
assert!(!evidence[1].visible);
assert!(evidence[2].visible);
assert_eq!(evidence[0].delay_before, Duration::ZERO);
assert!(evidence[1].delay_before > Duration::ZERO);
assert!(evidence[2].delay_before > Duration::ZERO);
assert!(evidence[0].timestamp <= evidence[1].timestamp);
assert!(evidence[1].timestamp <= evidence[2].timestamp);
handle.join().expect("join");
}
#[test]
fn backoff_delays_increase_exponentially() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let base = Duration::from_millis(100);
let max = Duration::from_secs(10);
let d1 = cli.calculate_backoff_delay(base, max, 1, 0.0);
let d2 = cli.calculate_backoff_delay(base, max, 2, 0.0);
let d3 = cli.calculate_backoff_delay(base, max, 3, 0.0);
let d4 = cli.calculate_backoff_delay(base, max, 4, 0.0);
assert_eq!(d1, Duration::from_millis(100));
assert_eq!(d2, Duration::from_millis(200));
assert_eq!(d3, Duration::from_millis(400));
assert_eq!(d4, Duration::from_millis(800));
assert_eq!(d2, d1 * 2);
assert_eq!(d3, d2 * 2);
assert_eq!(d4, d3 * 2);
}
#[test]
fn backoff_delays_capped_at_max() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let base = Duration::from_millis(100);
let max = Duration::from_millis(500);
let d4 = cli.calculate_backoff_delay(base, max, 4, 0.0);
assert_eq!(d4, Duration::from_millis(500));
let d20 = cli.calculate_backoff_delay(base, max, 20, 0.0);
assert_eq!(d20, Duration::from_millis(500));
}
#[test]
fn disabled_readiness_with_not_found_returns_false() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(404)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: false,
method: ReadinessMethod::Api,
initial_delay: Duration::from_secs(999), max_delay: Duration::from_secs(999),
max_total_wait: Duration::from_secs(999),
poll_interval: Duration::from_secs(999),
jitter_factor: 0.5,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(!visible);
assert_eq!(evidence.len(), 1);
assert!(!evidence[0].visible);
assert_eq!(evidence[0].attempt, 1);
assert_eq!(evidence[0].delay_before, Duration::ZERO);
handle.join().expect("join");
}
#[test]
fn both_mode_both_fail_times_out() {
let (api_base, handle) = with_multi_server(
move |req| {
req.respond(Response::empty(StatusCode(404)))
.expect("respond");
},
20,
);
let cli = RegistryClient::new(test_registry_with_index(api_base.clone())).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Both,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(20),
max_total_wait: Duration::from_millis(80),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(!visible);
assert!(evidence.len() >= 2);
assert!(evidence.iter().all(|e| !e.visible));
handle.join().expect("join");
}
#[test]
fn version_exists_errors_for_429_rate_limit() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(429)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.version_exists("demo", "1.0.0")
.expect_err("429 must fail");
assert!(format!("{err:#}").contains("unexpected status"));
handle.join().expect("join");
}
#[test]
fn version_exists_errors_for_502_bad_gateway() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(502)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.version_exists("demo", "1.0.0")
.expect_err("502 must fail");
assert!(format!("{err:#}").contains("unexpected status"));
handle.join().expect("join");
}
#[test]
fn version_exists_errors_for_503_service_unavailable() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(503)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.version_exists("demo", "1.0.0")
.expect_err("503 must fail");
assert!(format!("{err:#}").contains("unexpected status"));
handle.join().expect("join");
}
#[test]
fn crate_exists_errors_for_429_rate_limit() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(429)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli.crate_exists("demo").expect_err("429 must fail");
assert!(format!("{err:#}").contains("unexpected status"));
handle.join().expect("join");
}
#[test]
fn list_owners_errors_for_429_rate_limit() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(429)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli.list_owners("demo", "token").expect_err("429 must fail");
assert!(format!("{err:#}").contains("unexpected status while querying owners"));
handle.join().expect("join");
}
#[test]
fn list_owners_errors_on_non_json_response() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string("this is not json at all")
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "text/plain").expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.list_owners("demo", "token")
.expect_err("non-json must fail");
assert!(format!("{err:#}").contains("failed to parse owners JSON"));
handle.join().expect("join");
}
#[test]
fn list_owners_errors_on_truncated_json() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string(r#"{"users":[{"id":1,"login":"al"#)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.list_owners("demo", "token")
.expect_err("truncated json must fail");
assert!(format!("{err:#}").contains("failed to parse owners JSON"));
handle.join().expect("join");
}
#[test]
fn list_owners_errors_on_wrong_schema_json() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string(r#"{"data": [1, 2, 3]}"#)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.list_owners("demo", "token")
.expect_err("wrong schema must fail");
assert!(format!("{err:#}").contains("failed to parse owners JSON"));
handle.join().expect("join");
}
#[test]
fn parse_version_from_index_exact_match_only() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let content = "{\"vers\":\"1.0.0\"}\n{\"vers\":\"1.0.10\"}\n{\"vers\":\"1.0.0-beta.1\"}\n";
assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
assert!(cli.parse_version_from_index(content, "1.0.10").unwrap());
assert!(!cli.parse_version_from_index(content, "1.0.1").unwrap());
}
#[test]
fn parse_version_from_index_prerelease_versions() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
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";
assert!(
cli.parse_version_from_index(content, "1.0.0-alpha.1")
.unwrap()
);
assert!(
cli.parse_version_from_index(content, "1.0.0-beta.2")
.unwrap()
);
assert!(cli.parse_version_from_index(content, "1.0.0-rc.1").unwrap());
assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
assert!(
!cli.parse_version_from_index(content, "1.0.0-alpha.2")
.unwrap()
);
}
#[test]
fn parse_version_from_index_empty_content() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert!(!cli.parse_version_from_index("", "1.0.0").unwrap());
assert!(!cli.parse_version_from_index("\n\n\n", "1.0.0").unwrap());
}
#[test]
fn version_exists_slow_response_still_succeeds() {
let (api_base, handle) = with_server(|req| {
std::thread::sleep(Duration::from_millis(200));
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let exists = cli.version_exists("demo", "1.0.0").expect("exists");
assert!(exists);
handle.join().expect("join");
}
#[test]
fn api_mode_500_treated_as_not_visible_in_backoff() {
let (api_base, handle) = with_multi_server(
move |req| {
req.respond(Response::empty(StatusCode(500)))
.expect("respond");
},
10,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(20),
max_total_wait: Duration::from_millis(80),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(!visible);
assert!(evidence.iter().all(|e| !e.visible));
handle.join().expect("join");
}
#[test]
fn index_mode_502_treated_as_not_visible_in_backoff() {
let (api_base, handle) = with_multi_server(
move |req| {
req.respond(Response::empty(StatusCode(502)))
.expect("respond");
},
10,
);
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Index,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(20),
max_total_wait: Duration::from_millis(80),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(!visible);
assert!(evidence.iter().all(|e| !e.visible));
handle.join().expect("join");
}
#[test]
fn both_mode_prefer_index_true_checks_index_first() {
use std::sync::Arc;
let call_order = Arc::new(std::sync::Mutex::new(Vec::new()));
let call_order_clone = call_order.clone();
let (api_base, handle) = with_multi_server(
move |req| {
let url = req.url().to_string();
let mut order = call_order_clone.lock().unwrap();
if url.contains("/api/v1/crates/") {
order.push("api".to_string());
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
} else {
order.push("index".to_string());
req.respond(Response::empty(StatusCode(404)))
.expect("respond");
}
},
5,
);
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Both,
initial_delay: Duration::ZERO,
max_delay: Duration::from_secs(1),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(50),
jitter_factor: 0.0,
index_path: None,
prefer_index: true,
};
let (visible, _) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
let order = call_order.lock().unwrap();
assert!(order.len() >= 2);
assert_eq!(order[0], "index");
assert_eq!(order[1], "api");
handle.join().expect("join");
}
#[test]
fn both_mode_prefer_index_false_checks_api_first() {
use std::sync::Arc;
let call_order = Arc::new(std::sync::Mutex::new(Vec::new()));
let call_order_clone = call_order.clone();
let index_content = "{\"vers\":\"1.0.0\"}\n";
let (api_base, handle) = with_multi_server(
move |req| {
let url = req.url().to_string();
let mut order = call_order_clone.lock().unwrap();
if url.contains("/api/v1/crates/") {
order.push("api".to_string());
req.respond(Response::empty(StatusCode(404)))
.expect("respond");
} else {
order.push("index".to_string());
req.respond(
Response::from_string(index_content).with_status_code(StatusCode(200)),
)
.expect("respond");
}
},
5,
);
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Both,
initial_delay: Duration::ZERO,
max_delay: Duration::from_secs(1),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(50),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, _) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
let order = call_order.lock().unwrap();
assert!(order.len() >= 2);
assert_eq!(order[0], "api");
assert_eq!(order[1], "index");
handle.join().expect("join");
}
#[test]
fn snapshot_owners_response_parsed() {
let (api_base, handle) = with_server(|req| {
let body = r#"{"users":[{"id":42,"login":"alice","name":"Alice Wonderland"},{"id":99,"login":"bob","name":null}]}"#;
let resp = Response::from_string(body)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let owners = cli.list_owners("demo", "token").expect("owners");
insta::assert_debug_snapshot!("owners_response_parsed", owners);
handle.join().expect("join");
}
#[test]
fn snapshot_readiness_evidence_single_attempt() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_secs(1),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(50),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
assert_eq!(evidence.len(), 1);
insta::assert_debug_snapshot!(
"readiness_evidence_single_attempt",
evidence
.iter()
.map(|e| {
format!(
"attempt={} visible={} delay_before={}ms",
e.attempt,
e.visible,
e.delay_before.as_millis()
)
})
.collect::<Vec<_>>()
);
handle.join().expect("join");
}
mod property_tests_registry {
use proptest::prelude::*;
fn crate_name_strategy() -> impl Strategy<Value = String> {
"[a-z][a-z0-9_-]{0,63}".prop_map(|s| s)
}
proptest! {
#[test]
fn random_crate_names_produce_valid_api_url(name in crate_name_strategy()) {
let api_base = "https://crates.io";
let url = format!(
"{}/api/v1/crates/{}/{}",
api_base.trim_end_matches('/'),
name,
"1.0.0"
);
prop_assert!(!url.contains(' '));
prop_assert!(url.starts_with("https://"));
prop_assert!(url.contains("/api/v1/crates/"));
prop_assert!(url.parse::<reqwest::Url>().is_ok());
}
#[test]
fn random_crate_names_produce_valid_index_path(name in crate_name_strategy()) {
let path = shipper_sparse_index::sparse_index_path(&name);
prop_assert!(!path.is_empty());
prop_assert!(path.contains(&name.to_lowercase()));
let segments: Vec<&str> = path.split('/').collect();
match name.len() {
1 => {
prop_assert_eq!(segments.len(), 2);
prop_assert_eq!(segments[0], "1");
}
2 => {
prop_assert_eq!(segments.len(), 2);
prop_assert_eq!(segments[0], "2");
}
3 => {
prop_assert_eq!(segments.len(), 3);
prop_assert_eq!(segments[0], "3");
}
_ => {
prop_assert_eq!(segments.len(), 3);
prop_assert_eq!(segments[0].len(), 2);
prop_assert_eq!(segments[1].len(), 2);
}
}
}
}
}
#[test]
fn version_exists_errors_for_401_unauthorized() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(401)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.version_exists("demo", "1.0.0")
.expect_err("401 must fail");
assert!(format!("{err:#}").contains("unexpected status"));
handle.join().expect("join");
}
#[test]
fn version_exists_errors_for_403_forbidden() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(403)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.version_exists("demo", "1.0.0")
.expect_err("403 must fail");
assert!(format!("{err:#}").contains("unexpected status"));
handle.join().expect("join");
}
#[test]
fn crate_exists_errors_for_401_unauthorized() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(401)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli.crate_exists("demo").expect_err("401 must fail");
assert!(format!("{err:#}").contains("unexpected status"));
handle.join().expect("join");
}
#[test]
fn crate_exists_errors_for_502_bad_gateway() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(502)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli.crate_exists("demo").expect_err("502 must fail");
assert!(format!("{err:#}").contains("unexpected status"));
handle.join().expect("join");
}
#[test]
fn crate_exists_errors_for_503_service_unavailable() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(503)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli.crate_exists("demo").expect_err("503 must fail");
assert!(format!("{err:#}").contains("unexpected status"));
handle.join().expect("join");
}
#[test]
fn list_owners_errors_for_401_unauthorized() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(401)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli.list_owners("demo", "token").expect_err("401 must fail");
assert!(format!("{err:#}").contains("unexpected status while querying owners"));
handle.join().expect("join");
}
#[test]
fn list_owners_errors_for_502_bad_gateway() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(502)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli.list_owners("demo", "token").expect_err("502 must fail");
assert!(format!("{err:#}").contains("unexpected status while querying owners"));
handle.join().expect("join");
}
#[test]
fn rate_limit_429_treated_as_not_visible_in_api_backoff() {
let (api_base, handle) = with_multi_server(
move |req| {
req.respond(Response::empty(StatusCode(429)))
.expect("respond");
},
10,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(20),
max_total_wait: Duration::from_millis(80),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(!visible);
assert!(evidence.len() >= 2);
assert!(evidence.iter().all(|e| !e.visible));
handle.join().expect("join");
}
#[test]
fn rate_limit_429_then_success_in_backoff() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let (api_base, handle) = with_multi_server(
move |req| {
let n = counter_clone.fetch_add(1, Ordering::SeqCst);
let status = if n < 2 { 429 } else { 200 };
req.respond(Response::empty(StatusCode(status)))
.expect("respond");
},
5,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(20),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
assert!(evidence.len() >= 3);
assert!(!evidence[0].visible);
assert!(!evidence[1].visible);
assert!(evidence.last().unwrap().visible);
handle.join().expect("join");
}
#[test]
fn list_owners_errors_on_empty_response_body() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string("")
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.list_owners("demo", "token")
.expect_err("empty body must fail");
assert!(format!("{err:#}").contains("failed to parse owners JSON"));
handle.join().expect("join");
}
#[test]
fn list_owners_errors_on_html_error_page() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string("<html><body>503 Service Unavailable</body></html>")
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "text/html").expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.list_owners("demo", "token")
.expect_err("html must fail");
assert!(format!("{err:#}").contains("failed to parse owners JSON"));
handle.join().expect("join");
}
#[test]
fn list_owners_parses_response_with_multiple_owners() {
let body = r#"{"users":[
{"id":1,"login":"alice","name":"Alice"},
{"id":2,"login":"bob","name":null},
{"id":3,"login":"charlie","name":"Charlie D."}
]}"#;
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string(body)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let owners = cli.list_owners("demo", "token").expect("owners");
assert_eq!(owners.users.len(), 3);
assert_eq!(owners.users[0].login, "alice");
assert_eq!(owners.users[1].login, "bob");
assert_eq!(owners.users[2].login, "charlie");
assert!(owners.users[1].name.is_none());
handle.join().expect("join");
}
#[test]
fn list_owners_parses_empty_users_array() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string(r#"{"users":[]}"#)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let owners = cli.list_owners("demo", "token").expect("owners");
assert!(owners.users.is_empty());
handle.join().expect("join");
}
#[test]
fn list_owners_parses_large_response() {
let mut users = Vec::new();
for i in 0..100 {
users.push(format!(
r#"{{"id":{},"login":"user{}","name":"User {}"}}"#,
i, i, i
));
}
let body = format!(r#"{{"users":[{}]}}"#, users.join(","));
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string(body.as_str())
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let owners = cli.list_owners("demo", "token").expect("owners");
assert_eq!(owners.users.len(), 100);
assert_eq!(owners.users[99].login, "user99");
handle.join().expect("join");
}
#[test]
fn check_index_visibility_with_large_index() {
let mut lines = Vec::new();
for i in 0..500 {
lines.push(format!(r#"{{"vers":"{}.0.0"}}"#, i));
}
let index_content: String = lines.join("\n") + "\n";
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string(index_content.as_str())
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
assert!(
cli.check_index_visibility("demo", "499.0.0")
.expect("check")
);
handle.join().expect("join");
}
#[test]
fn parse_version_from_index_with_large_content() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let mut lines = Vec::new();
for i in 0..1000 {
lines.push(format!(r#"{{"vers":"0.{}.0"}}"#, i));
}
let content = lines.join("\n") + "\n";
assert!(cli.parse_version_from_index(&content, "0.999.0").unwrap());
assert!(!cli.parse_version_from_index(&content, "0.1000.0").unwrap());
}
#[test]
fn version_exists_errors_on_connection_refused() {
let server = tiny_http::Server::http("127.0.0.1:0").expect("server");
let addr = format!("http://{}", server.server_addr());
drop(server);
let cli = RegistryClient::new(test_registry(addr)).expect("client");
let err = cli
.version_exists("demo", "1.0.0")
.expect_err("connection refused must fail");
assert!(format!("{err:#}").contains("registry request failed"));
}
#[test]
fn crate_exists_errors_on_connection_refused() {
let server = tiny_http::Server::http("127.0.0.1:0").expect("server");
let addr = format!("http://{}", server.server_addr());
drop(server);
let cli = RegistryClient::new(test_registry(addr)).expect("client");
let err = cli
.crate_exists("demo")
.expect_err("connection refused must fail");
assert!(format!("{err:#}").contains("registry request failed"));
}
#[test]
fn list_owners_errors_on_connection_refused() {
let server = tiny_http::Server::http("127.0.0.1:0").expect("server");
let addr = format!("http://{}", server.server_addr());
drop(server);
let cli = RegistryClient::new(test_registry(addr)).expect("client");
let err = cli
.list_owners("demo", "token")
.expect_err("connection refused must fail");
assert!(format!("{err:#}").contains("registry owners request failed"));
}
#[test]
fn fetch_index_file_errors_for_unexpected_status_code() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(500)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
assert!(!visible);
handle.join().expect("join");
}
#[test]
fn check_index_visibility_returns_false_for_429() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(429)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
assert!(!visible);
handle.join().expect("join");
}
#[test]
fn check_index_visibility_returns_false_for_503() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(503)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
assert!(!visible);
handle.join().expect("join");
}
#[test]
fn index_with_304_not_modified_without_cache_returns_error_gracefully() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(304)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
assert!(!visible);
handle.join().expect("join");
}
#[test]
fn index_with_304_not_modified_uses_cache() {
let cache_dir = tempfile::tempdir().expect("tempdir");
let cache_path = cache_dir.path().join("de").join("mo").join("demo");
std::fs::create_dir_all(cache_path.parent().unwrap()).expect("mkdir");
std::fs::write(&cache_path, "{\"vers\":\"2.0.0\"}\n").expect("write cache");
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(304)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base))
.expect("client")
.with_cache_dir(cache_dir.path().to_path_buf());
let visible = cli.check_index_visibility("demo", "2.0.0").expect("check");
assert!(visible);
handle.join().expect("join");
}
#[test]
fn index_200_writes_cache_and_etag() {
let cache_dir = tempfile::tempdir().expect("tempdir");
let index_content = "{\"vers\":\"3.0.0\"}\n";
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string(index_content)
.with_status_code(StatusCode(200))
.with_header(tiny_http::Header::from_bytes("ETag", "\"abc123\"").expect("header"));
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base))
.expect("client")
.with_cache_dir(cache_dir.path().to_path_buf());
let visible = cli.check_index_visibility("demo", "3.0.0").expect("check");
assert!(visible);
let cache_path = cache_dir.path().join("de").join("mo").join("demo");
assert!(cache_path.exists());
let cached = std::fs::read_to_string(&cache_path).expect("read cache");
assert!(cached.contains("3.0.0"));
let etag_path = cache_path.with_extension("etag");
assert!(etag_path.exists());
let etag = std::fs::read_to_string(&etag_path).expect("read etag");
assert_eq!(etag, "\"abc123\"");
handle.join().expect("join");
}
#[test]
fn index_sends_etag_as_if_none_match() {
use std::sync::Arc;
use std::sync::Mutex;
let cache_dir = tempfile::tempdir().expect("tempdir");
let cache_path = cache_dir.path().join("de").join("mo").join("demo");
std::fs::create_dir_all(cache_path.parent().unwrap()).expect("mkdir");
std::fs::write(&cache_path, "{\"vers\":\"1.0.0\"}\n").expect("write");
std::fs::write(cache_path.with_extension("etag"), "\"etag-val\"").expect("write etag");
let received_header = Arc::new(Mutex::new(None));
let received_header_clone = received_header.clone();
let (api_base, handle) = with_server(move |req| {
let inm = req
.headers()
.iter()
.find(|h| h.field.equiv("If-None-Match"))
.map(|h| h.value.as_str().to_string());
*received_header_clone.lock().unwrap() = inm;
req.respond(Response::empty(StatusCode(304)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base))
.expect("client")
.with_cache_dir(cache_dir.path().to_path_buf());
let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
assert!(visible);
let header = received_header.lock().unwrap().clone();
assert_eq!(header, Some("\"etag-val\"".to_string()));
handle.join().expect("join");
}
#[test]
fn version_exists_with_hyphenated_crate_name() {
let (api_base, handle) = with_server(|req| {
assert_eq!(req.url(), "/api/v1/crates/my-crate/1.0.0");
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert!(cli.version_exists("my-crate", "1.0.0").expect("exists"));
handle.join().expect("join");
}
#[test]
fn version_exists_with_underscore_crate_name() {
let (api_base, handle) = with_server(|req| {
assert_eq!(req.url(), "/api/v1/crates/my_crate/2.0.0");
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert!(cli.version_exists("my_crate", "2.0.0").expect("exists"));
handle.join().expect("join");
}
#[test]
fn calculate_index_path_for_hyphenated_crate() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert_eq!(cli.calculate_index_path("my-crate"), "my/-c/my-crate");
}
#[test]
fn calculate_index_path_lowercases_mixed_case() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert_eq!(cli.calculate_index_path("MyLib"), "my/li/mylib");
assert_eq!(cli.calculate_index_path("UPPER"), "up/pe/upper");
}
#[test]
fn concurrent_version_exists_checks() {
let (api_base, handle) = with_multi_server(
|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
},
5,
);
let cli =
std::sync::Arc::new(RegistryClient::new(test_registry(api_base)).expect("client"));
let handles: Vec<_> = (0..5)
.map(|i| {
let cli = cli.clone();
let version = format!("{i}.0.0");
thread::spawn(move || cli.version_exists("demo", &version))
})
.collect();
for h in handles {
let result = h.join().expect("thread join");
assert!(result.expect("version_exists").eq(&true));
}
handle.join().expect("server join");
}
#[test]
fn concurrent_crate_exists_checks() {
let (api_base, handle) = with_multi_server(
|req| {
let url = req.url().to_string();
if url.contains("missing") {
req.respond(Response::empty(StatusCode(404)))
.expect("respond");
} else {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
}
},
4,
);
let cli =
std::sync::Arc::new(RegistryClient::new(test_registry(api_base)).expect("client"));
let names = ["found1", "found2", "missing1", "missing2"];
let handles: Vec<_> = names
.iter()
.map(|name| {
let cli = cli.clone();
let name = name.to_string();
thread::spawn(move || (name.clone(), cli.crate_exists(&name)))
})
.collect();
for h in handles {
let (name, result) = h.join().expect("thread join");
let exists = result.expect("crate_exists");
if name.contains("missing") {
assert!(!exists, "{name} should not exist");
} else {
assert!(exists, "{name} should exist");
}
}
handle.join().expect("server join");
}
#[test]
fn verify_ownership_returns_false_on_401_unauthorized() {
let (api_base, handle) = with_server(move |req| {
req.respond(Response::empty(StatusCode(401)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let verified = cli.verify_ownership("demo", "fake-token").expect("verify");
assert!(!verified);
handle.join().expect("join");
}
#[test]
fn verify_ownership_propagates_network_error() {
let server = tiny_http::Server::http("127.0.0.1:0").expect("server");
let addr = format!("http://{}", server.server_addr());
drop(server);
let cli = RegistryClient::new(test_registry(addr)).expect("client");
let result = cli.verify_ownership("demo", "token");
assert!(result.is_err());
}
#[test]
fn parse_version_from_index_only_whitespace_lines() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let content = " \n \n\t\n";
assert!(!cli.parse_version_from_index(content, "1.0.0").unwrap());
}
#[test]
fn parse_version_from_index_mixed_valid_and_garbage_lines() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let content = "garbage\n{\"vers\":\"1.0.0\"}\n<<invalid>>\n{\"vers\":\"2.0.0\"}\nnull\n";
assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
assert!(cli.parse_version_from_index(content, "2.0.0").unwrap());
assert!(!cli.parse_version_from_index(content, "3.0.0").unwrap());
}
#[test]
fn parse_version_from_index_json_array_instead_of_jsonl() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let content = r#"[{"vers":"1.0.0"},{"vers":"2.0.0"}]"#;
assert!(!cli.parse_version_from_index(content, "1.0.0").unwrap());
}
#[test]
fn parse_version_from_index_extra_fields_ignored() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let content = r#"{"name":"demo","vers":"1.0.0","cksum":"abc123","deps":[],"features":{},"yanked":false}"#;
assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
}
#[test]
fn calculate_backoff_delay_zero_base() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let delay = cli.calculate_backoff_delay(Duration::ZERO, Duration::from_secs(10), 5, 0.0);
assert_eq!(delay, Duration::ZERO);
}
#[test]
fn calculate_backoff_delay_zero_max() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let delay = cli.calculate_backoff_delay(Duration::from_millis(100), Duration::ZERO, 3, 0.0);
assert_eq!(delay, Duration::ZERO);
}
#[test]
fn calculate_backoff_delay_attempt_overflow_is_safe() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let delay = cli.calculate_backoff_delay(
Duration::from_millis(100),
Duration::from_secs(60),
u32::MAX,
0.0,
);
assert!(delay <= Duration::from_secs(60));
}
#[test]
fn calculate_backoff_delay_full_jitter_stays_in_range() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
for _ in 0..50 {
let delay = cli.calculate_backoff_delay(
Duration::from_millis(100),
Duration::from_secs(10),
1,
1.0,
);
assert!(delay <= Duration::from_millis(200));
}
}
#[test]
fn version_exists_normalizes_trailing_slash() {
let (api_base, handle) = with_server(|req| {
assert_eq!(req.url(), "/api/v1/crates/demo/1.0.0");
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let registry = Registry {
name: "test".to_string(),
api_base: format!("{}/", api_base),
index_base: None,
};
let cli = RegistryClient::new(registry).expect("client");
assert!(cli.version_exists("demo", "1.0.0").expect("exists"));
handle.join().expect("join");
}
#[test]
fn crate_exists_normalizes_trailing_slash() {
let (api_base, handle) = with_server(|req| {
assert_eq!(req.url(), "/api/v1/crates/demo");
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let registry = Registry {
name: "test".to_string(),
api_base: format!("{}/", api_base),
index_base: None,
};
let cli = RegistryClient::new(registry).expect("client");
assert!(cli.crate_exists("demo").expect("exists"));
handle.join().expect("join");
}
#[test]
fn check_new_crate_propagates_server_errors() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(500)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli.check_new_crate("demo").expect_err("500 must propagate");
assert!(format!("{err:#}").contains("unexpected status"));
handle.join().expect("join");
}
#[test]
fn backoff_handles_alternating_404_and_500_then_success() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let (api_base, handle) = with_multi_server(
move |req| {
let n = counter_clone.fetch_add(1, Ordering::SeqCst);
let status = match n {
0 => 404,
1 => 500,
2 => 404,
_ => 200,
};
req.respond(Response::empty(StatusCode(status)))
.expect("respond");
},
6,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(20),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
assert!(evidence.len() >= 4);
assert!(!evidence[0].visible);
assert!(!evidence[1].visible);
assert!(!evidence[2].visible);
assert!(evidence.last().unwrap().visible);
handle.join().expect("join");
}
#[test]
fn index_mode_backoff_uses_cached_content_on_304() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let cache_dir = tempfile::tempdir().expect("tempdir");
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let (api_base, handle) = with_multi_server(
move |req| {
let n = counter_clone.fetch_add(1, Ordering::SeqCst);
if n == 0 {
let resp = Response::from_string("{\"vers\":\"0.9.0\"}\n")
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("ETag", "\"v1\"").expect("header"),
);
req.respond(resp).expect("respond");
} else {
let resp =
Response::from_string("{\"vers\":\"0.9.0\"}\n{\"vers\":\"1.0.0\"}\n")
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("ETag", "\"v2\"").expect("header"),
);
req.respond(resp).expect("respond");
}
},
5,
);
let cli = RegistryClient::new(test_registry_with_index(api_base))
.expect("client")
.with_cache_dir(cache_dir.path().to_path_buf());
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Index,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(30),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
assert!(evidence.len() >= 2);
assert!(!evidence[0].visible);
assert!(evidence.last().unwrap().visible);
handle.join().expect("join");
}
#[test]
fn with_cache_dir_sets_cache_directory() {
let registry = Registry {
name: "test".to_string(),
api_base: "https://example.com".to_string(),
index_base: None,
};
let cli = RegistryClient::new(registry)
.expect("client")
.with_cache_dir(std::path::PathBuf::from("/tmp/test-cache"));
assert_eq!(cli.registry().name, "test");
}
#[test]
fn registry_accessor_returns_correct_values() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let registry = Registry {
name: "custom-registry".to_string(),
api_base: api_base.clone(),
index_base: Some("https://index.custom.io".to_string()),
};
let cli = RegistryClient::new(registry).expect("client");
assert_eq!(cli.registry().name, "custom-registry");
assert_eq!(cli.registry().api_base, api_base);
assert_eq!(
cli.registry().index_base.as_deref(),
Some("https://index.custom.io")
);
}
#[test]
fn registry_get_index_base_strips_sparse_prefix() {
let registry = Registry {
name: "test".to_string(),
api_base: "https://example.com".to_string(),
index_base: Some("sparse+https://index.example.com".to_string()),
};
assert_eq!(registry.get_index_base(), "https://index.example.com");
}
#[test]
fn registry_get_index_base_leaves_non_sparse_prefix() {
let registry = Registry {
name: "test".to_string(),
api_base: "https://example.com".to_string(),
index_base: Some("https://index.example.com".to_string()),
};
assert_eq!(registry.get_index_base(), "https://index.example.com");
}
#[test]
fn version_exists_429_with_retry_after_header_still_errors() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string("rate limited")
.with_status_code(StatusCode(429))
.with_header(tiny_http::Header::from_bytes("Retry-After", "30").expect("header"));
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.version_exists("demo", "1.0.0")
.expect_err("429 with Retry-After must fail");
let msg = format!("{err:#}");
assert!(msg.contains("unexpected status"));
assert!(msg.contains("429"));
handle.join().expect("join");
}
#[test]
fn crate_exists_429_with_retry_after_header_still_errors() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string("rate limited")
.with_status_code(StatusCode(429))
.with_header(tiny_http::Header::from_bytes("Retry-After", "60").expect("header"));
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.crate_exists("demo")
.expect_err("429 with Retry-After must fail");
let msg = format!("{err:#}");
assert!(msg.contains("unexpected status"));
handle.join().expect("join");
}
#[test]
fn list_owners_429_with_retry_after_header_still_errors() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string(r#"{"errors":[{"detail":"rate limited"}]}"#)
.with_status_code(StatusCode(429))
.with_header(tiny_http::Header::from_bytes("Retry-After", "120").expect("header"));
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.list_owners("demo", "token")
.expect_err("429 with Retry-After must fail");
assert!(format!("{err:#}").contains("unexpected status while querying owners"));
handle.join().expect("join");
}
#[test]
fn version_exists_error_message_includes_status_code_text() {
for code in [500, 502, 503] {
let (api_base, handle) = with_server(move |req| {
req.respond(Response::empty(StatusCode(code)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.version_exists("demo", "1.0.0")
.expect_err("server error must fail");
let msg = format!("{err:#}");
assert!(
msg.contains("unexpected status"),
"error for {code} should mention unexpected status: {msg}"
);
handle.join().expect("join");
}
}
#[test]
fn crate_exists_error_message_includes_status_code_text() {
for code in [500, 502, 503] {
let (api_base, handle) = with_server(move |req| {
req.respond(Response::empty(StatusCode(code)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.crate_exists("demo")
.expect_err("server error must fail");
let msg = format!("{err:#}");
assert!(
msg.contains("unexpected status"),
"error for {code} should mention unexpected status: {msg}"
);
handle.join().expect("join");
}
}
#[test]
fn list_owners_errors_for_503_service_unavailable() {
let (api_base, handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(503)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli.list_owners("demo", "token").expect_err("503 must fail");
assert!(format!("{err:#}").contains("unexpected status while querying owners"));
handle.join().expect("join");
}
#[test]
fn list_owners_ignores_unknown_extra_fields_in_json() {
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}}"#;
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string(body)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let owners = cli
.list_owners("demo", "token")
.expect("should parse despite extra fields");
assert_eq!(owners.users.len(), 1);
assert_eq!(owners.users[0].login, "alice");
handle.join().expect("join");
}
#[test]
fn list_owners_parses_response_with_special_chars_in_name() {
let body = r#"{"users":[{"id":1,"login":"user-ñ","name":"José García 日本語"}]}"#;
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string(body)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let owners = cli.list_owners("demo", "token").expect("owners");
assert_eq!(owners.users[0].login, "user-ñ");
assert_eq!(owners.users[0].name.as_deref(), Some("José García 日本語"));
handle.join().expect("join");
}
#[test]
fn list_owners_errors_on_null_json_body() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string("null")
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.list_owners("demo", "token")
.expect_err("null body must fail");
assert!(format!("{err:#}").contains("failed to parse owners JSON"));
handle.join().expect("join");
}
#[test]
fn list_owners_errors_on_json_array_instead_of_object() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string(r#"[{"id":1,"login":"alice","name":null}]"#)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.list_owners("demo", "token")
.expect_err("array body must fail");
assert!(format!("{err:#}").contains("failed to parse owners JSON"));
handle.join().expect("join");
}
#[test]
fn parse_version_from_index_build_metadata_versions() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let content =
"{\"vers\":\"1.0.0+build.1\"}\n{\"vers\":\"1.0.0+build.2\"}\n{\"vers\":\"1.0.0\"}\n";
assert!(
cli.parse_version_from_index(content, "1.0.0+build.1")
.unwrap()
);
assert!(
cli.parse_version_from_index(content, "1.0.0+build.2")
.unwrap()
);
assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
assert!(
!cli.parse_version_from_index(content, "1.0.0+build.3")
.unwrap()
);
}
#[test]
fn parse_version_from_index_leading_v_not_matched() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let content = "{\"vers\":\"1.0.0\"}\n";
assert!(!cli.parse_version_from_index(content, "v1.0.0").unwrap());
}
#[test]
fn parse_version_from_index_yanked_field_does_not_affect_match() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let content =
"{\"vers\":\"1.0.0\",\"yanked\":true}\n{\"vers\":\"2.0.0\",\"yanked\":false}\n";
assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
assert!(cli.parse_version_from_index(content, "2.0.0").unwrap());
}
#[test]
fn parse_version_from_index_many_prerelease_identifiers() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let content = "{\"vers\":\"1.0.0-alpha.1.beta.2.rc.3\"}\n";
assert!(
cli.parse_version_from_index(content, "1.0.0-alpha.1.beta.2.rc.3")
.unwrap()
);
assert!(
!cli.parse_version_from_index(content, "1.0.0-alpha.1.beta.2.rc.4")
.unwrap()
);
}
#[test]
fn parse_version_from_index_null_vers_field_skipped() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let content = "{\"vers\":null}\n{\"vers\":\"1.0.0\"}\n";
assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
assert!(!cli.parse_version_from_index(content, "null").unwrap());
}
#[test]
fn parse_version_from_index_numeric_vers_field_skipped() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let content = "{\"vers\":100}\n{\"vers\":\"2.0.0\"}\n";
assert!(cli.parse_version_from_index(content, "2.0.0").unwrap());
assert!(!cli.parse_version_from_index(content, "100").unwrap());
}
#[test]
fn verify_ownership_propagates_500_server_error() {
let (api_base, handle) = with_server(move |req| {
req.respond(Response::empty(StatusCode(500)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let result = cli.verify_ownership("demo", "token");
assert!(result.is_err());
handle.join().expect("join");
}
#[test]
fn verify_ownership_propagates_429_rate_limit() {
let (api_base, handle) = with_server(move |req| {
req.respond(Response::empty(StatusCode(429)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let result = cli.verify_ownership("demo", "token");
assert!(result.is_err());
handle.join().expect("join");
}
#[test]
fn verify_ownership_returns_true_with_single_owner() {
let body = r#"{"users":[{"id":42,"login":"sole-owner","name":"Only Me"}]}"#;
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string(body)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert!(cli.verify_ownership("demo", "token").expect("verify"));
handle.join().expect("join");
}
#[test]
fn verify_ownership_returns_true_with_many_owners() {
let mut users = Vec::new();
for i in 0..20 {
users.push(format!(
r#"{{"id":{},"login":"owner{}","name":null}}"#,
i, i
));
}
let body = format!(r#"{{"users":[{}]}}"#, users.join(","));
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string(body.as_str())
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert!(cli.verify_ownership("demo", "token").expect("verify"));
handle.join().expect("join");
}
#[test]
fn verify_ownership_returns_true_even_with_empty_owners_list() {
let body = r#"{"users":[]}"#;
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string(body)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert!(cli.verify_ownership("demo", "token").expect("verify"));
handle.join().expect("join");
}
#[test]
fn index_200_without_etag_header_still_caches_content() {
let cache_dir = tempfile::tempdir().expect("tempdir");
let index_content = "{\"vers\":\"1.0.0\"}\n";
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string(index_content).with_status_code(StatusCode(200));
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base))
.expect("client")
.with_cache_dir(cache_dir.path().to_path_buf());
let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
assert!(visible);
let cache_path = cache_dir.path().join("de").join("mo").join("demo");
assert!(cache_path.exists());
let etag_path = cache_path.with_extension("etag");
assert!(!etag_path.exists());
handle.join().expect("join");
}
#[test]
fn index_cache_populated_on_first_200_used_on_subsequent_304() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let cache_dir = tempfile::tempdir().expect("tempdir");
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let (api_base, handle) = with_multi_server(
move |req| {
let n = counter_clone.fetch_add(1, Ordering::SeqCst);
if n == 0 {
let resp = Response::from_string("{\"vers\":\"1.0.0\"}\n")
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("ETag", "\"first\"").expect("header"),
);
req.respond(resp).expect("respond");
} else {
req.respond(Response::empty(StatusCode(304)))
.expect("respond");
}
},
3,
);
let cli = RegistryClient::new(test_registry_with_index(api_base))
.expect("client")
.with_cache_dir(cache_dir.path().to_path_buf());
assert!(cli.check_index_visibility("demo", "1.0.0").expect("1st"));
assert!(cli.check_index_visibility("demo", "1.0.0").expect("2nd"));
handle.join().expect("join");
}
#[test]
fn backoff_429_then_500_then_success() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let (api_base, handle) = with_multi_server(
move |req| {
let n = counter_clone.fetch_add(1, Ordering::SeqCst);
let status = match n {
0 => 429, 1 => 500, _ => 200, };
req.respond(Response::empty(StatusCode(status)))
.expect("respond");
},
5,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(20),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
assert!(evidence.len() >= 3);
assert!(!evidence[0].visible);
assert!(!evidence[1].visible);
assert!(evidence.last().unwrap().visible);
handle.join().expect("join");
}
#[test]
fn backoff_index_mode_with_server_errors_gracefully_degrades() {
let (api_base, handle) = with_multi_server(
move |req| {
req.respond(Response::empty(StatusCode(502)))
.expect("respond");
},
10,
);
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Index,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(20),
max_total_wait: Duration::from_millis(80),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("should not error");
assert!(!visible);
assert!(evidence.len() >= 2);
handle.join().expect("join");
}
#[test]
fn snapshot_readiness_evidence_multi_attempt() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let (api_base, handle) = with_multi_server(
move |req| {
let n = counter_clone.fetch_add(1, Ordering::SeqCst);
let status = if n < 2 { 404 } else { 200 };
req.respond(Response::empty(StatusCode(status)))
.expect("respond");
},
5,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(50),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
assert_eq!(evidence.len(), 3);
insta::assert_debug_snapshot!(
"readiness_evidence_multi_attempt",
evidence
.iter()
.map(|e| {
format!(
"attempt={} visible={} delay_before={}ms",
e.attempt,
e.visible,
e.delay_before.as_millis()
)
})
.collect::<Vec<_>>()
);
handle.join().expect("join");
}
#[test]
fn snapshot_owners_empty_users() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string(r#"{"users":[]}"#)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let owners = cli.list_owners("demo", "token").expect("owners");
insta::assert_debug_snapshot!("owners_empty_users", owners);
handle.join().expect("join");
}
#[test]
fn snapshot_owners_multiple_with_mixed_names() {
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"}]}"#;
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string(body)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let owners = cli.list_owners("demo", "token").expect("owners");
insta::assert_debug_snapshot!("owners_multiple_with_mixed_names", owners);
handle.join().expect("join");
}
#[test]
fn snapshot_registry_debug_repr() {
let registry = Registry {
name: "crates-io".to_string(),
api_base: "https://crates.io".to_string(),
index_base: Some("https://index.crates.io".to_string()),
};
insta::assert_debug_snapshot!("registry_debug_repr", registry);
}
#[test]
fn snapshot_registry_debug_repr_no_index() {
let registry = Registry {
name: "private".to_string(),
api_base: "https://registry.example.com".to_string(),
index_base: None,
};
insta::assert_debug_snapshot!("registry_debug_repr_no_index", registry);
}
#[test]
fn rate_limit_429_backoff_retries_multiple_times_before_success() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let (api_base, handle) = with_multi_server(
move |req| {
let n = counter_clone.fetch_add(1, Ordering::SeqCst);
let status = if n < 4 { 429 } else { 200 };
req.respond(Response::empty(StatusCode(status)))
.expect("respond");
},
8,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(20),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
assert!(
evidence.len() >= 5,
"expected >=5 attempts, got {}",
evidence.len()
);
assert!(evidence[..4].iter().all(|e| !e.visible));
assert!(evidence.last().unwrap().visible);
handle.join().expect("join");
}
#[test]
fn rate_limit_429_continuous_causes_timeout() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let request_count = Arc::new(AtomicU32::new(0));
let request_count_clone = request_count.clone();
let (api_base, handle) = with_multi_server(
move |req| {
request_count_clone.fetch_add(1, Ordering::SeqCst);
req.respond(Response::empty(StatusCode(429)))
.expect("respond");
},
30,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(15),
max_total_wait: Duration::from_millis(60),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(!visible);
let total_requests = request_count.load(Ordering::SeqCst);
assert!(
total_requests >= 2,
"expected at least 2 requests during rate limiting, got {}",
total_requests
);
assert!(evidence.iter().all(|e| !e.visible));
handle.join().expect("join");
}
#[test]
fn server_errors_500_502_503_all_classified_as_not_visible_in_backoff() {
for error_code in [500u16, 502, 503] {
let (api_base, handle) = with_multi_server(
move |req| {
req.respond(Response::empty(StatusCode(error_code)))
.expect("respond");
},
10,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(15),
max_total_wait: Duration::from_millis(60),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.unwrap_or_else(|_| panic!("backoff with {error_code}"));
assert!(
!visible,
"{error_code} should be treated as not-visible in backoff"
);
assert!(
evidence.len() >= 2,
"{error_code} should trigger retries, got {} attempts",
evidence.len()
);
handle.join().expect("join");
}
}
#[test]
fn server_error_then_recovery_succeeds_for_each_5xx() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
for error_code in [500u16, 502, 503] {
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let (api_base, handle) = with_multi_server(
move |req| {
let n = counter_clone.fetch_add(1, Ordering::SeqCst);
let status = if n < 1 { error_code } else { 200 };
req.respond(Response::empty(StatusCode(status)))
.expect("respond");
},
5,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(20),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.unwrap_or_else(|_| panic!("recovery after {error_code}"));
assert!(visible, "should recover after transient {error_code} error");
assert!(evidence.len() >= 2);
assert!(!evidence[0].visible);
assert!(evidence.last().unwrap().visible);
handle.join().expect("join");
}
}
#[test]
fn list_owners_errors_on_200_with_binary_garbage() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string("\x00\x01\x02\x7e\x7f")
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/octet-stream")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.list_owners("demo", "token")
.expect_err("binary garbage must fail");
assert!(format!("{err:#}").contains("failed to parse owners JSON"));
handle.join().expect("join");
}
#[test]
fn list_owners_errors_on_200_with_valid_json_wrong_types() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string(r#"{"users":"not-an-array"}"#)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.list_owners("demo", "token")
.expect_err("wrong types must fail");
assert!(format!("{err:#}").contains("failed to parse owners JSON"));
handle.join().expect("join");
}
#[test]
fn list_owners_errors_on_200_with_nested_invalid_user_object() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string(
r#"{"users":[{"id":"not-a-number","login":"alice","name":null}]}"#,
)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json").expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let err = cli
.list_owners("demo", "token")
.expect_err("bad id type must fail");
assert!(format!("{err:#}").contains("failed to parse owners JSON"));
handle.join().expect("join");
}
#[test]
fn version_exists_false_for_nonexistent_version_with_prerelease() {
let (api_base, handle) = with_server(|req| {
assert_eq!(req.url(), "/api/v1/crates/demo/0.1.0-alpha.1");
req.respond(Response::empty(StatusCode(404)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let exists = cli.version_exists("demo", "0.1.0-alpha.1").expect("exists");
assert!(!exists);
handle.join().expect("join");
}
#[test]
fn backoff_version_appears_after_initial_not_found() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let (api_base, handle) = with_multi_server(
move |req| {
let n = counter_clone.fetch_add(1, Ordering::SeqCst);
let status = if n < 3 { 404 } else { 200 };
req.respond(Response::empty(StatusCode(status)))
.expect("respond");
},
6,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(20),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
assert_eq!(evidence.len(), 4);
for e in &evidence[..3] {
assert!(!e.visible, "should be not-found before appearing");
}
assert!(evidence[3].visible, "should become visible on 4th attempt");
handle.join().expect("join");
}
#[test]
fn index_path_normalizes_hyphens_and_underscores_independently() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let hyphen_path = cli.calculate_index_path("my-crate-lib");
let underscore_path = cli.calculate_index_path("my_crate_lib");
assert!(!hyphen_path.is_empty());
assert!(!underscore_path.is_empty());
assert_ne!(
hyphen_path, underscore_path,
"index paths for hyphen/underscore crates should differ"
);
}
#[test]
fn version_exists_passes_hyphenated_name_in_url() {
let (api_base, handle) = with_server(|req| {
assert_eq!(req.url(), "/api/v1/crates/my-crate-name/1.0.0");
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert!(cli.version_exists("my-crate-name", "1.0.0").expect("ok"));
handle.join().expect("join");
}
#[test]
fn version_exists_passes_underscored_name_in_url() {
let (api_base, handle) = with_server(|req| {
assert_eq!(req.url(), "/api/v1/crates/my_crate_name/1.0.0");
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert!(cli.version_exists("my_crate_name", "1.0.0").expect("ok"));
handle.join().expect("join");
}
#[test]
fn crate_exists_preserves_hyphenated_name_in_url() {
let (api_base, handle) = with_server(|req| {
assert_eq!(req.url(), "/api/v1/crates/serde-json");
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert!(cli.crate_exists("serde-json").expect("ok"));
handle.join().expect("join");
}
#[test]
fn version_exists_with_max_length_crate_name() {
let long_name = "a".repeat(64);
let expected_url = format!("/api/v1/crates/{}/1.0.0", long_name);
let (api_base, handle) = with_server(move |req| {
assert_eq!(req.url(), expected_url);
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert!(cli.version_exists(&"a".repeat(64), "1.0.0").expect("ok"));
handle.join().expect("join");
}
#[test]
fn calculate_index_path_for_long_crate_name() {
let (api_base, _handle) = with_server(|req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let long_name = "abcdefghijklmnopqrstuvwxyz01234567890123456789012345678901234567";
let path = cli.calculate_index_path(long_name);
assert!(path.starts_with("ab/cd/"));
assert!(path.ends_with(long_name));
}
#[test]
fn crate_exists_with_long_name_sends_correct_url() {
let long_name = format!("{}-{}", "x".repeat(30), "y".repeat(30));
let expected_url = format!("/api/v1/crates/{}", long_name);
let (api_base, handle) = with_server(move |req| {
assert_eq!(req.url(), expected_url);
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let name = format!("{}-{}", "x".repeat(30), "y".repeat(30));
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert!(cli.crate_exists(&name).expect("ok"));
handle.join().expect("join");
}
#[test]
fn version_exists_sends_prerelease_version_in_url() {
let (api_base, handle) = with_server(|req| {
assert_eq!(req.url(), "/api/v1/crates/demo/0.1.0-alpha.1");
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
assert!(cli.version_exists("demo", "0.1.0-alpha.1").expect("ok"));
handle.join().expect("join");
}
#[test]
fn check_index_visibility_finds_prerelease_version() {
let index_content =
"{\"vers\":\"0.1.0-alpha.1\"}\n{\"vers\":\"0.1.0-beta.1\"}\n{\"vers\":\"0.1.0\"}\n";
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string(index_content)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
assert!(
cli.check_index_visibility("demo", "0.1.0-alpha.1")
.expect("check")
);
handle.join().expect("join");
}
#[test]
fn backoff_with_prerelease_version_succeeds() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let (api_base, handle) = with_multi_server(
move |req| {
let n = counter_clone.fetch_add(1, Ordering::SeqCst);
let status = if n < 1 { 404 } else { 200 };
req.respond(Response::empty(StatusCode(status)))
.expect("respond");
},
5,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(20),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "0.1.0-alpha.1", &config)
.expect("backoff");
assert!(visible);
assert!(evidence.len() >= 2);
handle.join().expect("join");
}
#[test]
fn empty_owners_response_verify_ownership_still_returns_true() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string(r#"{"users":[]}"#)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let verified = cli.verify_ownership("demo", "token").expect("verify");
assert!(verified);
handle.join().expect("join");
}
#[test]
fn snapshot_empty_owner_list_detail() {
let (api_base, handle) = with_server(|req| {
let resp = Response::from_string(r#"{"users":[]}"#)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let owners = cli.list_owners("demo", "token").expect("owners");
assert!(owners.users.is_empty());
insta::assert_debug_snapshot!("empty_owner_list_detail", owners);
handle.join().expect("join");
}
#[test]
fn list_owners_with_team_and_individual_owners() {
let body = r#"{"users":[
{"id":1,"login":"alice","name":"Alice"},
{"id":2,"login":"bob","name":"Bob"},
{"id":3,"login":"github:rust-lang:core","name":"Rust Core Team"},
{"id":4,"login":"github:my-org:devs","name":null}
]}"#;
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string(body)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let owners = cli.list_owners("demo", "token").expect("owners");
assert_eq!(owners.users.len(), 4);
assert_eq!(owners.users[0].login, "alice");
assert_eq!(owners.users[2].login, "github:rust-lang:core");
assert!(owners.users[3].name.is_none());
handle.join().expect("join");
}
#[test]
fn snapshot_owners_with_teams() {
let body = r#"{"users":[
{"id":10,"login":"maintainer","name":"Main Tainer"},
{"id":20,"login":"github:org:team","name":"Org Team"}
]}"#;
let (api_base, handle) = with_server(move |req| {
let resp = Response::from_string(body)
.with_status_code(StatusCode(200))
.with_header(
tiny_http::Header::from_bytes("Content-Type", "application/json")
.expect("header"),
);
req.respond(resp).expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let owners = cli.list_owners("demo", "token").expect("owners");
insta::assert_debug_snapshot!("owners_with_teams", owners);
handle.join().expect("join");
}
#[test]
fn backoff_poll_interval_increases_between_attempts() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let (api_base, handle) = with_multi_server(
move |req| {
let n = counter_clone.fetch_add(1, Ordering::SeqCst);
let status = if n < 3 { 404 } else { 200 };
req.respond(Response::empty(StatusCode(status)))
.expect("respond");
},
6,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_secs(5),
max_total_wait: Duration::from_secs(30),
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0, index_path: None,
prefer_index: false,
};
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
assert!(visible);
assert!(evidence.len() >= 4);
assert_eq!(evidence[0].delay_before, Duration::ZERO);
assert_eq!(evidence[1].delay_before, Duration::from_millis(10));
assert_eq!(evidence[2].delay_before, Duration::from_millis(20));
assert_eq!(evidence[3].delay_before, Duration::from_millis(40));
handle.join().expect("join");
}
#[test]
fn backoff_total_elapsed_time_respects_max_total_wait() {
let (api_base, handle) = with_multi_server(
move |req| {
req.respond(Response::empty(StatusCode(404)))
.expect("respond");
},
30,
);
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let max_wait = Duration::from_millis(200);
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay: Duration::ZERO,
max_delay: Duration::from_millis(30),
max_total_wait: max_wait,
poll_interval: Duration::from_millis(10),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let start = Instant::now();
let (visible, _evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
let elapsed = start.elapsed();
assert!(!visible);
assert!(
elapsed < max_wait + Duration::from_millis(200),
"elapsed {:?} exceeded max_total_wait {:?} by too much",
elapsed,
max_wait
);
handle.join().expect("join");
}
#[test]
fn backoff_initial_delay_is_honored() {
let (api_base, handle) = with_server(move |req| {
req.respond(Response::empty(StatusCode(200)))
.expect("respond");
});
let cli = RegistryClient::new(test_registry(api_base)).expect("client");
let initial_delay = Duration::from_millis(100);
let config = ReadinessConfig {
enabled: true,
method: ReadinessMethod::Api,
initial_delay,
max_delay: Duration::from_secs(1),
max_total_wait: Duration::from_secs(5),
poll_interval: Duration::from_millis(50),
jitter_factor: 0.0,
index_path: None,
prefer_index: false,
};
let start = Instant::now();
let (visible, evidence) = cli
.is_version_visible_with_backoff("demo", "1.0.0", &config)
.expect("backoff");
let elapsed = start.elapsed();
assert!(visible);
assert_eq!(evidence.len(), 1);
assert!(
elapsed >= initial_delay,
"elapsed {:?} should be >= initial_delay {:?}",
elapsed,
initial_delay
);
handle.join().expect("join");
}
mod property_tests_version_strings {
use proptest::prelude::*;
fn semver_strategy() -> impl Strategy<Value = String> {
(0..100u32, 0..100u32, 0..100u32)
.prop_map(|(major, minor, patch)| format!("{major}.{minor}.{patch}"))
}
fn semver_with_prerelease_strategy() -> impl Strategy<Value = String> {
(
0..50u32,
0..50u32,
0..50u32,
proptest::option::of("[a-z]{1,5}\\.[0-9]{1,2}"),
)
.prop_map(|(major, minor, patch, pre)| match pre {
Some(p) => format!("{major}.{minor}.{patch}-{p}"),
None => format!("{major}.{minor}.{patch}"),
})
}
proptest! {
#[test]
fn version_found_when_present_in_index(version in semver_strategy()) {
let content = format!("{{\"vers\":\"{version}\"}}\n");
let found = shipper_sparse_index::contains_version(&content, &version);
prop_assert!(found, "version {version} should be found in index");
}
#[test]
fn version_not_found_when_absent_from_index(
needle in semver_strategy(),
haystack in semver_strategy(),
) {
prop_assume!(needle != haystack);
let content = format!("{{\"vers\":\"{haystack}\"}}\n");
let found = shipper_sparse_index::contains_version(&content, &needle);
prop_assert!(!found, "version {needle} should NOT be found (only {haystack} in index)");
}
#[test]
fn prerelease_version_found_in_index(version in semver_with_prerelease_strategy()) {
let content = format!("{{\"vers\":\"{version}\"}}\n");
let found = shipper_sparse_index::contains_version(&content, &version);
prop_assert!(found, "pre-release version {version} should be found in index");
}
#[test]
fn version_string_in_multi_line_index(
target in semver_strategy(),
other1 in semver_strategy(),
other2 in semver_strategy(),
) {
let content = format!(
"{{\"vers\":\"{other1}\"}}\n{{\"vers\":\"{target}\"}}\n{{\"vers\":\"{other2}\"}}\n"
);
let found = shipper_sparse_index::contains_version(&content, &target);
prop_assert!(found, "version {target} should be found in multi-line index");
}
}
}
}