use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Instant, SystemTime};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::testkit::{gzip, mmdb_body, sha256_hex, tar_gz};
use super::*;
use crate::geoip::config::AutoDownloadConfig;
fn free(provider: GeoIpProvider) -> ProviderChoice {
ProviderChoice::from(provider)
}
fn paid(provider: GeoIpProvider) -> ProviderChoice {
ProviderChoice {
provider,
tier: ProviderTier::Paid,
}
}
fn config_in(dir: &Path, provider: GeoIpProvider) -> GeoIpConfig {
config_for(dir, free(provider))
}
fn config_for(dir: &Path, choice: ProviderChoice) -> GeoIpConfig {
GeoIpConfig {
provider: choice.into(),
auto_download: AutoDownloadConfig {
enabled: true,
data_dir: dir.to_path_buf(),
..Default::default()
},
..Default::default()
}
}
fn credentialled(dir: &Path, provider: GeoIpProvider) -> GeoIpConfig {
credentialled_for(dir, free(provider))
}
fn credentialled_for(dir: &Path, choice: ProviderChoice) -> GeoIpConfig {
let mut config = config_for(dir, choice);
config.auto_download.maxmind_account_id = Some("123456".into());
config.auto_download.maxmind_license_key = Some("secret-key".into());
config.auto_download.ipinfo_token = Some("token-wxyz".into());
config
}
fn default_client() -> reqwest::Client {
let defaults = AutoDownloadConfig::default();
fetch::client(
None,
Duration::from_secs(defaults.connect_timeout_secs),
Duration::from_secs(defaults.read_timeout_secs),
)
.unwrap()
}
fn at_server(server: &MockServer, url: &str) -> String {
let parsed = reqwest::Url::parse(url).unwrap();
match parsed.query() {
Some(query) => format!("{}{}?{query}", server.uri(), parsed.path()),
None => format!("{}{}", server.uri(), parsed.path()),
}
}
fn planned(kind: Kind, config: &GeoIpConfig, server: &MockServer) -> Vec<Transfer> {
plan(kind, config)
.unwrap()
.into_iter()
.map(|mut transfer| {
transfer.url = at_server(server, &transfer.url);
transfer.fallback_url = transfer
.fallback_url
.as_deref()
.map(|url| at_server(server, url));
transfer.checksum_url = transfer
.checksum_url
.as_deref()
.map(|url| at_server(server, url));
transfer
})
.collect()
}
async fn requested_paths(server: &MockServer) -> Vec<String> {
server
.received_requests()
.await
.unwrap()
.iter()
.map(|request| request.url.path().to_string())
.collect()
}
async fn requested_targets(server: &MockServer) -> Vec<String> {
server
.received_requests()
.await
.unwrap()
.iter()
.map(|request| format!("{} {}", request.method, request.url))
.collect()
}
fn path_of(url: &str) -> String {
reqwest::Url::parse(url).unwrap().path().to_string()
}
fn raw_transfer(url: String, dest: PathBuf) -> Transfer {
Transfer {
url,
fallback_url: None,
checksum_url: None,
dest,
archive: Archive::Raw,
format: DatabaseFormat::Mmdb,
credential: Credential::None,
}
}
fn resumable_transfer(url: String, dest: PathBuf, checksum_url: String) -> Transfer {
Transfer {
checksum_url: Some(checksum_url),
..raw_transfer(url, dest)
}
}
fn gzip_transfer(url: String, dest: PathBuf) -> Transfer {
Transfer {
archive: Archive::Gzip,
..raw_transfer(url, dest)
}
}
fn drip_server(chunks: Vec<Vec<u8>>, gap: Duration) -> String {
let length = chunks.iter().map(Vec::len).sum();
drip_server_promising(chunks, Some(length), gap)
}
fn drip_server_promising(chunks: Vec<Vec<u8>>, length: Option<usize>, gap: Duration) -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let address = listener.local_addr().unwrap();
let listener = tokio::net::TcpListener::from_std(listener).unwrap();
tokio::spawn(async move {
let Ok((mut socket, _)) = listener.accept().await else {
return;
};
let mut buffer = [0u8; 2048];
let _ = socket.read(&mut buffer).await;
let head = match length {
Some(length) => format!("HTTP/1.1 200 OK\r\nContent-Length: {length}\r\n\r\n"),
None => "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n".to_string(),
};
let _ = socket.write_all(head.as_bytes()).await;
for (index, chunk) in chunks.into_iter().enumerate() {
if index > 0 {
tokio::time::sleep(gap).await;
}
let _ = socket.write_all(&chunk).await;
let _ = socket.flush().await;
}
});
format!("http://{address}/db.mmdb")
}
fn held_server(
body: Vec<u8>,
) -> (
String,
tokio::sync::oneshot::Receiver<()>,
tokio::sync::oneshot::Sender<()>,
) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let address = listener.local_addr().unwrap();
let listener = tokio::net::TcpListener::from_std(listener).unwrap();
let (arrived, has_arrived) = tokio::sync::oneshot::channel();
let (release, released) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let Ok((mut socket, _)) = listener.accept().await else {
return;
};
let mut buffer = [0u8; 2048];
let _ = socket.read(&mut buffer).await;
let _ = arrived.send(());
let _ = released.await;
let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len());
let _ = socket.write_all(head.as_bytes()).await;
let _ = socket.write_all(&body).await;
let _ = socket.flush().await;
});
(format!("http://{address}/db.mmdb"), has_arrived, release)
}
fn entries(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = fs::read_dir(dir)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| Path::new(name).extension() != Some("lock".as_ref()))
.collect();
names.sort();
names
}
#[test]
fn every_provider_declares_its_files_and_their_format() {
let mmdb = |name| {
Some(DatabaseSpec {
format: DatabaseFormat::Mmdb,
names: name,
})
};
let cases = [
(
free(GeoIpProvider::DbIp),
mmdb(&["dbip-city-lite.mmdb"][..]),
mmdb(&["dbip-asn-lite.mmdb"][..]),
),
(
free(GeoIpProvider::MaxMind),
mmdb(&["GeoLite2-City.mmdb"][..]),
mmdb(&["GeoLite2-ASN.mmdb"][..]),
),
(
paid(GeoIpProvider::MaxMind),
mmdb(&["GeoIP2-City.mmdb"][..]),
mmdb(&["GeoIP2-ISP.mmdb"][..]),
),
(
free(GeoIpProvider::IpInfo),
mmdb(&["ipinfo-lite.mmdb"][..]),
None,
),
(
free(GeoIpProvider::SapicsOriginAsn),
None,
mmdb(&["origin-asn.mmdb"][..]),
),
(
free(GeoIpProvider::SapicsIpToAsn),
None,
mmdb(&["iptoasn-asn.mmdb"][..]),
),
(free(GeoIpProvider::Custom), None, None),
];
for (choice, city, asn) in cases {
assert_eq!(provider_files(choice).unwrap(), (city, asn), "{choice:?}");
}
}
#[test]
fn planned_destinations_match_the_declared_files() {
let dir = PathBuf::from("/var/lib/geoip");
for choice in [
free(GeoIpProvider::DbIp),
free(GeoIpProvider::MaxMind),
paid(GeoIpProvider::MaxMind),
free(GeoIpProvider::IpInfo),
free(GeoIpProvider::SapicsIpToAsn),
free(GeoIpProvider::SapicsOriginAsn),
] {
let config = credentialled_for(&dir, choice);
for kind in [Kind::City, Kind::Asn] {
match (plan(kind, &config), kind.spec(choice).unwrap()) {
(Ok(transfers), Some(spec)) => {
let destinations: Vec<PathBuf> = transfers
.iter()
.map(|transfer| transfer.dest.clone())
.collect();
let declared: Vec<PathBuf> =
spec.names.iter().map(|name| dir.join(name)).collect();
assert_eq!(destinations, declared, "{choice:?} {kind:?}");
}
(Err(GeoIpDownloadError::NoDatabases { .. }), None) => {}
(result, expected) => {
panic!("{choice:?} {kind:?}: {result:?} does not match {expected:?}")
}
}
}
}
}
#[test]
fn each_provider_resolves_its_documented_urls() {
let dir = PathBuf::from("/var/lib/geoip");
let urls = |kind, provider| -> Vec<String> {
plan(kind, &credentialled(&dir, provider))
.unwrap()
.into_iter()
.map(|transfer| transfer.url)
.collect()
};
assert_eq!(
urls(Kind::City, GeoIpProvider::MaxMind),
["https://download.maxmind.com/geoip/databases/GeoLite2-City/download?suffix=tar.gz"]
);
assert_eq!(
urls(Kind::Asn, GeoIpProvider::MaxMind),
["https://download.maxmind.com/geoip/databases/GeoLite2-ASN/download?suffix=tar.gz"]
);
assert_eq!(
urls(Kind::City, GeoIpProvider::IpInfo),
["https://ipinfo.io/data/ipinfo_lite.mmdb"]
);
assert_eq!(
urls(Kind::Asn, GeoIpProvider::SapicsOriginAsn),
["https://github.com/sapics/ip-location-db/releases/download/latest/origin-asn.mmdb"]
);
assert_eq!(
urls(Kind::Asn, GeoIpProvider::SapicsIpToAsn),
["https://github.com/sapics/ip-location-db/releases/download/latest/iptoasn-asn.mmdb"]
);
let checksums = |provider| -> Vec<Option<String>> {
plan(Kind::Asn, &credentialled(&dir, provider))
.unwrap()
.into_iter()
.map(|transfer| transfer.checksum_url)
.collect()
};
assert_eq!(
checksums(GeoIpProvider::SapicsOriginAsn),
[Some(
"https://github.com/sapics/ip-location-db/releases/download/checksum/origin-asn.mmdb.sha256"
.to_string()
)]
);
}
#[test]
fn the_maxmind_tier_selects_the_edition_on_one_endpoint() {
let dir = PathBuf::from("/var/lib/geoip");
let planned = |kind, choice| -> Transfer {
plan(kind, &credentialled_for(&dir, choice))
.unwrap()
.remove(0)
};
let free_city = planned(Kind::City, free(GeoIpProvider::MaxMind));
let paid_city = planned(Kind::City, paid(GeoIpProvider::MaxMind));
let paid_asn = planned(Kind::Asn, paid(GeoIpProvider::MaxMind));
assert_eq!(
free_city.url,
"https://download.maxmind.com/geoip/databases/GeoLite2-City/download?suffix=tar.gz"
);
assert_eq!(
paid_city.url,
"https://download.maxmind.com/geoip/databases/GeoIP2-City/download?suffix=tar.gz"
);
assert_eq!(paid_city.dest, dir.join("GeoIP2-City.mmdb"));
assert_eq!(
paid_city.archive,
Archive::TarGz {
member: "GeoIP2-City.mmdb"
}
);
assert_eq!(
paid_asn.url,
"https://download.maxmind.com/geoip/databases/GeoIP2-ISP/download?suffix=tar.gz"
);
assert_eq!(paid_asn.dest, dir.join("GeoIP2-ISP.mmdb"));
}
#[test]
fn a_paid_maxmind_selection_still_needs_the_credential() {
let dir = PathBuf::from("/var/lib/geoip");
let config = config_for(&dir, paid(GeoIpProvider::MaxMind));
let err = validate(&config).unwrap_err();
assert!(
matches!(
err,
GeoIpDownloadError::MissingCredential {
provider: "MaxMind",
field: "auto_download.maxmind_account_id",
}
),
"{err:?}"
);
}
#[test]
fn an_unmodelled_paid_tier_is_refused_rather_than_guessed_at() {
let dir = PathBuf::from("/var/lib/geoip");
for provider in [
GeoIpProvider::DbIp,
GeoIpProvider::IpInfo,
GeoIpProvider::SapicsIpToAsn,
GeoIpProvider::SapicsOriginAsn,
] {
let config = credentialled_for(&dir, paid(provider));
let err = validate(&config).unwrap_err();
let message = err.to_string();
assert!(
matches!(
err,
GeoIpDownloadError::UnsupportedTier { tier: "paid", .. }
),
"{provider:?}: {err:?}"
);
assert!(message.contains("paid"), "{message}");
}
}
#[test]
fn a_config_that_can_be_acted_on_validates() {
let dir = PathBuf::from("/var/lib/geoip");
validate(&config_in(&dir, GeoIpProvider::SapicsIpToAsn)).unwrap();
validate(&credentialled_for(&dir, paid(GeoIpProvider::MaxMind))).unwrap();
validate(&config_in(&dir, GeoIpProvider::DbIp)).unwrap();
let mut off = config_for(&dir, paid(GeoIpProvider::IpInfo));
off.auto_download.enabled = false;
validate(&off).unwrap();
let mut explicit = config_for(&dir, paid(GeoIpProvider::IpInfo));
explicit.city_db_path = Some("/data/city.mmdb".into());
explicit.asn_db_path = Some("/data/asn.mmdb".into());
validate(&explicit).unwrap();
}
#[test]
fn a_setting_that_can_only_fail_is_refused_by_name() {
let dir = PathBuf::from("/var/lib/geoip");
for (edit, key) in [
(
(|a: &mut AutoDownloadConfig| a.read_timeout_secs = 0) as fn(&mut AutoDownloadConfig),
Some("auto_download.read_timeout_secs"),
),
(
|a| a.connect_timeout_secs = 0,
Some("auto_download.connect_timeout_secs"),
),
(
|a| a.min_size_percent = 101,
Some("auto_download.min_size_percent"),
),
(
|a| a.min_size_percent = 255,
Some("auto_download.min_size_percent"),
),
(|a| a.min_size_percent = 100, None),
(|a| a.min_size_percent = 0, None),
(|a| a.read_timeout_secs = 1, None),
] {
let mut config = config_in(&dir, GeoIpProvider::DbIp);
edit(&mut config.auto_download);
match (validate(&config), key) {
(Ok(()), None) => {}
(Err(e @ GeoIpDownloadError::InvalidSetting { .. }), Some(key)) => {
assert!(e.to_string().contains(key), "{e}");
assert!(e.is_permanent());
}
(other, key) => panic!("{key:?} gave {other:?}"),
}
}
let mut off = config_in(&dir, GeoIpProvider::DbIp);
off.auto_download.read_timeout_secs = 0;
off.auto_download.enabled = false;
validate(&off).unwrap();
let mut injected = config_in(&dir, GeoIpProvider::DbIp).with_http_client(default_client());
injected.auto_download.read_timeout_secs = 0;
validate(&injected).unwrap();
injected.auto_download.min_size_percent = 101;
assert!(validate(&injected).is_err());
}
#[tokio::test]
async fn a_download_with_a_setting_that_can_only_fail_makes_no_request() {
let dir = tempfile::tempdir().unwrap();
let mut config = config_in(dir.path(), GeoIpProvider::DbIp);
config.auto_download.read_timeout_secs = 0;
let err = download(Kind::City, &config).await.unwrap_err();
assert!(
matches!(err, GeoIpDownloadError::InvalidSetting { .. }),
"{err:?}"
);
}
fn rate_limited(retry_after_secs: Option<u64>) -> GeoIpDownloadError {
GeoIpDownloadError::RateLimited {
url: "https://example.invalid/db".into(),
retry_after_secs,
}
}
#[test]
fn a_refusal_or_a_rate_limit_starts_a_cool_down() {
use cooldown::{CEILING_SECS, FLOOR_SECS};
let undersized = || GeoIpDownloadError::Undersized {
path: "db".into(),
actual: 1,
existing: 100,
floor_percent: 50,
};
let mismatch = || GeoIpDownloadError::ChecksumMismatch {
url: "u".into(),
expected: "a".into(),
actual: "b".into(),
};
for (error, expected) in [
(rate_limited(Some(600)), Some(600)),
(rate_limited(None), Some(FLOOR_SECS)),
(rate_limited(Some(10 * CEILING_SECS)), Some(CEILING_SECS)),
(undersized(), Some(FLOOR_SECS)),
(mismatch(), Some(FLOOR_SECS)),
(
GeoIpDownloadError::TooLarge {
url: "u".into(),
limit: 64,
},
Some(FLOOR_SECS),
),
(
GeoIpDownloadError::CredentialRejected {
url: "u".into(),
status: 401,
fields: "auto_download.maxmind_license_key",
},
Some(CEILING_SECS),
),
(
GeoIpDownloadError::NotEntitled { url: "u".into() },
Some(CEILING_SECS),
),
(
GeoIpDownloadError::UnexpectedStatus {
url: "u".into(),
status: 404,
},
Some(CEILING_SECS),
),
(GeoIpDownloadError::Busy { path: "db".into() }, None),
(GeoIpDownloadError::Io(io::Error::other("disk")), None),
(
GeoIpDownloadError::UnexpectedStatus {
url: "u".into(),
status: 500,
},
None,
),
] {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
cooldown::begin(&dest, &error, 1);
let waiting = cooldown::wait_for(&dest, "city", 1).map(|wait| wait.as_secs());
match (waiting, expected) {
(None, None) => {}
(Some(waiting), Some(expected)) => {
assert!(
(expected.saturating_sub(5)..=expected).contains(&waiting),
"{error:?} waits {waiting}s, wanted {expected}s"
);
}
(waiting, expected) => panic!("{error:?}: {waiting:?} against {expected:?}"),
}
if error.is_permanent() {
assert!(cooldown::wait_for(&dest, "city", 2).is_none(), "{error:?}");
}
}
}
#[test]
fn a_cool_down_ends_when_it_passes_or_a_download_does() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let stamp = cooldown::stamp_path(&dest);
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
fs::write(&stamp, (now - 10).to_string()).unwrap();
assert!(cooldown::wait_for(&dest, "city", 0).is_none());
fs::write(&stamp, "soon").unwrap();
assert!(cooldown::wait_for(&dest, "city", 0).is_none());
fs::write(&stamp, (now + 600).to_string()).unwrap();
assert!(cooldown::wait_for(&dest, "city", 0).is_some());
cooldown::clear(&dest);
assert!(cooldown::wait_for(&dest, "city", 0).is_none());
assert!(!stamp.exists());
}
#[tokio::test]
async fn an_oversized_body_is_not_asked_for_again_inside_its_cool_down() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/db.mmdb", listener.local_addr().unwrap());
let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
tokio::spawn({
let requests = requests.clone();
async move {
while let Ok((mut socket, _)) = listener.accept().await {
requests.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let mut buffer = [0u8; 2048];
let _ = socket.read(&mut buffer).await;
let _ = socket
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1048576\r\n\r\n")
.await;
}
}
});
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let client = default_client();
let (url, dest, client) = (&url, &dest, &client);
let fetch = move || async move {
let file = raw_transfer(url.clone(), dest.clone())
.run_within(client, 64)
.await?;
Ok(vec![file])
};
let first = attempt("city", Some(dest), 0, fetch).await;
assert!(
matches!(
first,
Attempt::Ran(Err(GeoIpDownloadError::TooLarge { .. }))
),
"{first:?}"
);
assert_eq!(requests.load(std::sync::atomic::Ordering::SeqCst), 1);
let second = attempt("city", Some(dest), 0, fetch).await;
assert!(matches!(second, Attempt::Waiting(_)), "{second:?}");
assert_eq!(
requests.load(std::sync::atomic::Ordering::SeqCst),
1,
"the cool-down let a second request through"
);
}
#[tokio::test]
async fn a_database_that_is_cooling_down_costs_no_request() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let city = dir.path().join("dbip-city-lite.mmdb");
let asn = dir.path().join("dbip-asn-lite.mmdb");
fs::write(&city, b"stale city").unwrap();
fs::write(&asn, b"stale asn").unwrap();
let proxied = reqwest::Client::builder()
.proxy(reqwest::Proxy::all(server.uri()).unwrap())
.build()
.unwrap();
let mut config = config_in(dir.path(), GeoIpProvider::DbIp).with_http_client(proxied);
config.auto_download.max_age_days = 0;
for file in [&city, &asn] {
fs::write(cooldown::stamp_path(file), (now + 600).to_string()).unwrap();
}
let databases = ensure_databases(&config).await.unwrap();
assert_eq!(databases.city.unwrap().files, std::slice::from_ref(&city));
assert_eq!(databases.asn.unwrap().files, std::slice::from_ref(&asn));
assert_eq!(requested_targets(&server).await.len(), 0);
for file in [&city, &asn] {
fs::write(cooldown::stamp_path(file), (now - 1).to_string()).unwrap();
}
ensure_databases(&config).await.unwrap();
assert_eq!(requested_targets(&server).await.len(), 2);
}
#[tokio::test]
async fn an_unmodelled_tier_resolves_to_nothing_rather_than_a_wrong_url() {
let dir = tempfile::tempdir().unwrap();
let config = credentialled_for(dir.path(), paid(GeoIpProvider::DbIp));
let databases = ensure_databases(&config).await.unwrap();
assert_eq!(databases, Databases::default());
assert!(entries(dir.path()).is_empty(), "{:?}", entries(dir.path()));
}
#[test]
fn the_dbip_url_is_this_month_with_last_month_behind_it() {
fn month_in(url: &str) -> String {
url.rsplit_once("-lite-")
.unwrap()
.1
.trim_end_matches(".mmdb.gz")
.to_string()
}
fn months_between(earlier: &str, later: &str) -> i32 {
let ordinal = |month: &str| {
let (year, month) = month.split_once('-').unwrap();
year.parse::<i32>().unwrap() * 12 + month.parse::<i32>().unwrap()
};
ordinal(later) - ordinal(earlier)
}
let config = config_in(Path::new("/var/lib/geoip"), GeoIpProvider::DbIp);
for (kind, slug) in [(Kind::City, "city"), (Kind::Asn, "asn")] {
let transfers = plan(kind, &config).unwrap();
assert_eq!(transfers.len(), 1);
let fallback = transfers[0].fallback_url.clone().unwrap();
let current = month_in(&transfers[0].url);
let previous = month_in(&fallback);
assert_eq!(
transfers[0].url,
format!("https://download.db-ip.com/free/dbip-{slug}-lite-{current}.mmdb.gz")
);
assert_eq!(
fallback,
format!("https://download.db-ip.com/free/dbip-{slug}-lite-{previous}.mmdb.gz")
);
assert_eq!(current, chrono::Utc::now().format("%Y-%m").to_string());
assert_eq!(
months_between(&previous, ¤t),
1,
"{previous} {current}"
);
}
}
#[test]
fn is_fresh_rejects_a_missing_file() {
let missing = Path::new("/nonexistent/path/file.mmdb");
assert!(!is_fresh(missing, 0));
assert!(!is_fresh(missing, 86_400));
assert!(!is_fresh(missing, u64::MAX));
}
#[test]
fn is_fresh_accepts_a_new_file() {
let file = tempfile::NamedTempFile::new().unwrap();
assert!(is_fresh(file.path(), 86_400));
assert!(is_fresh(file.path(), u64::MAX / 2));
}
#[test]
fn is_fresh_rejects_everything_at_zero_max_age() {
let file = tempfile::NamedTempFile::new().unwrap();
assert!(!is_fresh(file.path(), 0));
}
#[test]
fn is_fresh_handles_a_directory_without_panicking() {
let dir = tempfile::tempdir().unwrap();
assert!(is_fresh(dir.path(), 86_400));
}
#[tokio::test]
async fn custom_provider_returns_the_explicit_paths() {
let config = GeoIpConfig {
provider: GeoIpProvider::Custom.into(),
city_db_path: Some("/data/city.mmdb".into()),
asn_db_path: Some("/data/asn.mmdb".into()),
..Default::default()
};
let databases = ensure_databases(&config).await.unwrap();
assert_eq!(
databases.city.unwrap().files,
[PathBuf::from("/data/city.mmdb")]
);
assert_eq!(
databases.asn.unwrap().files,
[PathBuf::from("/data/asn.mmdb")]
);
}
#[tokio::test]
async fn explicit_paths_override_the_provider() {
let config = GeoIpConfig {
provider: GeoIpProvider::DbIp.into(),
city_db_path: Some("/custom/city.mmdb".into()),
asn_db_path: Some("/custom/asn.mmdb".into()),
..Default::default()
};
let databases = ensure_databases(&config).await.unwrap();
assert_eq!(
databases.city.unwrap().files,
[PathBuf::from("/custom/city.mmdb")]
);
assert_eq!(
databases.asn.unwrap().files,
[PathBuf::from("/custom/asn.mmdb")]
);
}
#[tokio::test]
async fn custom_provider_without_paths_returns_nothing() {
let config = GeoIpConfig {
provider: GeoIpProvider::Custom.into(),
..Default::default()
};
let databases = ensure_databases(&config).await.unwrap();
assert_eq!(databases, Databases::default());
}
#[tokio::test]
async fn an_explicit_path_bypasses_its_own_kind_only() {
let dir = tempfile::tempdir().unwrap();
let city = dir.path().join("dbip-city-lite.mmdb");
fs::write(&city, b"fake city").unwrap();
let mut config = config_in(dir.path(), GeoIpProvider::DbIp);
config.asn_db_path = Some("/opt/asn.mmdb".into());
config.auto_download.enabled = false;
let databases = ensure_databases(&config).await.unwrap();
assert_eq!(databases.city.unwrap().files, [city]);
assert_eq!(
databases.asn.unwrap().files,
[PathBuf::from("/opt/asn.mmdb")]
);
}
#[tokio::test]
async fn disabled_config_resolves_nothing() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("dbip-city-lite.mmdb"), b"fake mmdb").unwrap();
let mut config = config_in(dir.path(), GeoIpProvider::DbIp);
config.enabled = false;
let databases = ensure_databases(&config).await.unwrap();
assert_eq!(databases, Databases::default());
}
#[tokio::test]
async fn auto_download_off_returns_only_existing_files() {
let dir = tempfile::tempdir().unwrap();
let city = dir.path().join("dbip-city-lite.mmdb");
fs::write(&city, b"fake mmdb").unwrap();
let mut config = config_in(dir.path(), GeoIpProvider::DbIp);
config.auto_download.enabled = false;
let databases = ensure_databases(&config).await.unwrap();
assert_eq!(databases.city.unwrap().files, [city]);
assert!(databases.asn.is_none());
}
#[tokio::test]
async fn auto_download_off_over_an_empty_directory_returns_nothing() {
let mut config = config_in(Path::new("/nonexistent"), GeoIpProvider::DbIp);
config.auto_download.enabled = false;
let databases = ensure_databases(&config).await.unwrap();
assert_eq!(databases, Databases::default());
}
#[tokio::test]
async fn each_kind_resolves_through_its_own_provider() {
let dir = tempfile::tempdir().unwrap();
let city = dir.path().join("ipinfo-lite.mmdb");
let asn = dir.path().join("origin-asn.mmdb");
for file in [&city, &asn] {
fs::write(file, b"fake").unwrap();
}
let mut config = config_in(dir.path(), GeoIpProvider::DbIp);
config.provider = ProviderSelection {
city: free(GeoIpProvider::IpInfo),
asn: free(GeoIpProvider::SapicsOriginAsn),
};
config.auto_download.enabled = false;
let databases = ensure_databases(&config).await.unwrap();
let city_database = databases.city.unwrap();
let asn_database = databases.asn.unwrap();
assert_eq!(city_database.files, [city]);
assert_eq!(city_database.format, DatabaseFormat::Mmdb);
assert_eq!(asn_database.files, [asn]);
assert_eq!(asn_database.format, DatabaseFormat::Mmdb);
}
#[test]
fn a_database_is_its_whole_file_set() {
let spec = DatabaseSpec {
format: DatabaseFormat::Csv,
names: &["ranges-ipv4.csv", "ranges-ipv6.csv"],
};
let database = spec.at(Path::new("/var/lib/geoip"));
assert_eq!(database.format, DatabaseFormat::Csv);
assert_eq!(
database.files,
[
PathBuf::from("/var/lib/geoip/ranges-ipv4.csv"),
PathBuf::from("/var/lib/geoip/ranges-ipv6.csv"),
]
);
}
#[tokio::test]
async fn maxmind_without_credentials_degrades_to_no_paths() {
let dir = tempfile::tempdir().unwrap();
let config = config_in(dir.path(), GeoIpProvider::MaxMind);
let databases = ensure_databases(&config).await.unwrap();
assert_eq!(databases, Databases::default());
}
#[tokio::test]
async fn ipinfo_without_a_token_degrades_to_no_paths() {
let dir = tempfile::tempdir().unwrap();
let config = config_in(dir.path(), GeoIpProvider::IpInfo);
let databases = ensure_databases(&config).await.unwrap();
assert_eq!(databases, Databases::default());
}
#[tokio::test]
async fn a_stale_file_survives_a_failed_download() {
let dir = tempfile::tempdir().unwrap();
let city = dir.path().join("GeoLite2-City.mmdb");
fs::write(&city, b"stale city").unwrap();
let mut config = config_in(dir.path(), GeoIpProvider::MaxMind);
config.auto_download.max_age_days = 0;
let databases = ensure_databases(&config).await.unwrap();
assert_eq!(databases.city.unwrap().files, [city]);
assert!(databases.asn.is_none(), "no stale ASN file exists");
}
#[test]
fn missing_credentials_name_the_config_field() {
let dir = PathBuf::from("/var/lib/geoip");
let err = plan(Kind::City, &config_in(&dir, GeoIpProvider::MaxMind)).unwrap_err();
assert!(matches!(
err,
GeoIpDownloadError::MissingCredential {
provider: "MaxMind",
field: "auto_download.maxmind_account_id",
}
));
let mut config = config_in(&dir, GeoIpProvider::MaxMind);
config.auto_download.maxmind_account_id = Some("account".into());
let err = plan(Kind::Asn, &config).unwrap_err();
assert!(matches!(
err,
GeoIpDownloadError::MissingCredential {
field: "auto_download.maxmind_license_key",
..
}
));
let err = plan(Kind::City, &config_in(&dir, GeoIpProvider::IpInfo)).unwrap_err();
assert!(matches!(
err,
GeoIpDownloadError::MissingCredential {
provider: "IpInfo",
field: "auto_download.ipinfo_token",
}
));
}
#[tokio::test]
async fn the_sapics_providers_publish_no_city_database() {
for provider in [GeoIpProvider::SapicsIpToAsn, GeoIpProvider::SapicsOriginAsn] {
let dir = tempfile::tempdir().unwrap();
let mut config = config_in(dir.path(), provider);
config.auto_download.enabled = false;
let databases = ensure_databases(&config).await.unwrap();
assert!(databases.city.is_none(), "{provider:?}");
}
}
#[tokio::test]
async fn ipinfo_lite_publishes_no_asn_database() {
let dir = tempfile::tempdir().unwrap();
let mut config = config_in(dir.path(), GeoIpProvider::IpInfo);
config.auto_download.enabled = false;
let databases = ensure_databases(&config).await.unwrap();
assert!(databases.asn.is_none());
}
#[tokio::test]
async fn maxmind_basic_auth_reaches_the_request() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = credentialled(dir.path(), GeoIpProvider::MaxMind);
let archive = tar_gz("GeoLite2-City.mmdb", &mmdb_body(b"city database"));
let digest = sha256_hex(&archive);
Mock::given(method("GET"))
.and(query_param("suffix", "tar.gz.sha256"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(format!("{digest} GeoLite2-City_20260901.tar.gz\n")),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(query_param("suffix", "tar.gz"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive))
.mount(&server)
.await;
let transfer = planned(Kind::City, &config, &server).remove(0);
let dest = transfer.run(&default_client()).await.unwrap();
assert_eq!(dest, dir.path().join("GeoLite2-City.mmdb"));
assert_eq!(fs::read(&dest).unwrap(), mmdb_body(b"city database"));
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2);
for request in &requests {
assert_eq!(
request.headers.get("authorization").unwrap(),
"Basic MTIzNDU2OnNlY3JldC1rZXk="
);
let url = request.url.as_str();
assert!(!url.contains("123456"), "{url}");
assert!(!url.contains("secret-key"), "{url}");
}
}
#[cfg(feature = "geoip-lookup")]
#[test]
fn a_database_carries_when_it_was_built_not_when_it_was_fetched() {
let path = std::path::Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/data/city-test.mmdb"
));
let built = crate::geoip::enricher::open_reader(path)
.unwrap()
.metadata()
.build_time()
.unwrap();
let written = fs::metadata(path).unwrap().modified().unwrap();
let gap = written.duration_since(built).unwrap();
assert!(
gap > Duration::from_secs(30 * SECS_PER_DAY),
"built and fetched are {gap:?} apart"
);
}
#[test]
fn a_refused_download_is_counted_apart_from_one_that_never_arrived() {
let refused = [
GeoIpDownloadError::NotADatabase {
url: "https://example.invalid/db".into(),
},
GeoIpDownloadError::Undersized {
path: "/tmp/db.mmdb".into(),
actual: 1,
existing: 1_000_000,
floor_percent: 50,
},
GeoIpDownloadError::Truncated {
url: "https://example.invalid/db".into(),
expected: 100,
actual: 10,
},
];
for error in &refused {
assert_eq!(error.outcome(), "refused", "{error:?}");
}
assert_eq!(
GeoIpDownloadError::Busy {
path: "/tmp/db.mmdb".into()
}
.outcome(),
"busy"
);
assert_eq!(
GeoIpDownloadError::UnexpectedStatus {
url: "https://example.invalid/db".into(),
status: 500,
}
.outcome(),
"failed"
);
}
#[test]
fn a_408_is_the_one_4xx_worth_coming_back_from() {
let at = |status| GeoIpDownloadError::UnexpectedStatus {
url: "https://example.invalid/db".into(),
status,
};
assert!(!at(408).is_permanent(), "408 is worth another attempt");
for status in [400, 401, 403, 404, 407, 409, 410, 451] {
assert!(at(status).is_permanent(), "{status}");
}
for status in [500, 502, 503, 504] {
assert!(!at(status).is_permanent(), "{status}");
}
}
#[test]
fn a_body_that_expanded_past_the_ceiling_is_worth_fetching_again() {
let err = GeoIpDownloadError::TooLarge {
url: "https://example.invalid/db".into(),
limit: 4 * 1024 * 1024 * 1024,
};
assert!(!err.is_permanent(), "{err:?}");
assert!(err.to_string().contains("4294967296"), "{err}");
}
#[tokio::test]
async fn a_second_writer_is_turned_away_rather_than_interleaved() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = config_in(dir.path(), GeoIpProvider::SapicsOriginAsn);
let lock_path = dir.path().join("origin-asn.mmdb.lock");
let held = fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
held.lock().unwrap();
let transfer = planned(Kind::Asn, &config, &server).remove(0);
let err = transfer.run(&default_client()).await.unwrap_err();
assert!(matches!(err, GeoIpDownloadError::Busy { .. }), "{err:?}");
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn a_transfer_in_flight_turns_a_second_transfer_of_the_same_database_away() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let body = mmdb_body(b"the database the first writer is fetching");
let (url, arrived, release) = held_server(body.clone());
let first = tokio::spawn({
let dest = dest.clone();
async move { raw_transfer(url, dest).run(&default_client()).await }
});
arrived.await.unwrap();
let err = raw_transfer("http://127.0.0.1:1/db.mmdb".to_string(), dest.clone())
.run(&default_client())
.await
.unwrap_err();
assert!(matches!(err, GeoIpDownloadError::Busy { .. }), "{err:?}");
assert_eq!(err.outcome(), "busy");
release.send(()).unwrap();
assert_eq!(first.await.unwrap().unwrap(), dest);
assert_eq!(fs::read(&dest).unwrap(), body);
}
#[tokio::test]
async fn a_cancelled_transfer_holds_its_lock_until_its_blocking_steps_end() {
for (step, held_file, left) in [
("write", "db.mmdb.part", "db.mmdb.part"),
("materialise", "db.mmdb", "db.mmdb"),
] {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let body = mmdb_body(b"the database the cancelled transfer is placing");
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
.mount(&server)
.await;
let (entered, release) = testkit::hold(&dir.path().join(held_file));
let transfer = tokio::spawn({
let (url, dest) = (format!("{}/db.mmdb", server.uri()), dest.clone());
async move { raw_transfer(url, dest).run(&default_client()).await }
});
tokio::task::spawn_blocking(move || entered.recv())
.await
.unwrap()
.unwrap();
transfer.abort();
assert!(transfer.await.unwrap_err().is_cancelled(), "{step}");
let lock = fs::OpenOptions::new()
.read(true)
.write(true)
.open(dir.path().join("db.mmdb.lock"))
.unwrap();
let held = matches!(lock.try_lock(), Err(fs::TryLockError::WouldBlock));
release.send(()).unwrap();
assert!(
held,
"the lock was released while the {step} step still ran"
);
let deadline = Instant::now() + Duration::from_secs(10);
while lock.try_lock().is_err() {
assert!(Instant::now() < deadline, "the {step} step never finished");
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(fs::read(dir.path().join(left)).unwrap(), body, "{step}");
assert_eq!(entries(dir.path()), [left], "{step}");
}
}
#[tokio::test]
async fn a_body_is_written_off_the_runtime_thread() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let body = mmdb_body(&vec![b'x'; 3 << 20]);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
.mount(&server)
.await;
raw_transfer(format!("{}/db.mmdb", server.uri()), dest.clone())
.run(&default_client())
.await
.unwrap();
assert_eq!(fs::read(&dest).unwrap(), body);
let threads = testkit::threads_for(&dir.path().join("db.mmdb.part"));
let here = std::thread::current().id();
assert!(
!threads.is_empty() && threads.iter().all(|&thread| thread != here),
"the part file was written on {threads:?}, the runtime is on {here:?}"
);
}
#[tokio::test]
async fn a_destination_that_cannot_be_renamed_over_leaves_no_staged_file() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
fs::create_dir(&dest).unwrap();
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(mmdb_body(b"a real database")))
.mount(&server)
.await;
let err = raw_transfer(format!("{}/db.mmdb", server.uri()), dest.clone())
.run(&default_client())
.await
.unwrap_err();
assert!(matches!(err, GeoIpDownloadError::Io(_)), "{err:?}");
assert!(dest.is_dir(), "the destination was left as it was found");
assert_eq!(entries(dir.path()), ["db.mmdb"]);
}
#[tokio::test]
async fn a_data_directory_that_cannot_be_created_costs_no_request() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let blocker = dir.path().join("geoip");
fs::write(&blocker, b"not a directory").unwrap();
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(mmdb_body(b"a real database")))
.mount(&server)
.await;
let err = raw_transfer(format!("{}/db.mmdb", server.uri()), blocker.join("db.mmdb"))
.run(&default_client())
.await
.unwrap_err();
assert!(matches!(err, GeoIpDownloadError::Io(_)), "{err:?}");
assert!(server.received_requests().await.unwrap().is_empty());
assert_eq!(entries(dir.path()), ["geoip"]);
}
#[tokio::test]
async fn the_ipinfo_token_rides_as_a_query_parameter() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = credentialled(dir.path(), GeoIpProvider::IpInfo);
let planned_url = plan(Kind::City, &config).unwrap().remove(0).url;
assert!(!planned_url.contains("token-wxyz"), "{planned_url}");
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(mmdb_body(b"ipinfo lite")))
.mount(&server)
.await;
let transfer = planned(Kind::City, &config, &server).remove(0);
let dest = transfer.run(&default_client()).await.unwrap();
assert_eq!(fs::read(&dest).unwrap(), mmdb_body(b"ipinfo lite"));
let requests = server.received_requests().await.unwrap();
let url = &requests[0].url;
assert_eq!(url.path(), "/data/ipinfo_lite.mmdb");
assert_eq!(url.query(), Some("token=token-wxyz"));
}
#[tokio::test]
async fn a_rejected_credential_is_not_sent_again_until_it_changes() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let client = default_client();
let plan_at = |config: &GeoIpConfig| {
let transfer = planned(Kind::City, config, &server).remove(0);
(fingerprint(std::slice::from_ref(&transfer)), transfer)
};
let mut config = credentialled(dir.path(), GeoIpProvider::MaxMind);
let (asked, first) = plan_at(&config);
let dest = first.dest.clone();
let attempted = attempt("city", Some(&dest), asked, || first.run(&client)).await;
assert!(
matches!(
attempted,
Attempt::Ran(Err(GeoIpDownloadError::CredentialRejected { .. }))
),
"{attempted:?}"
);
let (asked, again) = plan_at(&config);
let attempted = attempt("city", Some(&dest), asked, || again.run(&client)).await;
assert!(matches!(attempted, Attempt::Waiting(_)), "{attempted:?}");
assert_eq!(requested_paths(&server).await.len(), 1);
config.auto_download.maxmind_license_key = Some("licence-rotated".into());
let (asked, rotated) = plan_at(&config);
let attempted = attempt("city", Some(&dest), asked, || rotated.run(&client)).await;
assert!(matches!(attempted, Attempt::Ran(Err(_))), "{attempted:?}");
assert_eq!(requested_paths(&server).await.len(), 2);
}
#[tokio::test]
async fn a_403_is_reported_rather_than_written_out() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = credentialled(dir.path(), GeoIpProvider::IpInfo);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(403).set_body_string("<html>forbidden</html>"))
.mount(&server)
.await;
let transfer = planned(Kind::City, &config, &server).remove(0);
let endpoint = transfer.url.clone();
let dest = transfer.dest.clone();
let err = transfer.run(&default_client()).await.unwrap_err();
assert!(
matches!(&err, GeoIpDownloadError::NotEntitled { url } if *url == endpoint),
"{err:?}"
);
assert!(!dest.exists());
assert!(entries(dir.path()).is_empty(), "{:?}", entries(dir.path()));
}
#[tokio::test]
async fn an_anonymous_403_is_reported_as_a_status() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(403).set_body_string("<html>forbidden</html>"))
.mount(&server)
.await;
let url = format!("{}/db.mmdb", server.uri());
let err = raw_transfer(url.clone(), dest.clone())
.run(&default_client())
.await
.unwrap_err();
assert!(
matches!(&err, GeoIpDownloadError::UnexpectedStatus { url: at, status: 403 } if *at == url),
"{err:?}"
);
assert!(err.is_permanent(), "{err:?}");
assert!(!dest.exists());
}
#[tokio::test]
async fn a_failed_transfer_leaves_no_part_file() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = config_in(dir.path(), GeoIpProvider::DbIp);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"not a gzip stream".to_vec()))
.mount(&server)
.await;
let transfer = planned(Kind::City, &config, &server).remove(0);
let dest = transfer.dest.clone();
let err = transfer.run(&default_client()).await.unwrap_err();
assert!(matches!(err, GeoIpDownloadError::Io(_)), "{err:?}");
assert!(!dest.exists());
assert!(entries(dir.path()).is_empty(), "{:?}", entries(dir.path()));
}
#[tokio::test]
async fn an_unpublished_current_month_falls_back_to_the_previous_one() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = config_in(dir.path(), GeoIpProvider::DbIp);
let transfer = planned(Kind::City, &config, &server).remove(0);
let current = path_of(&transfer.url);
let previous = path_of(transfer.fallback_url.as_deref().unwrap());
Mock::given(method("GET"))
.and(path(current.clone()))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(previous.clone()))
.respond_with(
ResponseTemplate::new(200).set_body_bytes(gzip(&mmdb_body(b"previous month city"))),
)
.mount(&server)
.await;
let dest = transfer.run(&default_client()).await.unwrap();
assert_eq!(fs::read(&dest).unwrap(), mmdb_body(b"previous month city"));
assert_eq!(requested_paths(&server).await, [current, previous]);
}
#[tokio::test]
async fn a_published_current_month_never_asks_for_the_previous_one() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = config_in(dir.path(), GeoIpProvider::DbIp);
let transfer = planned(Kind::City, &config, &server).remove(0);
let current = path_of(&transfer.url);
Mock::given(method("GET"))
.and(path(current.clone()))
.respond_with(
ResponseTemplate::new(200).set_body_bytes(gzip(&mmdb_body(b"this month city"))),
)
.mount(&server)
.await;
let dest = transfer.run(&default_client()).await.unwrap();
assert_eq!(fs::read(&dest).unwrap(), mmdb_body(b"this month city"));
assert_eq!(requested_paths(&server).await, [current]);
}
#[tokio::test]
async fn a_403_does_not_fall_back_to_the_previous_month() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = config_in(dir.path(), GeoIpProvider::DbIp);
let transfer = planned(Kind::City, &config, &server).remove(0);
let current = path_of(&transfer.url);
let previous = path_of(transfer.fallback_url.as_deref().unwrap());
Mock::given(method("GET"))
.and(path(current.clone()))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(previous))
.respond_with(
ResponseTemplate::new(200).set_body_bytes(gzip(&mmdb_body(b"previous month city"))),
)
.mount(&server)
.await;
let err = transfer.run(&default_client()).await.unwrap_err();
assert!(
matches!(
err,
GeoIpDownloadError::UnexpectedStatus { status: 403, .. }
),
"{err:?}"
);
assert_eq!(requested_paths(&server).await, [current]);
}
#[tokio::test]
async fn a_published_digest_is_checked_against_the_download() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = config_in(dir.path(), GeoIpProvider::SapicsOriginAsn);
let body = mmdb_body(b"origin asn database");
let digest = sha256_hex(&body);
Mock::given(method("GET"))
.and(path(
"/sapics/ip-location-db/releases/download/latest/origin-asn.mmdb",
))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(
"/sapics/ip-location-db/releases/download/checksum/origin-asn.mmdb.sha256",
))
.respond_with(
ResponseTemplate::new(200).set_body_string(format!("{digest} origin-asn.mmdb\n")),
)
.mount(&server)
.await;
let transfer = planned(Kind::Asn, &config, &server).remove(0);
let written = transfer.run(&default_client()).await.unwrap();
assert_eq!(written, dir.path().join("origin-asn.mmdb"));
assert_eq!(fs::read(&written).unwrap(), body);
}
#[tokio::test]
async fn a_download_that_misses_its_digest_is_rejected() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = config_in(dir.path(), GeoIpProvider::SapicsOriginAsn);
let expected = sha256_hex(&mmdb_body(b"what the provider published"));
Mock::given(method("GET"))
.and(path(
"/sapics/ip-location-db/releases/download/latest/origin-asn.mmdb",
))
.respond_with(ResponseTemplate::new(200).set_body_bytes(mmdb_body(b"something else")))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(
"/sapics/ip-location-db/releases/download/checksum/origin-asn.mmdb.sha256",
))
.respond_with(
ResponseTemplate::new(200).set_body_string(format!("{expected} origin-asn.mmdb\n")),
)
.mount(&server)
.await;
let transfer = planned(Kind::Asn, &config, &server).remove(0);
let dest = transfer.dest.clone();
let err = transfer.run(&default_client()).await.unwrap_err();
assert!(
matches!(err, GeoIpDownloadError::ChecksumMismatch { .. }),
"{err:?}"
);
assert!(!dest.exists());
assert!(entries(dir.path()).is_empty(), "{:?}", entries(dir.path()));
}
#[tokio::test]
async fn an_error_never_carries_a_credential_written_into_a_url() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let with_secret = |name: &str| {
server
.uri()
.replacen("http://", "http://user:sekrit-pw@", 1)
+ &format!("/{name}?token=sekrit-token")
};
let body = mmdb_body(b"a database");
Mock::given(method("GET"))
.and(path("/db.mmdb"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/db.mmdb.sha256"))
.respond_with(ResponseTemplate::new(200).set_body_string(sha256_hex(b"other")))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/gone.mmdb"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let mismatch = resumable_transfer(
with_secret("db.mmdb"),
dir.path().join("db.mmdb"),
with_secret("db.mmdb.sha256"),
);
let refused = raw_transfer(with_secret("gone.mmdb"), dir.path().join("gone.mmdb"));
for transfer in [mismatch, refused] {
let err = transfer.run(&default_client()).await.unwrap_err();
let shown = format!("{err} {err:?}");
assert!(!shown.contains("sekrit"), "{shown}");
}
}
#[tokio::test]
async fn a_rejected_download_leaves_the_previous_database_in_place() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = credentialled(dir.path(), GeoIpProvider::IpInfo);
let good = mmdb_body(b"yesterday's database");
let dest = dir.path().join("ipinfo-lite.mmdb");
for body in [
b"<!DOCTYPE html><title>Log in to your account</title>".to_vec(),
mmdb_body(b"a real database")[..12].to_vec(),
] {
fs::write(&dest, &good).unwrap();
server.reset().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
.mount(&server)
.await;
let transfer = planned(Kind::City, &config, &server).remove(0);
let err = transfer.run(&default_client()).await.unwrap_err();
assert!(
matches!(err, GeoIpDownloadError::NotADatabase { .. }),
"{err:?}"
);
assert_eq!(fs::read(&dest).unwrap(), good, "{body:?}");
assert_eq!(entries(dir.path()), ["ipinfo-lite.mmdb"], "{body:?}");
}
}
#[tokio::test]
async fn a_checksum_mismatch_leaves_the_previous_database_in_place() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = config_in(dir.path(), GeoIpProvider::SapicsOriginAsn);
let good = mmdb_body(b"yesterday's asn database");
let dest = dir.path().join("origin-asn.mmdb");
fs::write(&dest, &good).unwrap();
let expected = sha256_hex(&mmdb_body(b"what the provider published"));
Mock::given(method("GET"))
.and(path(
"/sapics/ip-location-db/releases/download/latest/origin-asn.mmdb",
))
.respond_with(ResponseTemplate::new(200).set_body_bytes(mmdb_body(b"something else")))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(
"/sapics/ip-location-db/releases/download/checksum/origin-asn.mmdb.sha256",
))
.respond_with(
ResponseTemplate::new(200).set_body_string(format!("{expected} origin-asn.mmdb\n")),
)
.mount(&server)
.await;
let transfer = planned(Kind::Asn, &config, &server).remove(0);
let err = transfer.run(&default_client()).await.unwrap_err();
assert!(
matches!(err, GeoIpDownloadError::ChecksumMismatch { .. }),
"{err:?}"
);
assert_eq!(fs::read(&dest).unwrap(), good);
assert_eq!(entries(dir.path()), ["origin-asn.mmdb"]);
}
#[tokio::test]
async fn a_digest_that_is_not_a_sha256_leaves_the_previous_database_in_place() {
for body in [
"<!DOCTYPE html><html><title>Not Found</title></html>".to_string(),
format!("sha256:{}", sha256_hex(&mmdb_body(b"what was published"))),
String::new(),
] {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let good = mmdb_body(b"yesterday's database");
fs::write(&dest, &good).unwrap();
Mock::given(method("GET"))
.and(path("/db.mmdb"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(mmdb_body(b"today's database")))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/db.mmdb.sha256"))
.respond_with(ResponseTemplate::new(200).set_body_string(body.clone()))
.mount(&server)
.await;
let err = resumable_transfer(
format!("{}/db.mmdb", server.uri()),
dest.clone(),
format!("{}/db.mmdb.sha256", server.uri()),
)
.run(&default_client())
.await
.unwrap_err();
assert!(
matches!(&err, GeoIpDownloadError::MalformedChecksum { url } if url.ends_with("/db.mmdb.sha256")),
"{body:?}: {err:?}"
);
assert_eq!(err.outcome(), "refused", "{body:?}");
assert_eq!(fs::read(&dest).unwrap(), good, "{body:?}");
}
}
fn default_guard(kind: Kind) -> Guard {
Guard::new(kind, &AutoDownloadConfig::default())
}
async fn served(server: &MockServer, dest: PathBuf, body: Vec<u8>) -> Transfer {
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body))
.mount(server)
.await;
raw_transfer(format!("{}/db.mmdb", server.uri()), dest)
}
#[cfg(feature = "geoip-lookup")]
#[tokio::test]
async fn a_database_that_answers_nothing_leaves_the_previous_one_in_place() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let good = verify::fixtures::asn_mmdb(13_335, "CLOUDFLARENET");
fs::write(&dest, &good).unwrap();
let transfer = served(
&server,
dest.clone(),
verify::fixtures::asn_mmdb(13_335, ""),
)
.await;
let err = transfer
.run_guarded(&default_client(), default_guard(Kind::Asn))
.await
.unwrap_err();
assert!(
matches!(
err,
GeoIpDownloadError::Unpopulated {
field: "organisation name",
..
}
),
"{err:?}"
);
assert_eq!(fs::read(&dest).unwrap(), good);
assert_eq!(entries(dir.path()), ["db.mmdb"]);
assert!(!err.is_permanent(), "{err:?}");
}
#[tokio::test]
async fn a_populated_database_replaces_the_previous_one() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
fs::write(&dest, verify::fixtures::asn_mmdb(13_335, "AN OLDER NAME")).unwrap();
let published = verify::fixtures::asn_mmdb(13_335, "CLOUDFLARENET");
let transfer = served(&server, dest.clone(), published.clone()).await;
let written = transfer
.run_guarded(&default_client(), default_guard(Kind::Asn))
.await
.unwrap();
assert_eq!(written, dest);
assert_eq!(fs::read(&dest).unwrap(), published);
}
#[cfg(feature = "geoip-lookup")]
#[tokio::test]
async fn a_city_database_that_resolves_no_country_leaves_the_previous_one() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let good = verify::fixtures::city_mmdb("US");
fs::write(&dest, &good).unwrap();
let transfer = served(&server, dest.clone(), verify::fixtures::city_mmdb("")).await;
let err = transfer
.run_guarded(&default_client(), default_guard(Kind::City))
.await
.unwrap_err();
assert!(
matches!(
err,
GeoIpDownloadError::Unpopulated {
field: "country",
..
}
),
"{err:?}"
);
assert_eq!(fs::read(&dest).unwrap(), good);
assert_eq!(entries(dir.path()), ["db.mmdb"]);
}
#[tokio::test]
async fn a_stub_replacement_leaves_the_previous_one_in_place() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let mut good = verify::fixtures::asn_mmdb(13_335, "CLOUDFLARENET");
good.resize(10_000, 0);
fs::write(&dest, &good).unwrap();
let stub = verify::fixtures::asn_mmdb(13_335, "CLOUDFLARENET");
let transfer = served(&server, dest.clone(), stub).await;
let err = transfer
.run_guarded(&default_client(), default_guard(Kind::Asn))
.await
.unwrap_err();
let message = err.to_string();
assert!(
matches!(
err,
GeoIpDownloadError::Undersized {
existing: 10_000,
floor_percent: 50,
..
}
),
"{err:?}"
);
assert!(message.contains("10000"), "{message}");
assert_eq!(fs::read(&dest).unwrap(), good);
assert_eq!(entries(dir.path()), ["db.mmdb"]);
}
#[tokio::test]
async fn a_first_download_has_no_copy_to_be_measured_against() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let published = verify::fixtures::asn_mmdb(15_169, "GOOGLE");
let transfer = served(&server, dest.clone(), published.clone()).await;
let written = transfer
.run_guarded(&default_client(), default_guard(Kind::Asn))
.await
.unwrap();
assert_eq!(written, dest);
assert_eq!(fs::read(&dest).unwrap(), published);
}
#[tokio::test]
async fn an_operator_can_turn_the_content_check_off() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
fs::write(&dest, verify::fixtures::asn_mmdb(13_335, "CLOUDFLARENET")).unwrap();
let blank = verify::fixtures::asn_mmdb(13_335, "");
let auto = AutoDownloadConfig {
verify_content: false,
..Default::default()
};
let transfer = served(&server, dest.clone(), blank.clone()).await;
let written = transfer
.run_guarded(&default_client(), Guard::new(Kind::Asn, &auto))
.await
.unwrap();
assert_eq!(written, dest);
assert_eq!(fs::read(&dest).unwrap(), blank);
}
#[tokio::test]
async fn an_operator_can_turn_the_size_floor_off() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let mut previous = verify::fixtures::asn_mmdb(13_335, "CLOUDFLARENET");
previous.resize(10_000, 0);
fs::write(&dest, &previous).unwrap();
let small = verify::fixtures::asn_mmdb(13_335, "CLOUDFLARENET");
let auto = AutoDownloadConfig {
min_size_percent: 0,
..Default::default()
};
let transfer = served(&server, dest.clone(), small.clone()).await;
let written = transfer
.run_guarded(&default_client(), Guard::new(Kind::Asn, &auto))
.await
.unwrap();
assert_eq!(written, dest);
assert_eq!(fs::read(&dest).unwrap(), small);
}
#[tokio::test]
async fn a_login_page_answered_with_200_is_not_written_out() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = credentialled(dir.path(), GeoIpProvider::IpInfo);
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("<html><title>Log in to your account</title></html>"),
)
.mount(&server)
.await;
let transfer = planned(Kind::City, &config, &server).remove(0);
let dest = transfer.dest.clone();
let err = transfer.run(&default_client()).await.unwrap_err();
assert!(
matches!(err, GeoIpDownloadError::NotADatabase { .. }),
"{err:?}"
);
assert!(!dest.exists());
assert!(entries(dir.path()).is_empty(), "{:?}", entries(dir.path()));
}
#[tokio::test]
async fn a_rejected_credential_names_the_config_fields() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = credentialled(dir.path(), GeoIpProvider::MaxMind);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let transfer = planned(Kind::City, &config, &server).remove(0);
let err = transfer.run(&default_client()).await.unwrap_err();
let message = err.to_string();
assert!(
matches!(err, GeoIpDownloadError::CredentialRejected { .. }),
"{err:?}"
);
assert!(err.is_permanent(), "{err:?}");
assert!(
message.contains("auto_download.maxmind_account_id"),
"{message}"
);
assert!(
message.contains("auto_download.maxmind_license_key"),
"{message}"
);
assert!(!message.contains("secret-key"), "{message}");
}
#[tokio::test]
async fn an_unentitled_edition_is_not_reported_as_a_bad_credential() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = credentialled(dir.path(), GeoIpProvider::MaxMind);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
let transfer = planned(Kind::City, &config, &server).remove(0);
let err = transfer.run(&default_client()).await.unwrap_err();
let message = err.to_string();
assert!(
matches!(err, GeoIpDownloadError::NotEntitled { .. }),
"{err:?}"
);
assert!(err.is_permanent(), "{err:?}");
assert_eq!(err.outcome(), "unentitled");
assert!(message.contains("entitlement"), "{message}");
assert!(!message.contains("maxmind_license_key"), "{message}");
assert!(!message.contains("secret-key"), "{message}");
}
#[tokio::test]
async fn a_rate_limit_carries_the_delay_the_provider_asked_for() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = credentialled(dir.path(), GeoIpProvider::IpInfo);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "120"))
.mount(&server)
.await;
let transfer = planned(Kind::City, &config, &server).remove(0);
let err = transfer.run(&default_client()).await.unwrap_err();
assert!(
matches!(
err,
GeoIpDownloadError::RateLimited {
retry_after_secs: Some(120),
..
}
),
"{err:?}"
);
assert!(!err.is_permanent(), "{err:?}");
assert_eq!(requested_paths(&server).await.len(), 1, "no retry in place");
}
#[tokio::test]
async fn a_transient_failure_is_not_reported_as_permanent() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let config = credentialled(dir.path(), GeoIpProvider::IpInfo);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let transfer = planned(Kind::City, &config, &server).remove(0);
let err = transfer.run(&default_client()).await.unwrap_err();
assert!(
matches!(
err,
GeoIpDownloadError::UnexpectedStatus { status: 503, .. }
),
"{err:?}"
);
assert!(!err.is_permanent(), "{err:?}");
}
#[tokio::test]
async fn a_body_shorter_than_its_content_length_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let part = dir.path().join("db.mmdb.part");
let good = mmdb_body(b"yesterday's database");
fs::write(&dest, &good).unwrap();
let body = mmdb_body(b"a database that stops early");
let mut chunks: Vec<Vec<u8>> = body.chunks(16).map(<[u8]>::to_vec).collect();
let missing = chunks.pop().unwrap();
let delivered = body.len() - missing.len();
let url = drip_server_promising(chunks, Some(body.len()), Duration::from_millis(10));
let err = raw_transfer(url, dest.clone())
.run(&default_client())
.await
.unwrap_err();
assert!(
matches!(
err,
GeoIpDownloadError::Http(_) | GeoIpDownloadError::Truncated { .. }
),
"{err:?}"
);
assert!(!err.is_permanent(), "{err:?}");
assert_eq!(fs::read(&dest).unwrap(), good);
assert_eq!(fs::read(&part).unwrap().len(), delivered);
}
#[tokio::test]
async fn a_body_past_the_size_ceiling_is_refused_and_removed() {
let fits = mmdb_body(b"a body exactly on the ceiling");
let over = mmdb_body(b"a body one byte past the ceiling!");
let limit = u64::try_from(fits.len()).unwrap();
assert!(over.len() > fits.len());
for (declared, body) in [(true, &fits), (false, &fits), (true, &over), (false, &over)] {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let chunks: Vec<Vec<u8>> = body.chunks(16).map(<[u8]>::to_vec).collect();
let url = drip_server_promising(chunks, declared.then_some(body.len()), Duration::ZERO);
let result = raw_transfer(url, dest.clone())
.run_within(&default_client(), limit)
.await;
if body.len() == fits.len() {
assert_eq!(
fs::read(result.unwrap()).unwrap(),
*body,
"declared={declared}"
);
} else {
let err = result.unwrap_err();
assert!(
matches!(err, GeoIpDownloadError::TooLarge { limit: l, .. } if l == limit),
"declared={declared}: {err:?}"
);
assert!(!err.is_permanent(), "{err:?}");
assert!(
entries(dir.path()).is_empty(),
"declared={declared}: {:?} left behind",
entries(dir.path())
);
}
}
}
#[tokio::test]
async fn a_slow_but_progressing_transfer_completes() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let body = mmdb_body(b"a body that arrives in pieces");
let chunks: Vec<Vec<u8>> = body.chunks(8).map(<[u8]>::to_vec).collect();
let url = drip_server(chunks, Duration::from_millis(200));
let client = fetch::client(None, Duration::from_secs(5), Duration::from_secs(1)).unwrap();
let started = Instant::now();
let written = raw_transfer(url, dest).run(&client).await.unwrap();
let elapsed = started.elapsed();
assert_eq!(fs::read(&written).unwrap(), body);
assert!(elapsed > Duration::from_secs(1), "{elapsed:?}");
}
#[tokio::test]
async fn a_stalled_transfer_fails_and_keeps_what_arrived() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let part = dir.path().join("db.mmdb.part");
let url = drip_server(
vec![b"first half".to_vec(), b"never arrives".to_vec()],
Duration::from_secs(3),
);
let client = fetch::client(None, Duration::from_secs(5), Duration::from_millis(300)).unwrap();
let err = raw_transfer(url, dest.clone())
.run(&client)
.await
.unwrap_err();
assert!(matches!(err, GeoIpDownloadError::Http(_)), "{err:?}");
assert!(!dest.exists(), "the destination is only written on success");
assert_eq!(fs::read(&part).unwrap(), b"first half");
}
#[tokio::test]
async fn an_interrupted_transfer_resumes_from_the_part_file() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let part = dir.path().join("db.mmdb.part");
let body = mmdb_body(b"the whole database body");
let (prefix, remainder) = body.split_at(8);
fs::write(&part, prefix).unwrap();
Mock::given(method("GET"))
.and(path("/db.mmdb"))
.respond_with(
ResponseTemplate::new(206)
.insert_header(
"content-range",
format!("bytes 8-{}/{}", body.len() - 1, body.len()).as_str(),
)
.set_body_bytes(remainder.to_vec()),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/db.mmdb.sha256"))
.respond_with(ResponseTemplate::new(200).set_body_string(sha256_hex(&body)))
.mount(&server)
.await;
let written = resumable_transfer(
format!("{}/db.mmdb", server.uri()),
dest,
format!("{}/db.mmdb.sha256", server.uri()),
)
.run(&default_client())
.await
.unwrap();
assert_eq!(fs::read(&written).unwrap(), body);
let requests = server.received_requests().await.unwrap();
assert_eq!(requests[0].headers.get("range").unwrap(), "bytes=8-");
}
#[tokio::test]
async fn a_server_that_ignores_the_range_restarts_the_file() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let part = dir.path().join("db.mmdb.part");
let body = mmdb_body(b"the whole database body");
fs::write(&part, &body[..8]).unwrap();
Mock::given(method("GET"))
.and(path("/db.mmdb"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/db.mmdb.sha256"))
.respond_with(ResponseTemplate::new(200).set_body_string(sha256_hex(&body)))
.mount(&server)
.await;
let written = resumable_transfer(
format!("{}/db.mmdb", server.uri()),
dest,
format!("{}/db.mmdb.sha256", server.uri()),
)
.run(&default_client())
.await
.unwrap();
assert_eq!(fs::read(&written).unwrap(), body);
let requests = server.received_requests().await.unwrap();
assert_eq!(requests[0].headers.get("range").unwrap(), "bytes=8-");
}
#[tokio::test]
async fn a_part_file_holding_the_whole_body_is_started_again() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let part = dir.path().join("db.mmdb.part");
let body = mmdb_body(b"the whole database body");
fs::write(&part, &body).unwrap();
Mock::given(method("GET"))
.and(path("/db.mmdb"))
.and(header("range", format!("bytes={}-", body.len()).as_str()))
.respond_with(
ResponseTemplate::new(416)
.insert_header("content-range", format!("bytes */{}", body.len()).as_str()),
)
.with_priority(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/db.mmdb"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/db.mmdb.sha256"))
.respond_with(ResponseTemplate::new(200).set_body_string(sha256_hex(&body)))
.mount(&server)
.await;
let written = resumable_transfer(
format!("{}/db.mmdb", server.uri()),
dest,
format!("{}/db.mmdb.sha256", server.uri()),
)
.run(&default_client())
.await
.unwrap();
assert_eq!(fs::read(&written).unwrap(), body);
}
#[tokio::test]
async fn a_raw_body_with_no_digest_is_never_continued() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let part = dir.path().join("db.mmdb.part");
let body = mmdb_body(b"the whole database body");
fs::write(&part, b"a prefix of yesterday's build").unwrap();
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
.mount(&server)
.await;
let written = raw_transfer(format!("{}/db.mmdb", server.uri()), dest)
.run(&default_client())
.await
.unwrap();
assert_eq!(fs::read(&written).unwrap(), body);
let requests = server.received_requests().await.unwrap();
assert!(
requests[0].headers.get("range").is_none(),
"the request must not ask for a range it cannot check"
);
}
#[tokio::test]
async fn a_gzip_resume_spliced_from_another_build_fails_the_decode() {
const AT: usize = 24;
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let part = dir.path().join("db.mmdb.part");
let good = mmdb_body(b"yesterday's database");
fs::write(&dest, &good).unwrap();
let yesterday = gzip(&mmdb_body(&b"yesterday's build ".repeat(64)));
let today = gzip(&mmdb_body(&b"today's build ".repeat(64)));
fs::write(&part, &yesterday[..AT]).unwrap();
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(206)
.insert_header(
"content-range",
format!("bytes {AT}-{}/{}", today.len() - 1, today.len()).as_str(),
)
.set_body_bytes(today[AT..].to_vec()),
)
.mount(&server)
.await;
let err = gzip_transfer(format!("{}/db.mmdb", server.uri()), dest.clone())
.run(&default_client())
.await
.unwrap_err();
assert!(matches!(err, GeoIpDownloadError::Io(_)), "{err:?}");
let requests = server.received_requests().await.unwrap();
assert_eq!(
requests[0].headers.get("range").unwrap(),
format!("bytes={AT}-").as_str()
);
assert_eq!(fs::read(&dest).unwrap(), good);
assert_eq!(entries(dir.path()), ["db.mmdb"]);
}
#[tokio::test]
async fn a_part_file_past_the_resume_window_is_not_continued() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("db.mmdb");
let part = dir.path().join("db.mmdb.part");
let body = mmdb_body(b"this month's database body");
fs::write(&part, b"last month's prefix").unwrap();
fs::File::options()
.write(true)
.open(&part)
.unwrap()
.set_modified(SystemTime::now() - Duration::from_secs(48 * 60 * 60))
.unwrap();
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
.mount(&server)
.await;
let written = raw_transfer(format!("{}/db.mmdb", server.uri()), dest)
.run(&default_client())
.await
.unwrap();
assert_eq!(fs::read(&written).unwrap(), body);
let requests = server.received_requests().await.unwrap();
assert!(requests[0].headers.get("range").is_none());
}
#[tokio::test]
async fn a_transport_error_never_carries_the_token() {
let dir = tempfile::tempdir().unwrap();
let config = credentialled(dir.path(), GeoIpProvider::IpInfo);
let mut transfer = plan(Kind::City, &config).unwrap().remove(0);
transfer.url = "http://127.0.0.1:1/data/ipinfo_lite.mmdb".to_string();
let err = transfer.run(&default_client()).await.unwrap_err();
let rendered = format!("{err} {err:?}");
assert!(matches!(err, GeoIpDownloadError::Http(_)), "{err:?}");
assert!(!rendered.contains("token-wxyz"), "{rendered}");
}
#[tokio::test]
async fn a_fresh_file_is_reused_without_a_transfer() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let city = dir.path().join("dbip-city-lite.mmdb");
let asn = dir.path().join("dbip-asn-lite.mmdb");
fs::write(&city, b"fake city").unwrap();
fs::write(&asn, b"fake asn").unwrap();
let proxied = reqwest::Client::builder()
.proxy(reqwest::Proxy::all(server.uri()).unwrap())
.build()
.unwrap();
let config = config_in(dir.path(), GeoIpProvider::DbIp).with_http_client(proxied);
let databases = ensure_databases(&config).await.unwrap();
assert_eq!(requested_targets(&server).await, Vec::<String>::new());
assert_eq!(fs::read(&city).unwrap(), b"fake city");
assert_eq!(databases.city.unwrap().files, [city]);
assert_eq!(databases.asn.unwrap().files, [asn]);
}
#[tokio::test]
async fn a_stale_file_is_re_fetched() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let city = dir.path().join("dbip-city-lite.mmdb");
fs::write(&city, b"stale city").unwrap();
let proxied = reqwest::Client::builder()
.proxy(reqwest::Proxy::all(server.uri()).unwrap())
.build()
.unwrap();
let mut config = config_in(dir.path(), GeoIpProvider::DbIp).with_http_client(proxied);
config.auto_download.max_age_days = 0;
let databases = ensure_databases(&config).await.unwrap();
assert_eq!(databases.city.unwrap().files, [city]);
assert_eq!(
requested_targets(&server).await,
[
"CONNECT download.db-ip.com:443",
"CONNECT download.db-ip.com:443"
]
);
}
#[test]
fn databases_default_is_empty() {
let databases = Databases::default();
assert!(databases.city.is_none());
assert!(databases.asn.is_none());
}
#[test]
fn databases_hold_either_half() {
let databases = Databases {
city: Some(Database {
format: DatabaseFormat::Mmdb,
files: vec![PathBuf::from("/tmp/city.mmdb")],
}),
asn: None,
};
assert_eq!(
databases.city.unwrap().files,
[PathBuf::from("/tmp/city.mmdb")]
);
assert!(databases.asn.is_none());
}
#[test]
fn error_messages_name_the_problem() {
let err = GeoIpDownloadError::MissingCredential {
provider: "MaxMind",
field: "auto_download.maxmind_account_id",
};
let msg = err.to_string();
assert!(msg.contains("MaxMind"), "{msg}");
assert!(msg.contains("auto_download.maxmind_account_id"), "{msg}");
let err = GeoIpDownloadError::NoDatabases {
provider: "IpInfo".to_string(),
kind: "asn",
};
assert!(err.to_string().contains("IpInfo"));
let err = GeoIpDownloadError::ArchiveMemberMissing {
member: "GeoLite2-City.mmdb",
};
assert!(err.to_string().contains("GeoLite2-City.mmdb"));
let err = GeoIpDownloadError::UnexpectedStatus {
url: "https://example.invalid/db".to_string(),
status: 403,
};
assert!(err.to_string().contains("403"));
}
#[test]
fn every_built_in_selection_is_expressible_as_a_row() {
for (choice, kind) in [
(free(GeoIpProvider::DbIp), Kind::City),
(free(GeoIpProvider::DbIp), Kind::Asn),
(free(GeoIpProvider::MaxMind), Kind::City),
(free(GeoIpProvider::MaxMind), Kind::Asn),
(paid(GeoIpProvider::MaxMind), Kind::City),
(paid(GeoIpProvider::MaxMind), Kind::Asn),
(free(GeoIpProvider::IpInfo), Kind::City),
(free(GeoIpProvider::SapicsOriginAsn), Kind::Asn),
(free(GeoIpProvider::SapicsIpToAsn), Kind::Asn),
] {
let source = SourceSpec::select(choice, kind)
.unwrap_or_else(|e| panic!("{choice:?} {kind:?}: {e}"))
.unwrap_or_else(|| panic!("{choice:?} {kind:?} has no row"));
assert_eq!(source.database().format, DatabaseFormat::Mmdb);
assert_eq!(source.database().names.len(), 1, "{choice:?} {kind:?}");
}
for tier in [ProviderTier::Free, ProviderTier::Paid] {
let choice = ProviderChoice {
provider: GeoIpProvider::Custom,
tier,
};
for kind in [Kind::City, Kind::Asn] {
assert!(
SourceSpec::select(choice, kind).unwrap().is_none(),
"{choice:?} {kind:?}"
);
}
}
}
#[test]
fn a_selection_reports_where_its_terms_are_published() {
let dbip = source_terms(ProviderSelection::from(GeoIpProvider::DbIp));
assert_eq!(dbip.len(), 2);
for terms in &dbip {
assert!(terms.terms_url.starts_with("https://"), "{terms:?}");
}
let sapics = source_terms(ProviderSelection::from(GeoIpProvider::SapicsOriginAsn));
assert_eq!(sapics.len(), 1);
assert_eq!(sapics[0].kind, "asn");
assert!(sapics[0].terms_url.starts_with("https://"));
let ipinfo = source_terms(ProviderSelection::from(GeoIpProvider::IpInfo));
assert_eq!(ipinfo.len(), 1);
assert_eq!(ipinfo[0].kind, "city");
assert!(ipinfo[0].min_interval.is_some());
assert_eq!(
source_terms(ProviderSelection::from(GeoIpProvider::Custom)),
Vec::new()
);
}
#[test]
fn the_freshness_default_is_the_providers_own_cadence() {
let month = Duration::from_secs(30 * 86_400);
let defaults = AutoDownloadConfig::default();
let geolite = SourceSpec::select(free(GeoIpProvider::MaxMind), Kind::City)
.unwrap()
.unwrap();
let dbip = SourceSpec::select(free(GeoIpProvider::DbIp), Kind::City)
.unwrap()
.unwrap();
assert_eq!(defaults.max_age_days, 30);
assert!(geolite.staleness_window(&defaults) < month);
assert_eq!(dbip.staleness_window(&defaults), month);
let tightened = AutoDownloadConfig {
max_age_days: 2,
..Default::default()
};
assert_eq!(
dbip.staleness_window(&tightened),
Duration::from_secs(2 * 86_400)
);
}
#[test]
fn a_fetch_ceiling_cannot_be_configured_away() {
let aggressive = AutoDownloadConfig {
max_age_days: 0,
..Default::default()
};
let ipinfo = SourceSpec::select(free(GeoIpProvider::IpInfo), Kind::City)
.unwrap()
.unwrap();
assert_eq!(
ipinfo.staleness_window(&aggressive),
Duration::from_secs(86_400)
);
let dbip = SourceSpec::select(free(GeoIpProvider::DbIp), Kind::City)
.unwrap()
.unwrap();
assert_eq!(dbip.staleness_window(&aggressive), Duration::ZERO);
}
#[test]
fn a_plan_error_never_carries_a_credential() {
let mut config = config_in(Path::new("/var/lib/geoip"), GeoIpProvider::MaxMind);
config.auto_download.maxmind_account_id = Some("account-1234".into());
let err = plan(Kind::City, &config).unwrap_err();
let rendered = format!("{err} {err:?}");
assert!(!rendered.contains("account-1234"), "{rendered}");
}