mirror of
https://github.com/NetworkCats/rustyip.git
synced 2026-11-13 16:17:46 +00:00
test: add unit and integration tests for models, error, config, db, and edge cases
This commit is contained in:
@@ -64,4 +64,16 @@ mod tests {
|
||||
fn rejects_empty() {
|
||||
assert!(!is_cli_user_agent(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_is_case_sensitive() {
|
||||
// Real CLI tools send consistent casing; we intentionally do not
|
||||
// match case-insensitively to avoid false positives.
|
||||
assert!(!is_cli_user_agent("CURL/8.7.1"));
|
||||
assert!(!is_cli_user_agent("Curl/8.7.1"));
|
||||
assert!(!is_cli_user_agent("WGET/1.21.4"));
|
||||
assert!(!is_cli_user_agent("httpie/3.2.2"));
|
||||
assert!(!is_cli_user_agent("XH/0.22.0"));
|
||||
assert!(!is_cli_user_agent("powershell/7.4.1"));
|
||||
}
|
||||
}
|
||||
|
||||
+160
@@ -39,3 +39,163 @@ impl Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// Environment variable tests must run serially because they mutate global state.
|
||||
// We recover from poisoned mutexes since panics in should_panic tests are expected.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
|
||||
ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
const CONFIG_VARS: &[&str] = &[
|
||||
"LISTEN_ADDR",
|
||||
"DB_PATH",
|
||||
"DB_UPDATE_URL",
|
||||
"DB_UPDATE_INTERVAL_HOURS",
|
||||
"SITE_DOMAIN",
|
||||
];
|
||||
|
||||
// SAFETY: These env var helpers are only called while holding ENV_LOCK,
|
||||
// ensuring no concurrent mutation of the process environment.
|
||||
unsafe fn clear_config_vars() {
|
||||
for var in CONFIG_VARS {
|
||||
unsafe { env::remove_var(var) };
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn set_var(key: &str, value: &str) {
|
||||
unsafe { env::set_var(key, value) };
|
||||
}
|
||||
|
||||
unsafe fn remove_var(key: &str) {
|
||||
unsafe { env::remove_var(key) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_when_no_env_vars() {
|
||||
let _guard = lock_env();
|
||||
// SAFETY: ENV_LOCK is held, serializing access to env vars.
|
||||
unsafe { clear_config_vars() };
|
||||
|
||||
let config = Config::from_env();
|
||||
assert_eq!(config.listen_addr.to_string(), "0.0.0.0:3000");
|
||||
assert_eq!(config.db_path, "data/Merged-IP.mmdb");
|
||||
assert!(config.db_update_url.contains("Merged-IP.mmdb"));
|
||||
assert_eq!(config.db_update_interval_hours, 24);
|
||||
assert_eq!(config.site_domain, "localhost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_listen_addr() {
|
||||
let _guard = lock_env();
|
||||
// SAFETY: ENV_LOCK is held.
|
||||
unsafe {
|
||||
clear_config_vars();
|
||||
set_var("LISTEN_ADDR", "127.0.0.1:8080");
|
||||
}
|
||||
|
||||
let config = Config::from_env();
|
||||
assert_eq!(config.listen_addr.to_string(), "127.0.0.1:8080");
|
||||
|
||||
// SAFETY: ENV_LOCK is held.
|
||||
unsafe { remove_var("LISTEN_ADDR") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_db_path() {
|
||||
let _guard = lock_env();
|
||||
// SAFETY: ENV_LOCK is held.
|
||||
unsafe {
|
||||
clear_config_vars();
|
||||
set_var("DB_PATH", "/tmp/test.mmdb");
|
||||
}
|
||||
|
||||
let config = Config::from_env();
|
||||
assert_eq!(config.db_path, "/tmp/test.mmdb");
|
||||
|
||||
// SAFETY: ENV_LOCK is held.
|
||||
unsafe { remove_var("DB_PATH") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_update_url() {
|
||||
let _guard = lock_env();
|
||||
// SAFETY: ENV_LOCK is held.
|
||||
unsafe {
|
||||
clear_config_vars();
|
||||
set_var("DB_UPDATE_URL", "https://example.com/db.mmdb");
|
||||
}
|
||||
|
||||
let config = Config::from_env();
|
||||
assert_eq!(config.db_update_url, "https://example.com/db.mmdb");
|
||||
|
||||
// SAFETY: ENV_LOCK is held.
|
||||
unsafe { remove_var("DB_UPDATE_URL") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_update_interval() {
|
||||
let _guard = lock_env();
|
||||
// SAFETY: ENV_LOCK is held.
|
||||
unsafe {
|
||||
clear_config_vars();
|
||||
set_var("DB_UPDATE_INTERVAL_HOURS", "12");
|
||||
}
|
||||
|
||||
let config = Config::from_env();
|
||||
assert_eq!(config.db_update_interval_hours, 12);
|
||||
|
||||
// SAFETY: ENV_LOCK is held.
|
||||
unsafe { remove_var("DB_UPDATE_INTERVAL_HOURS") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_site_domain() {
|
||||
let _guard = lock_env();
|
||||
// SAFETY: ENV_LOCK is held.
|
||||
unsafe {
|
||||
clear_config_vars();
|
||||
set_var("SITE_DOMAIN", "ip.example.com");
|
||||
}
|
||||
|
||||
let config = Config::from_env();
|
||||
assert_eq!(config.site_domain, "ip.example.com");
|
||||
|
||||
// SAFETY: ENV_LOCK is held.
|
||||
unsafe { remove_var("SITE_DOMAIN") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "LISTEN_ADDR must be a valid socket address")]
|
||||
fn invalid_listen_addr_panics() {
|
||||
let _guard = lock_env();
|
||||
// SAFETY: ENV_LOCK is held.
|
||||
unsafe {
|
||||
clear_config_vars();
|
||||
set_var("LISTEN_ADDR", "not-a-socket-addr");
|
||||
}
|
||||
|
||||
let _config = Config::from_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "DB_UPDATE_INTERVAL_HOURS must be a valid integer")]
|
||||
fn invalid_update_interval_panics() {
|
||||
let _guard = lock_env();
|
||||
// SAFETY: ENV_LOCK is held.
|
||||
unsafe {
|
||||
clear_config_vars();
|
||||
set_var("DB_UPDATE_INTERVAL_HOURS", "not-a-number");
|
||||
}
|
||||
|
||||
let _config = Config::from_env();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,3 +91,167 @@ pub fn lookup_city_name(db: &DbReader, ip: IpAddr) -> Option<String> {
|
||||
let record: CityOnly<'_> = result.decode().ok()??;
|
||||
record.city.names.english.map(str::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
fn test_db_path() -> String {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
format!("{manifest_dir}/data/Merged-IP.mmdb")
|
||||
}
|
||||
|
||||
fn test_reader() -> DbReader {
|
||||
load_db(Path::new(&test_db_path())).expect("failed to load test DB")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_db_with_valid_path() {
|
||||
let reader = load_db(Path::new(&test_db_path()));
|
||||
assert!(reader.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_db_with_invalid_path() {
|
||||
let reader = load_db(Path::new("/nonexistent/path.mmdb"));
|
||||
assert!(reader.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_shared_creates_valid_shared_db() {
|
||||
let reader = test_reader();
|
||||
let shared = new_shared(reader);
|
||||
let loaded = shared.load();
|
||||
// Verify the shared DB is functional by doing a simple lookup
|
||||
let ip: IpAddr = "1.1.1.1".parse().unwrap();
|
||||
let result = loaded.lookup(ip);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_returns_some_for_known_ip() {
|
||||
let reader = test_reader();
|
||||
let ip: IpAddr = "1.1.1.1".parse().unwrap();
|
||||
let result = lookup(&reader, ip);
|
||||
assert!(result.is_some());
|
||||
let info = result.unwrap();
|
||||
assert_eq!(info.ip, "1.1.1.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_returns_none_for_unroutable_ip() {
|
||||
let reader = test_reader();
|
||||
let ip: IpAddr = "0.0.0.0".parse().unwrap();
|
||||
let result = lookup(&reader, ip);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_proxy_returns_data_for_known_ip() {
|
||||
let reader = test_reader();
|
||||
let ip: IpAddr = "45.77.77.77".parse().unwrap();
|
||||
let proxy = lookup_proxy(&reader, ip);
|
||||
assert!(proxy.is_some());
|
||||
let proxy = proxy.unwrap();
|
||||
assert!(proxy.is_proxy);
|
||||
assert!(proxy.is_hosting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_proxy_returns_none_for_unroutable_ip() {
|
||||
let reader = test_reader();
|
||||
let ip: IpAddr = "0.0.0.0".parse().unwrap();
|
||||
let proxy = lookup_proxy(&reader, ip);
|
||||
assert!(proxy.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_asn_number_for_known_ip() {
|
||||
let reader = test_reader();
|
||||
let ip: IpAddr = "1.1.1.1".parse().unwrap();
|
||||
let asn = lookup_asn_number(&reader, ip);
|
||||
assert_eq!(asn, Some(13335));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_asn_number_returns_none_for_unroutable_ip() {
|
||||
let reader = test_reader();
|
||||
let ip: IpAddr = "0.0.0.0".parse().unwrap();
|
||||
let asn = lookup_asn_number(&reader, ip);
|
||||
assert!(asn.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_asn_org_for_known_ip() {
|
||||
let reader = test_reader();
|
||||
let ip: IpAddr = "1.1.1.1".parse().unwrap();
|
||||
let org = lookup_asn_org(&reader, ip);
|
||||
assert!(org.is_some());
|
||||
assert_eq!(org.unwrap(), "Cloudflare, Inc.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_asn_org_returns_none_for_unroutable_ip() {
|
||||
let reader = test_reader();
|
||||
let ip: IpAddr = "0.0.0.0".parse().unwrap();
|
||||
let org = lookup_asn_org(&reader, ip);
|
||||
assert!(org.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_country_name_for_known_ip() {
|
||||
let reader = test_reader();
|
||||
let ip: IpAddr = "45.77.77.77".parse().unwrap();
|
||||
let country = lookup_country_name(&reader, ip);
|
||||
assert_eq!(country.as_deref(), Some("United States"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_country_name_returns_none_for_unroutable_ip() {
|
||||
let reader = test_reader();
|
||||
let ip: IpAddr = "0.0.0.0".parse().unwrap();
|
||||
let country = lookup_country_name(&reader, ip);
|
||||
assert!(country.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_city_name_for_known_ip() {
|
||||
let reader = test_reader();
|
||||
let ip: IpAddr = "45.77.77.77".parse().unwrap();
|
||||
let city = lookup_city_name(&reader, ip);
|
||||
assert_eq!(city.as_deref(), Some("Piscataway"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_city_name_returns_none_for_unroutable_ip() {
|
||||
let reader = test_reader();
|
||||
let ip: IpAddr = "0.0.0.0".parse().unwrap();
|
||||
let city = lookup_city_name(&reader, ip);
|
||||
assert!(city.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_works_with_ipv6() {
|
||||
let reader = test_reader();
|
||||
let ip: IpAddr = "2606:4700:4700::1111".parse().unwrap();
|
||||
let result = lookup(&reader, ip);
|
||||
assert!(result.is_some());
|
||||
let info = result.unwrap();
|
||||
assert_eq!(info.ip, "2606:4700:4700::1111");
|
||||
assert!(info.asn.is_some());
|
||||
assert_eq!(
|
||||
info.asn.as_ref().unwrap().autonomous_system_number,
|
||||
Some(13335)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_lookups_work_with_ipv6() {
|
||||
let reader = test_reader();
|
||||
let ip: IpAddr = "2606:4700:4700::1111".parse().unwrap();
|
||||
assert_eq!(lookup_asn_number(&reader, ip), Some(13335));
|
||||
assert!(lookup_asn_org(&reader, ip).is_some());
|
||||
assert!(lookup_proxy(&reader, ip).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,3 +19,57 @@ impl IntoResponse for AppError {
|
||||
(status, message).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
async fn check_error_response(error: AppError, expected_status: StatusCode, expected_body: &str) {
|
||||
let response = error.into_response();
|
||||
assert_eq!(response.status(), expected_status);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let text = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert_eq!(text, expected_body);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ip_not_found_returns_404() {
|
||||
check_error_response(
|
||||
AppError::IpNotFound,
|
||||
StatusCode::NOT_FOUND,
|
||||
"IP not found in database",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_ip_returns_400() {
|
||||
check_error_response(
|
||||
AppError::InvalidIp,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid IP address",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_client_ip_returns_400() {
|
||||
check_error_response(
|
||||
AppError::MissingClientIp,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Missing client IP address",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn db_lookup_failed_returns_500() {
|
||||
check_error_response(
|
||||
AppError::DbLookupFailed,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Database lookup failed",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
+424
@@ -423,3 +423,427 @@ pub fn get_en_name(names: &Option<Names>) -> &str {
|
||||
.and_then(|n| n.english.as_deref())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
fn empty_mmdb_names<'a>() -> MmdbNames<'a> {
|
||||
MmdbNames::default()
|
||||
}
|
||||
|
||||
fn english_only_names<'a>() -> MmdbNames<'a> {
|
||||
MmdbNames {
|
||||
english: Some("TestName"),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn full_names<'a>() -> MmdbNames<'a> {
|
||||
MmdbNames {
|
||||
german: Some("Deutsch"),
|
||||
english: Some("English"),
|
||||
spanish: Some("Espanol"),
|
||||
french: Some("Francais"),
|
||||
japanese: Some("Nihongo"),
|
||||
brazilian_portuguese: Some("Portugues"),
|
||||
russian: Some("Russkiy"),
|
||||
simplified_chinese: Some("Zhongwen"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- MmdbNames::is_empty tests ---
|
||||
|
||||
#[test]
|
||||
fn mmdb_names_is_empty_when_all_none() {
|
||||
let names = empty_mmdb_names();
|
||||
assert!(names.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mmdb_names_is_not_empty_with_english() {
|
||||
let names = english_only_names();
|
||||
assert!(!names.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mmdb_names_is_not_empty_with_non_english_only() {
|
||||
let names = MmdbNames {
|
||||
german: Some("Berlin"),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!names.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mmdb_names_is_not_empty_when_fully_populated() {
|
||||
let names = full_names();
|
||||
assert!(!names.is_empty());
|
||||
}
|
||||
|
||||
// --- convert_names tests ---
|
||||
|
||||
#[test]
|
||||
fn convert_names_returns_none_for_empty() {
|
||||
let names = empty_mmdb_names();
|
||||
assert!(convert_names(&names).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_names_returns_some_with_english() {
|
||||
let names = english_only_names();
|
||||
let result = convert_names(&names).expect("should produce Some");
|
||||
assert_eq!(result.english.as_deref(), Some("TestName"));
|
||||
assert!(result.german.is_none());
|
||||
assert!(result.spanish.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_names_copies_all_languages() {
|
||||
let names = full_names();
|
||||
let result = convert_names(&names).expect("should produce Some");
|
||||
assert_eq!(result.german.as_deref(), Some("Deutsch"));
|
||||
assert_eq!(result.english.as_deref(), Some("English"));
|
||||
assert_eq!(result.spanish.as_deref(), Some("Espanol"));
|
||||
assert_eq!(result.french.as_deref(), Some("Francais"));
|
||||
assert_eq!(result.japanese.as_deref(), Some("Nihongo"));
|
||||
assert_eq!(result.brazilian_portuguese.as_deref(), Some("Portugues"));
|
||||
assert_eq!(result.russian.as_deref(), Some("Russkiy"));
|
||||
assert_eq!(result.simplified_chinese.as_deref(), Some("Zhongwen"));
|
||||
}
|
||||
|
||||
// --- has_* predicate tests ---
|
||||
|
||||
#[test]
|
||||
fn has_city_false_for_default() {
|
||||
let city = MmdbCity::default();
|
||||
assert!(!has_city(&city));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_city_true_with_geoname_id() {
|
||||
let city = MmdbCity {
|
||||
geoname_id: Some(12345),
|
||||
names: empty_mmdb_names(),
|
||||
};
|
||||
assert!(has_city(&city));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_city_true_with_names() {
|
||||
let city = MmdbCity {
|
||||
geoname_id: None,
|
||||
names: english_only_names(),
|
||||
};
|
||||
assert!(has_city(&city));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_continent_false_for_default() {
|
||||
let continent = MmdbContinent::default();
|
||||
assert!(!has_continent(&continent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_continent_true_with_code() {
|
||||
let continent = MmdbContinent {
|
||||
code: Some("NA"),
|
||||
geoname_id: None,
|
||||
names: empty_mmdb_names(),
|
||||
};
|
||||
assert!(has_continent(&continent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_country_false_for_default() {
|
||||
let country = MmdbCountry::default();
|
||||
assert!(!has_country(&country));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_country_true_with_iso_code() {
|
||||
let country = MmdbCountry {
|
||||
geoname_id: None,
|
||||
iso_code: Some("US"),
|
||||
names: empty_mmdb_names(),
|
||||
};
|
||||
assert!(has_country(&country));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_location_false_for_default() {
|
||||
let loc = MmdbLocation::default();
|
||||
assert!(!has_location(&loc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_location_true_with_latitude() {
|
||||
let loc = MmdbLocation {
|
||||
latitude: Some(40.0),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(has_location(&loc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_location_true_with_timezone() {
|
||||
let loc = MmdbLocation {
|
||||
time_zone: Some("America/New_York"),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(has_location(&loc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_postal_false_for_default() {
|
||||
let postal = MmdbPostal::default();
|
||||
assert!(!has_postal(&postal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_postal_true_with_code() {
|
||||
let postal = MmdbPostal {
|
||||
code: Some("10001"),
|
||||
};
|
||||
assert!(has_postal(&postal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_asn_false_for_default() {
|
||||
let asn = MmdbAsn::default();
|
||||
assert!(!has_asn(&asn));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_asn_true_with_number() {
|
||||
let asn = MmdbAsn {
|
||||
autonomous_system_number: Some(13335),
|
||||
autonomous_system_organization: None,
|
||||
as_domain: None,
|
||||
};
|
||||
assert!(has_asn(&asn));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_asn_true_with_org() {
|
||||
let asn = MmdbAsn {
|
||||
autonomous_system_number: None,
|
||||
autonomous_system_organization: Some("Cloudflare"),
|
||||
as_domain: None,
|
||||
};
|
||||
assert!(has_asn(&asn));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_asn_true_with_domain() {
|
||||
let asn = MmdbAsn {
|
||||
autonomous_system_number: None,
|
||||
autonomous_system_organization: None,
|
||||
as_domain: Some("cloudflare.com"),
|
||||
};
|
||||
assert!(has_asn(&asn));
|
||||
}
|
||||
|
||||
// --- from_mmdb_record tests ---
|
||||
|
||||
#[test]
|
||||
fn from_mmdb_record_with_empty_record() {
|
||||
let record = MmdbRecord::default();
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
|
||||
let info = from_mmdb_record(ip, &record);
|
||||
|
||||
assert_eq!(info.ip, "1.2.3.4");
|
||||
assert!(info.city.is_none());
|
||||
assert!(info.continent.is_none());
|
||||
assert!(info.country.is_none());
|
||||
assert!(info.location.is_none());
|
||||
assert!(info.postal.is_none());
|
||||
assert!(info.registered_country.is_none());
|
||||
assert!(info.subdivisions.is_none());
|
||||
assert!(info.asn.is_none());
|
||||
assert!(!info.proxy.is_proxy);
|
||||
assert!(!info.proxy.is_vpn);
|
||||
assert!(!info.proxy.is_tor);
|
||||
assert!(!info.proxy.is_hosting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_mmdb_record_with_populated_record() {
|
||||
let record = MmdbRecord {
|
||||
city: MmdbCity {
|
||||
geoname_id: Some(5101798),
|
||||
names: english_only_names(),
|
||||
},
|
||||
continent: MmdbContinent {
|
||||
code: Some("NA"),
|
||||
geoname_id: Some(6255149),
|
||||
names: english_only_names(),
|
||||
},
|
||||
country: MmdbCountry {
|
||||
geoname_id: Some(6252001),
|
||||
iso_code: Some("US"),
|
||||
names: english_only_names(),
|
||||
},
|
||||
location: MmdbLocation {
|
||||
accuracy_radius: Some(1000),
|
||||
latitude: Some(40.5),
|
||||
longitude: Some(-74.3),
|
||||
metro_code: Some(501),
|
||||
time_zone: Some("America/New_York"),
|
||||
},
|
||||
postal: MmdbPostal {
|
||||
code: Some("08854"),
|
||||
},
|
||||
registered_country: MmdbCountry {
|
||||
geoname_id: Some(6252001),
|
||||
iso_code: Some("US"),
|
||||
names: english_only_names(),
|
||||
},
|
||||
subdivisions: vec![MmdbSubdivision {
|
||||
geoname_id: Some(5101760),
|
||||
iso_code: Some("NJ"),
|
||||
names: english_only_names(),
|
||||
}],
|
||||
asn: MmdbAsn {
|
||||
autonomous_system_number: Some(20473),
|
||||
autonomous_system_organization: Some("The Constant Company"),
|
||||
as_domain: Some("vultr.com"),
|
||||
},
|
||||
proxy: MmdbProxy {
|
||||
is_proxy: true,
|
||||
is_hosting: true,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(45, 77, 77, 77));
|
||||
let info = from_mmdb_record(ip, &record);
|
||||
|
||||
assert_eq!(info.ip, "45.77.77.77");
|
||||
|
||||
let city = info.city.as_ref().expect("city should be present");
|
||||
assert_eq!(city.geoname_id, Some(5101798));
|
||||
|
||||
let continent = info
|
||||
.continent
|
||||
.as_ref()
|
||||
.expect("continent should be present");
|
||||
assert_eq!(continent.code.as_deref(), Some("NA"));
|
||||
|
||||
let country = info.country.as_ref().expect("country should be present");
|
||||
assert_eq!(country.iso_code.as_deref(), Some("US"));
|
||||
|
||||
let location = info.location.as_ref().expect("location should be present");
|
||||
assert_eq!(location.accuracy_radius, Some(1000));
|
||||
assert_eq!(location.latitude, Some(40.5));
|
||||
assert_eq!(location.time_zone.as_deref(), Some("America/New_York"));
|
||||
|
||||
let postal = info.postal.as_ref().expect("postal should be present");
|
||||
assert_eq!(postal.code.as_deref(), Some("08854"));
|
||||
|
||||
assert!(info.registered_country.is_some());
|
||||
|
||||
let subdivisions = info
|
||||
.subdivisions
|
||||
.as_ref()
|
||||
.expect("subdivisions should be present");
|
||||
assert_eq!(subdivisions.len(), 1);
|
||||
assert_eq!(subdivisions[0].iso_code.as_deref(), Some("NJ"));
|
||||
|
||||
let asn = info.asn.as_ref().expect("asn should be present");
|
||||
assert_eq!(asn.autonomous_system_number, Some(20473));
|
||||
assert_eq!(
|
||||
asn.autonomous_system_organization.as_deref(),
|
||||
Some("The Constant Company")
|
||||
);
|
||||
assert_eq!(asn.as_domain.as_deref(), Some("vultr.com"));
|
||||
|
||||
assert!(info.proxy.is_proxy);
|
||||
assert!(info.proxy.is_hosting);
|
||||
assert!(!info.proxy.is_vpn);
|
||||
assert!(!info.proxy.is_tor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_mmdb_record_with_ipv6() {
|
||||
let record = MmdbRecord::default();
|
||||
let ip = IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111));
|
||||
let info = from_mmdb_record(ip, &record);
|
||||
assert_eq!(info.ip, "2606:4700:4700::1111");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_mmdb_record_empty_subdivisions_become_none() {
|
||||
let record = MmdbRecord {
|
||||
subdivisions: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1));
|
||||
let info = from_mmdb_record(ip, &record);
|
||||
assert!(info.subdivisions.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_mmdb_record_proxy_flags_are_forwarded() {
|
||||
let record = MmdbRecord {
|
||||
proxy: MmdbProxy {
|
||||
is_proxy: true,
|
||||
is_vpn: true,
|
||||
is_tor: true,
|
||||
is_hosting: true,
|
||||
is_cdn: true,
|
||||
is_school: true,
|
||||
is_anonymous: true,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
|
||||
let info = from_mmdb_record(ip, &record);
|
||||
assert!(info.proxy.is_proxy);
|
||||
assert!(info.proxy.is_vpn);
|
||||
assert!(info.proxy.is_tor);
|
||||
assert!(info.proxy.is_hosting);
|
||||
assert!(info.proxy.is_cdn);
|
||||
assert!(info.proxy.is_school);
|
||||
assert!(info.proxy.is_anonymous);
|
||||
}
|
||||
|
||||
// --- get_en_name tests ---
|
||||
|
||||
#[test]
|
||||
fn get_en_name_returns_empty_for_none() {
|
||||
let names: Option<Names> = None;
|
||||
assert_eq!(get_en_name(&names), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_en_name_returns_empty_when_english_missing() {
|
||||
let names = Some(Names {
|
||||
german: Some("Berlin".to_string()),
|
||||
english: None,
|
||||
spanish: None,
|
||||
french: None,
|
||||
japanese: None,
|
||||
brazilian_portuguese: None,
|
||||
russian: None,
|
||||
simplified_chinese: None,
|
||||
});
|
||||
assert_eq!(get_en_name(&names), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_en_name_returns_english_value() {
|
||||
let names = Some(Names {
|
||||
english: Some("New York".to_string()),
|
||||
german: None,
|
||||
spanish: None,
|
||||
french: None,
|
||||
japanese: None,
|
||||
brazilian_portuguese: None,
|
||||
russian: None,
|
||||
simplified_chinese: None,
|
||||
});
|
||||
assert_eq!(get_en_name(&names), "New York");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,3 +280,152 @@ async fn ipv6_via_cf_header() {
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body.trim(), "2606:4700:4700::1111");
|
||||
}
|
||||
|
||||
// --- Edge case tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_ip_query_falls_back_to_cf_header() {
|
||||
let app = build_test_app();
|
||||
let (status, body) = get_with_headers(
|
||||
&app,
|
||||
"/json?ip=",
|
||||
vec![("CF-Connecting-IP", "8.8.8.8")],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(json["ip"], "8.8.8.8");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn whitespace_ip_query_falls_back_to_cf_header() {
|
||||
let app = build_test_app();
|
||||
let (status, body) = get_with_headers(
|
||||
&app,
|
||||
"/json?ip=%20",
|
||||
vec![("CF-Connecting-IP", "8.8.8.8")],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(json["ip"], "8.8.8.8");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ip_not_in_database_returns_500() {
|
||||
let app = build_test_app();
|
||||
// 0.0.0.0 is unlikely to be in a geo database
|
||||
let (status, _) = get(&app, "/json?ip=0.0.0.0").await;
|
||||
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_with_query_param_returns_html() {
|
||||
let app = build_test_app();
|
||||
let (status, body) = get_with_headers(
|
||||
&app,
|
||||
"/?ip=45.77.77.77",
|
||||
vec![
|
||||
("User-Agent", "curl/8.7.1"),
|
||||
("CF-Connecting-IP", "1.2.3.4"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
// When CLI sends ?ip=, the shortcut is bypassed and HTML is returned
|
||||
assert!(body.contains("<!DOCTYPE html>"));
|
||||
assert!(body.contains("45.77.77.77"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accept_json_with_query_param_returns_html() {
|
||||
let app = build_test_app();
|
||||
let (status, body) = get_with_headers(
|
||||
&app,
|
||||
"/?ip=45.77.77.77",
|
||||
vec![
|
||||
("Accept", "application/json"),
|
||||
("CF-Connecting-IP", "8.8.8.8"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
// When Accept: application/json is sent with ?ip=, the JSON shortcut is bypassed
|
||||
assert!(body.contains("<!DOCTYPE html>"));
|
||||
assert!(body.contains("45.77.77.77"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ip_endpoint_via_cf_header() {
|
||||
let app = build_test_app();
|
||||
let (status, body) = get_with_headers(
|
||||
&app,
|
||||
"/ip",
|
||||
vec![("CF-Connecting-IP", "1.1.1.1")],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body.trim(), "1.1.1.1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn asn_endpoint_via_cf_header() {
|
||||
let app = build_test_app();
|
||||
let (status, body) = get_with_headers(
|
||||
&app,
|
||||
"/asn",
|
||||
vec![("CF-Connecting-IP", "1.1.1.1")],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body.trim(), "AS13335");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn org_endpoint_via_cf_header() {
|
||||
let app = build_test_app();
|
||||
let (status, body) = get_with_headers(
|
||||
&app,
|
||||
"/org",
|
||||
vec![("CF-Connecting-IP", "1.1.1.1")],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body.trim(), "Cloudflare, Inc.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn country_endpoint_via_cf_header() {
|
||||
let app = build_test_app();
|
||||
let (status, body) = get_with_headers(
|
||||
&app,
|
||||
"/country",
|
||||
vec![("CF-Connecting-IP", "45.77.77.77")],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body.trim(), "United States");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_ip_on_field_endpoints_returns_400() {
|
||||
let app = build_test_app();
|
||||
|
||||
let endpoints = ["/ip", "/asn", "/org", "/country", "/city", "/proxy", "/vpn", "/hosting", "/tor"];
|
||||
for endpoint in endpoints {
|
||||
let uri = format!("{endpoint}?ip=invalid");
|
||||
let (status, _) = get(&app, &uri).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "expected 400 for {endpoint}?ip=invalid");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn field_endpoints_without_ip_or_header_return_400() {
|
||||
let app = build_test_app();
|
||||
|
||||
let endpoints = ["/ip", "/asn", "/org", "/country", "/city", "/proxy", "/vpn", "/hosting", "/tor", "/json"];
|
||||
for endpoint in endpoints {
|
||||
let (status, _) = get(&app, endpoint).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "expected 400 for {endpoint} without IP");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user