From efedf1cd59eeb0bf31826a0fef29a816eccf2566 Mon Sep 17 00:00:00 2001 From: NetworkCats Date: Mon, 2 Mar 2026 23:56:51 +0800 Subject: [PATCH] test: add unit and integration tests for models, error, config, db, and edge cases --- src/cli_detect.rs | 12 ++ src/config.rs | 160 ++++++++++++++++ src/db.rs | 164 +++++++++++++++++ src/error.rs | 54 ++++++ src/models.rs | 424 +++++++++++++++++++++++++++++++++++++++++++ tests/integration.rs | 149 +++++++++++++++ 6 files changed, 963 insertions(+) diff --git a/src/cli_detect.rs b/src/cli_detect.rs index 4d4f6af..8c81a94 100644 --- a/src/cli_detect.rs +++ b/src/cli_detect.rs @@ -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")); + } } diff --git a/src/config.rs b/src/config.rs index dfc770c..fc1b882 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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(); + } +} diff --git a/src/db.rs b/src/db.rs index 7949a22..7e62586 100644 --- a/src/db.rs +++ b/src/db.rs @@ -91,3 +91,167 @@ pub fn lookup_city_name(db: &DbReader, ip: IpAddr) -> Option { 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()); + } +} diff --git a/src/error.rs b/src/error.rs index c7513d8..1349c09 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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; + } +} diff --git a/src/models.rs b/src/models.rs index 0ea2e1b..a93a0b0 100644 --- a/src/models.rs +++ b/src/models.rs @@ -423,3 +423,427 @@ pub fn get_en_name(names: &Option) -> &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 = 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"); + } +} diff --git a/tests/integration.rs b/tests/integration.rs index 3cc30fb..74fe1d1 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -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("")); + 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("")); + 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"); + } +}