diff --git a/backend/src/routes/asn/helpers.rs b/backend/src/routes/asn/helpers.rs index e25b3df..8b995bf 100644 --- a/backend/src/routes/asn/helpers.rs +++ b/backend/src/routes/asn/helpers.rs @@ -57,3 +57,118 @@ pub fn extract_last_modified(find_value: &dyn Fn(&str) -> String) -> Option) -> impl Fn(&str) -> String { + move |key: &str| map.get(key).map(|v| v.to_string()).unwrap_or_default() + } + + #[test] + fn build_arin_address_full_fields() { + let m: HashMap<&str, &str> = [ + ("Address", "123 Main St"), + ("City", "Springfield"), + ("StateProv", "IL"), + ("PostalCode", "62701"), + ("Country", "USA"), + ] + .into_iter() + .collect(); + let find = constant(m); + let parts = build_arin_address(&find); + assert_eq!(parts.len(), 3); + assert_eq!(parts[0], "123 Main St"); + assert_eq!(parts[1], "Springfield, IL, 62701"); + assert_eq!(parts[2], "USA"); + } + + #[test] + fn build_arin_address_partial_fields_skips_empty() { + let m: HashMap<&str, &str> = [ + ("City", "Springfield"), + ("PostalCode", "62701"), + ("Country", "USA"), + ] + .into_iter() + .collect(); + let find = constant(m); + let parts = build_arin_address(&find); + assert_eq!( + parts, + vec!["Springfield, 62701".to_string(), "USA".to_string()] + ); + } + + #[test] + fn build_arin_address_no_fields_is_empty() { + let m: HashMap<&str, &str> = HashMap::new(); + let find = constant(m); + assert!(build_arin_address(&find).is_empty()); + } + + #[test] + fn extract_created_prefers_created_then_regdate_then_regdate_dash() { + let m: HashMap<&str, &str> = HashMap::new(); + let find = constant(m.clone()); + assert_eq!(extract_created(&find), None); + + let m: HashMap<&str, &str> = [("reg-date", "2000-01-01")].into_iter().collect(); + let find = constant(m); + assert_eq!(extract_created(&find), Some("2000-01-01".to_string())); + + let m: HashMap<&str, &str> = [("RegDate", "2010-05-05"), ("reg-date", "2000-01-01")] + .into_iter() + .collect(); + let find = constant(m); + assert_eq!( + extract_created(&find), + Some("2010-05-05".to_string()), + "RegDate beats reg-date", + ); + + let m: HashMap<&str, &str> = [("created", "1995-01-01"), ("RegDate", "2010-05-05")] + .into_iter() + .collect(); + let find = constant(m); + assert_eq!( + extract_created(&find), + Some("1995-01-01".to_string()), + "created beats the rest", + ); + } + + #[test] + fn extract_last_modified_prefers_last_modified_then_updated_then_changed() { + let m: HashMap<&str, &str> = HashMap::new(); + let find = constant(m); + assert_eq!(extract_last_modified(&find), None); + + let m: HashMap<&str, &str> = [("changed", "2000-01-01")].into_iter().collect(); + let find = constant(m); + assert_eq!(extract_last_modified(&find), Some("2000-01-01".to_string()),); + + let m: HashMap<&str, &str> = [("Updated", "2024-01-01"), ("changed", "2000-01-01")] + .into_iter() + .collect(); + let find = constant(m); + assert_eq!( + extract_last_modified(&find), + Some("2024-01-01".to_string()), + "Updated beats changed", + ); + + let m: HashMap<&str, &str> = [("last-modified", "1999-12-31"), ("Updated", "2024-01-01")] + .into_iter() + .collect(); + let find = constant(m); + assert_eq!( + extract_last_modified(&find), + Some("1999-12-31".to_string()), + "last-modified beats Updated", + ); + } +} diff --git a/backend/src/routes/asn/mod.rs b/backend/src/routes/asn/mod.rs index 60ad374..c2addfb 100644 --- a/backend/src/routes/asn/mod.rs +++ b/backend/src/routes/asn/mod.rs @@ -1,5 +1,9 @@ pub mod helpers; pub mod parser; +#[cfg(test)] +mod parser_contacts_tests; +#[cfg(test)] +mod parser_tests; use crate::asn_types::*; use crate::rate_limit::UpstreamRateLimiter; diff --git a/backend/src/routes/asn/parser_contacts_tests.rs b/backend/src/routes/asn/parser_contacts_tests.rs new file mode 100644 index 0000000..26e9acd --- /dev/null +++ b/backend/src/routes/asn/parser_contacts_tests.rs @@ -0,0 +1,274 @@ +use super::super::super::asn_types::*; +use super::parser::parse_raw_responses; + +fn rec(key: &str, value: &str) -> RipeStatWhoisRecord { + RipeStatWhoisRecord { + key: Some(key.to_string()), + value: Some(value.to_string()), + } +} + +fn empty_overview() -> RipeStatOverviewResponse { + RipeStatOverviewResponse { data: None } +} + +fn empty_peeringdb() -> PeeringDbResponse { + PeeringDbResponse { data: None } +} + +fn whois_with(authority: Option<&str>, records: Vec>) -> RipeStatWhoisResponse { + RipeStatWhoisResponse { + data: Some(RipeStatWhoisData { + records: Some( + records + .into_iter() + .map(|g| g.into_iter().map(|(k, v)| rec(k, v)).collect()) + .collect(), + ), + authorities: authority.map(|a| vec![a.to_string()]), + }), + } +} + +fn peeringdb_with(website: Option<&str>, info_ratio: Option<&str>) -> PeeringDbResponse { + PeeringDbResponse { + data: Some(vec![PeeringDbNet { + website: website.map(|s| s.to_string()), + info_ratio: info_ratio.map(|s| s.to_string()), + }]), + } +} + +#[test] +fn ripestat_email_contacts_chain_includes_tech_admin() { + let r = parse_raw_responses( + 1, + whois_with( + Some("RIPE"), + vec![vec![ + ("e-mail", "first@example.com"), + ("e-mail", "second@example.com"), + ("OrgTechEmail", "tech@example.com"), + ("OrgNOCEmail", "noc@example.com"), + ]], + ), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!( + r.data.email_contacts, + vec![ + "first@example.com".to_string(), + "second@example.com".to_string() + ], + "e-mail entries win when present", + ); + + let r = parse_raw_responses( + 1, + whois_with( + Some("RIPE"), + vec![vec![ + ("OrgTechEmail", "tech@example.com"), + ("OrgNOCEmail", "noc@example.com"), + ]], + ), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!( + r.data.email_contacts, + vec![ + "tech@example.com".to_string(), + "noc@example.com".to_string() + ], + ); + + let r = parse_raw_responses( + 1, + whois_with( + Some("RIPE"), + vec![vec![("tech-c", "TECH-HANDLE"), ("admin-c", "ADMIN-HANDLE")]], + ), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!( + r.data.email_contacts, + vec!["TECH-HANDLE".to_string(), "ADMIN-HANDLE".to_string()], + "tech-c/admin-c are the last-resort fallback", + ); +} + +#[test] +fn ripestat_abuse_contacts_chain() { + let r = parse_raw_responses( + 1, + whois_with( + Some("RIPE"), + vec![vec![("abuse-mailbox", "abuse@example.com")]], + ), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!(r.data.abuse_contacts, vec!["abuse@example.com".to_string()]); + + let r = parse_raw_responses( + 1, + whois_with( + Some("RIPE"), + vec![vec![("OrgAbuseEmail", "org-abuse@example.com")]], + ), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!( + r.data.abuse_contacts, + vec!["org-abuse@example.com".to_string()] + ); + + let r = parse_raw_responses( + 1, + whois_with(Some("RIPE"), vec![vec![("abuse-c", "ABUSE-C")]]), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!(r.data.abuse_contacts, vec!["ABUSE-C".to_string()]); +} + +#[test] +fn ripestat_abuse_falls_back_to_email_when_no_abuse_keys() { + let r = parse_raw_responses( + 1, + whois_with(Some("RIPE"), vec![vec![("e-mail", "fallback@example.com")]]), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!( + r.data.abuse_contacts, + vec!["fallback@example.com".to_string()], + "abuse_contacts should mirror email_contacts when no abuse fields exist", + ); +} + +#[test] +fn ripestat_arin_source_triggers_arin_address_builder() { + let r = parse_raw_responses( + 1, + whois_with( + Some("ARIN"), + vec![vec![ + ("source", "ARIN"), + ("City", "Atlanta"), + ("StateProv", "GA"), + ("PostalCode", "30303"), + ("Country", "USA"), + ]], + ), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!( + r.data.owner_address, + vec!["Atlanta, GA, 30303".to_string(), "USA".to_string()], + "ARIN builder fires when no address records exist", + ); +} + +#[test] +fn ripestat_remarks_without_http_used_as_address_fallback() { + let r = parse_raw_responses( + 1, + whois_with( + Some("RIPE"), + vec![vec![ + ("remarks", "https://example.com/page"), + ("remarks", "Office address line"), + ]], + ), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!( + r.data.owner_address, + vec!["Office address line".to_string()] + ); +} + +#[test] +fn ripestat_peeringdb_website_and_traffic_ratio() { + let r = parse_raw_responses( + 1, + whois_with(Some("RIPE"), vec![vec![("source", "RIPE")]]), + empty_overview(), + peeringdb_with(Some("https://example.com"), Some("10/20")), + ) + .expect("ok"); + assert_eq!(r.data.website, "https://example.com"); + assert_eq!(r.data.traffic_ratio, Some("10/20".to_string())); +} + +#[test] +fn ripestat_website_falls_back_to_http_remark() { + let r = parse_raw_responses( + 1, + whois_with( + Some("RIPE"), + vec![vec![ + ("source", "RIPE"), + ("remarks", "https://example.com/about"), + ]], + ), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!(r.data.website, "https://example.com/about"); +} + +#[test] +fn ripestat_dates_extracted_with_fallbacks() { + let r = parse_raw_responses( + 1, + whois_with( + Some("RIPE"), + vec![vec![ + ("created", "1995-01-01"), + ("last-modified", "2024-08-14"), + ]], + ), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!( + r.data.rir_allocation.date_allocated, + Some("1995-01-01".to_string()) + ); + assert_eq!(r.data.date_updated, Some("2024-08-14".to_string())); + + let r = parse_raw_responses( + 1, + whois_with( + Some("ARIN"), + vec![vec![("RegDate", "2010-05-05"), ("Updated", "2020-06-06")]], + ), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!( + r.data.rir_allocation.date_allocated, + Some("2010-05-05".to_string()) + ); + assert_eq!(r.data.date_updated, Some("2020-06-06".to_string())); +} diff --git a/backend/src/routes/asn/parser_tests.rs b/backend/src/routes/asn/parser_tests.rs new file mode 100644 index 0000000..9df33b3 --- /dev/null +++ b/backend/src/routes/asn/parser_tests.rs @@ -0,0 +1,194 @@ +use super::super::super::asn_types::*; +use super::parser::parse_raw_responses; + +fn rec(key: &str, value: &str) -> RipeStatWhoisRecord { + RipeStatWhoisRecord { + key: Some(key.to_string()), + value: Some(value.to_string()), + } +} + +fn empty_whois() -> RipeStatWhoisResponse { + RipeStatWhoisResponse { data: None } +} + +fn empty_overview() -> RipeStatOverviewResponse { + RipeStatOverviewResponse { data: None } +} + +fn empty_peeringdb() -> PeeringDbResponse { + PeeringDbResponse { data: None } +} + +fn whois_with(authority: Option<&str>, records: Vec>) -> RipeStatWhoisResponse { + RipeStatWhoisResponse { + data: Some(RipeStatWhoisData { + records: Some( + records + .into_iter() + .map(|g| g.into_iter().map(|(k, v)| rec(k, v)).collect()) + .collect(), + ), + authorities: authority.map(|a| vec![a.to_string()]), + }), + } +} + +fn overview_with(holder: &str) -> RipeStatOverviewResponse { + RipeStatOverviewResponse { + data: Some(RipeStatOverviewData { + holder: Some(holder.to_string()), + }), + } +} + +#[test] +fn ripestat_ripe_authority_maps_to_ripe_ncc() { + let r = parse_raw_responses( + 15169, + whois_with(Some("RIPE"), vec![vec![("source", "RIPE")]]), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!(r.data.asn, 15169); + assert_eq!(r.data.rir_allocation.rir_name, "RIPE NCC"); +} + +#[test] +fn ripestat_arin_authority_maps_to_arin() { + let r = parse_raw_responses( + 15169, + whois_with(None, vec![vec![("source", "ARIN")]]), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!(r.data.rir_allocation.rir_name, "ARIN"); +} + +#[test] +fn ripestat_apnic_lacnic_afrinic_pass_through() { + for (label, expected) in [ + ("APNIC", "APNIC"), + ("LACNIC", "LACNIC"), + ("AFRINIC", "AFRINIC"), + ] { + let r = parse_raw_responses( + 1, + whois_with(Some(label), vec![vec![("source", label)]]), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!(r.data.rir_allocation.rir_name, expected, "{label} expected"); + } +} + +#[test] +fn ripestat_unknown_source_falls_back_to_unknown() { + let r = parse_raw_responses( + 1, + whois_with(None, vec![vec![("source", "FOO")]]), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!(r.data.rir_allocation.rir_name, "FOO"); +} + +#[test] +fn ripestat_no_source_uses_authority() { + let r = parse_raw_responses( + 1, + whois_with(Some("APNIC"), vec![vec![]]), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!(r.data.rir_allocation.rir_name, "APNIC"); +} + +#[test] +fn ripestat_missing_authority_yields_unknown() { + let r = parse_raw_responses(1, empty_whois(), empty_overview(), empty_peeringdb()).expect("ok"); + assert_eq!(r.data.rir_allocation.rir_name, "Unknown"); +} + +#[test] +fn ripestat_holder_provides_name_and_description() { + let r = parse_raw_responses( + 64512, + whois_with(Some("RIPE"), vec![vec![("source", "RIPE")]]), + overview_with("AS64512 - Example Holder Inc. - Some description, GB"), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!(r.data.name, "AS64512"); + assert_eq!( + r.data.description_short, "Example Holder Inc. - Some description, GB", + "description joins remaining holder parts with separator", + ); + assert_eq!(r.data.country_code, Some("GB".to_string())); +} + +#[test] +fn ripestat_falls_back_to_as_name_then_aut_num() { + let cases = vec![ + (vec![("as-name", "FromAsName")], "FromAsName"), + (vec![("ASName", "FromASName")], "FromASName"), + (vec![("aut-num", "FromAutNum")], "FromAutNum"), + ]; + for (records, expected_name) in cases { + let r = parse_raw_responses( + 1, + whois_with(Some("RIPE"), vec![records]), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!(r.data.name, expected_name, "for {expected_name}"); + } +} + +#[test] +fn ripestat_description_falls_back_to_org_name_then_holder() { + let cases = vec![ + (vec![("descr", "FromDescr")], "FromDescr"), + (vec![("OrgName", "FromOrgName")], "FromOrgName"), + ]; + for (records, expected_desc) in cases { + let r = parse_raw_responses( + 1, + whois_with(Some("RIPE"), vec![records]), + overview_with("HolderName - HolderDesc"), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!( + r.data.description_short, expected_desc, + "for {expected_desc}" + ); + } + + let r = parse_raw_responses( + 1, + whois_with(Some("RIPE"), vec![vec![]]), + overview_with("HolderName - HolderDesc"), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!(r.data.description_short, "HolderDesc"); +} + +#[test] +fn ripestat_country_extracted_from_country_field() { + let r = parse_raw_responses( + 1, + whois_with(Some("RIPE"), vec![vec![("country", "NL")]]), + empty_overview(), + empty_peeringdb(), + ) + .expect("ok"); + assert_eq!(r.data.country_code, Some("NL".to_string())); +} diff --git a/backend/src/routes/auth/mod.rs b/backend/src/routes/auth/mod.rs index 2350c54..b250148 100644 --- a/backend/src/routes/auth/mod.rs +++ b/backend/src/routes/auth/mod.rs @@ -161,3 +161,77 @@ pub async fn rate_limit_middleware( Ok(next.run(req).await) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::make_test_state; + use axum::http::header; + use std::sync::Arc; + + fn with_pin(state: AppState, pin: &str) -> AppState { + let mut server_cfg = (*state.config.0).clone(); + server_cfg.pin = Some(pin.to_string()); + let mut new_state = state; + new_state.config = crate::config::AppConfig(Arc::new(server_cfg)); + new_state + } + + #[tokio::test] + async fn no_pin_configured_means_open_access() { + let state = make_test_state(); + let mut headers = HeaderMap::new(); + headers.insert(header::COOKIE, "TRACE_PIN=anything".parse().unwrap()); + assert!( + is_authenticated(&headers, &state).await, + "if no PIN is configured, every request is authenticated", + ); + } + + #[tokio::test] + async fn pin_required_without_any_credentials_is_denied() { + let state = with_pin(make_test_state(), "1234"); + let headers = HeaderMap::new(); + assert!(!is_authenticated(&headers, &state).await); + } + + #[tokio::test] + async fn cookie_for_active_session_is_accepted() { + let state = with_pin(make_test_state(), "1234"); + let session_id = "deadbeefcafebabe".to_string(); + state + .active_sessions + .write() + .await + .insert(session_id.clone()); + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + format!("TRACE_PIN={session_id}").parse().unwrap(), + ); + assert!(is_authenticated(&headers, &state).await); + } + + #[tokio::test] + async fn x_pin_header_matches_configured_pin() { + let state = with_pin(make_test_state(), "1234"); + let mut headers = HeaderMap::new(); + headers.insert("x-pin", "1234".parse().unwrap()); + assert!(is_authenticated(&headers, &state).await); + + let mut wrong = HeaderMap::new(); + wrong.insert("x-pin", "5678".parse().unwrap()); + assert!(!is_authenticated(&wrong, &state).await); + } + + #[tokio::test] + async fn cookie_present_but_session_unknown_is_denied() { + let state = with_pin(make_test_state(), "1234"); + let mut headers = HeaderMap::new(); + headers.insert(header::COOKIE, "TRACE_PIN=unknown-session".parse().unwrap()); + assert!( + !is_authenticated(&headers, &state).await, + "an unknown session cookie must not authenticate", + ); + } +} diff --git a/backend/src/routes/auth/verify_pin.rs b/backend/src/routes/auth/verify_pin.rs index dd6e3e8..178a368 100644 --- a/backend/src/routes/auth/verify_pin.rs +++ b/backend/src/routes/auth/verify_pin.rs @@ -72,11 +72,7 @@ pub async fn verify_pin( let expected_pin = match pin_req.as_ref() { Some(p) => p, None => { - return ( - StatusCode::OK, - Json(serde_json::json!({ "success": true })), - ) - .into_response(); + return (StatusCode::OK, Json(serde_json::json!({ "success": true }))).into_response(); } }; let pin_str = payload.pin.as_deref().unwrap_or("").trim(); @@ -148,3 +144,29 @@ pub async fn verify_pin( .into_response() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_id_is_32_hex_chars() { + let id = generate_session_id(); + assert_eq!( + id.len(), + 32, + "16 random bytes should encode to 32 hex chars" + ); + assert!( + id.chars().all(|c| c.is_ascii_hexdigit()), + "session id must be lowercase hex", + ); + } + + #[test] + fn session_ids_differ_between_calls() { + let a = generate_session_id(); + let b = generate_session_id(); + assert_ne!(a, b, "consecutive session ids must not collide"); + } +} diff --git a/backend/src/routes/lookup.rs b/backend/src/routes/lookup.rs index 839fa0b..c8a76ac 100644 --- a/backend/src/routes/lookup.rs +++ b/backend/src/routes/lookup.rs @@ -17,6 +17,16 @@ use std::time::Instant; static START_TIME: LazyLock = LazyLock::new(Instant::now); +pub fn unknown_query_response() -> (StatusCode, Json) { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Invalid input", + "message": "Please enter a valid domain name, IP address, or ASN number", + })), + ) +} + pub async fn serve_config(State(state): State) -> impl IntoResponse { Json(serde_json::json!({ "siteTitle": state.config.0.site_title, @@ -132,19 +142,92 @@ pub async fn handle_lookup( }); Json(serde_json::json!({ "type": "whois", "data": response_data })).into_response() } - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Error fetching WHOIS data", "message": e }))).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Error fetching WHOIS data", "message": e })), + ) + .into_response(), }, "ip" => match try_ip_lookup(&state.client, &state.upstream_limiter, &query).await { - Ok(ip_data) => Json(serde_json::json!({ "type": "ip", "data": ip_data })).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Error fetching IP data", "message": e }))).into_response(), + Ok(ip_data) => { + Json(serde_json::json!({ "type": "ip", "data": ip_data })).into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Error fetching IP data", "message": e })), + ) + .into_response(), }, "asn" => { let asn_number = query.to_uppercase().replace("AS", ""); match fetch_asn_data(&state.client, &state.upstream_limiter, &asn_number).await { - Ok(asn_data) => Json(serde_json::json!({ "type": "asn", "data": asn_data.data })).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Error fetching ASN data", "message": e }))).into_response(), + Ok(asn_data) => Json(serde_json::json!({ "type": "asn", "data": asn_data.data })) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Error fetching ASN data", "message": e })), + ) + .into_response(), } } - _ => (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Invalid input", "message": "Please enter a valid domain name, IP address, or ASN number" }))).into_response(), + _ => unknown_query_response().into_response(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::make_test_state; + use axum::body::to_bytes; + + #[tokio::test] + async fn unknown_query_yields_400_with_documented_payload() { + let state = make_test_state(); + let resp = handle_lookup(Path("foo".to_string()), State(state)) + .await + .into_response(); + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "unclassified query must yield 400", + ); + let body = to_bytes(resp.into_body(), 1024).await.expect("body"); + let v: serde_json::Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(v["error"], "Invalid input"); + assert!( + v["message"] + .as_str() + .expect("message") + .contains("valid domain name"), + "message must explain the validation requirement, got: {v}", + ); + } + + #[tokio::test] + async fn known_queries_do_not_yield_400_unknown_payload() { + let state = make_test_state(); + let resp = handle_lookup(Path("example.com".to_string()), State(state)) + .await + .into_response(); + assert_ne!( + resp.status(), + StatusCode::BAD_REQUEST, + "known query types must not return the unknown-validation response", + ); + } + + #[test] + fn classify_unknown_query_does_not_match_asn_ip_or_domain_rules() { + assert_eq!(detect_query_type("foobar"), "unknown"); + assert_eq!(detect_query_type("not:an:ip"), "unknown"); + } + + #[test] + fn unknown_query_response_shape_is_stable() { + let (status, json) = unknown_query_response(); + assert_eq!(status, StatusCode::BAD_REQUEST); + let v = json.0; + assert_eq!(v["error"], "Invalid input"); + assert!(v["message"].is_string()); } } diff --git a/backend/src/routes/whois/mod.rs b/backend/src/routes/whois/mod.rs index 307f371..a4f4675 100644 --- a/backend/src/routes/whois/mod.rs +++ b/backend/src/routes/whois/mod.rs @@ -1,5 +1,7 @@ pub mod parser; #[cfg(test)] +mod parser_tests; +#[cfg(test)] mod tests; use crate::dns::IpAddresses; diff --git a/backend/src/routes/whois/parser.rs b/backend/src/routes/whois/parser.rs index 17b22e0..efda94c 100644 --- a/backend/src/routes/whois/parser.rs +++ b/backend/src/routes/whois/parser.rs @@ -12,7 +12,7 @@ static RE_IPV6: LazyLock = LazyLock::new(|| { .unwrap_or_else(|_| regex::Regex::new("").unwrap_or_else(|_| unreachable!())) }); -pub fn extract_ips_from_raw(raw_data: &str) -> IpAddresses { +pub(crate) fn extract_ips_from_raw(raw_data: &str) -> IpAddresses { let mut v4 = Vec::new(); let mut v6 = Vec::new(); for line in raw_data.lines() { @@ -76,7 +76,7 @@ pub async fn parse_whois_data(raw_data: &str, domain: &str) -> ParsedWhoisData { result } -fn parse_eu_whois(raw_data: &str, result: &mut ParsedWhoisData) { +pub(crate) fn parse_eu_whois(raw_data: &str, result: &mut ParsedWhoisData) { let mut current_section = String::new(); for line in raw_data.lines() { let trimmed = line.trim(); @@ -122,7 +122,7 @@ fn parse_eu_whois(raw_data: &str, result: &mut ParsedWhoisData) { } } -fn parse_generic_whois(raw_data: &str, result: &mut ParsedWhoisData) { +pub(crate) fn parse_generic_whois(raw_data: &str, result: &mut ParsedWhoisData) { for line in raw_data.lines() { let parts: Vec<&str> = line.splitn(2, ':').map(|s| s.trim()).collect(); if parts.len() < 2 { diff --git a/backend/src/routes/whois/parser_tests.rs b/backend/src/routes/whois/parser_tests.rs new file mode 100644 index 0000000..f60bbe3 --- /dev/null +++ b/backend/src/routes/whois/parser_tests.rs @@ -0,0 +1,243 @@ +use super::ParsedWhoisData; +use super::parser::{extract_ips_from_raw, parse_eu_whois, parse_generic_whois}; + +fn blank_result() -> ParsedWhoisData { + ParsedWhoisData { + domain_name: String::new(), + registrar: String::new(), + creation_date: String::new(), + expiration_date: String::new(), + last_updated: String::new(), + status: Vec::new(), + nameservers: Vec::new(), + ip_addresses: crate::dns::IpAddresses::default(), + raw: String::new(), + } +} + +#[test] +fn generic_verisign_style_extracts_registrar_dates_and_nameservers() { + let raw = " Domain Name: EXAMPLE.COM + Registry Domain ID: 2336799_DOMAIN_COM-VRSN + Registrar WHOIS Server: whois.verisign-grs.com + Registrar URL: https://www.verisign.com + Updated Date: 2024-08-14T07:01:44Z + Creation Date: 1995-08-14T04:00:00Z + Registry Expiry Date: 2030-08-13T04:00:00Z + Registrar: Verisign + Domain Status: clientTransferProhibited + Domain Status: serverTransferProhibited + Name Server: NS1.EXAMPLE.COM + Name Server: NS2.EXAMPLE.COM + Name Server: NS3.EXAMPLE.COM +"; + + let mut r = blank_result(); + parse_generic_whois(raw, &mut r); + + assert_eq!(r.registrar, "Verisign"); + assert_eq!(r.creation_date, "1995-08-14T04:00:00Z"); + assert_eq!(r.expiration_date, "2030-08-13T04:00:00Z"); + assert_eq!(r.last_updated, "2024-08-14T07:01:44Z"); + assert_eq!( + r.status, + vec![ + "clientTransferProhibited".to_string(), + "serverTransferProhibited".to_string(), + ] + ); + assert_eq!( + r.nameservers, + vec![ + "NS1.EXAMPLE.COM".to_string(), + "NS2.EXAMPLE.COM".to_string(), + "NS3.EXAMPLE.COM".to_string(), + ] + ); +} + +#[test] +fn generic_whois_ignores_malformed_lines() { + let raw = "nocolon +:missing_value +missing: +Creation Date: 2000-01-01T00:00:00Z +Name Server: +"; + let mut r = blank_result(); + parse_generic_whois(raw, &mut r); + assert_eq!(r.creation_date, "2000-01-01T00:00:00Z"); + assert!(r.nameservers.is_empty(), "no value -> no blank nameserver"); +} + +#[test] +fn generic_whois_first_value_wins_for_each_date() { + let raw = "Creation Date: 2000-01-01T00:00:00Z +Updated Date: 2001-02-03T00:00:00Z +Registry Expiry Date: 2030-12-31T00:00:00Z +Creation Date: 2020-12-12T00:00:00Z +Updated Date: 2024-12-12T00:00:00Z +"; + + let mut r = blank_result(); + parse_generic_whois(raw, &mut r); + + assert_eq!(r.creation_date, "2000-01-01T00:00:00Z"); + assert_eq!(r.last_updated, "2001-02-03T00:00:00Z"); + assert_eq!(r.expiration_date, "2030-12-31T00:00:00Z"); +} + +#[test] +fn generic_whois_nameservers_comma_separated_keep_first_token() { + let raw = "Name Server: ns1.example.com, ns2.example.com, ns3.example.com +"; + + let mut r = blank_result(); + parse_generic_whois(raw, &mut r); + + assert_eq!(r.nameservers, vec!["ns1.example.com".to_string()]); +} + +#[test] +fn generic_whois_does_not_set_domain_name() { + let raw = "Domain Name: SHOULD-NOT-SET +Registrar: Example Registrar +"; + + let mut r = blank_result(); + parse_generic_whois(raw, &mut r); + + assert_eq!( + r.domain_name, "", + "parse_generic_whois never touches domain_name" + ); + assert_eq!(r.registrar, "Example Registrar"); +} + +#[test] +fn extract_ips_picks_ips_from_host_dns_lines_only() { + let raw = " descr: see 192.0.2.1 for context + remarks: https://example.com/info + host: 198.51.100.10 + host: 198.51.100.10 + dns: 2001:db8:0:0:0:0:0:1, 192.0.2.5 +"; + + let ips = extract_ips_from_raw(raw); + + assert_eq!( + ips.v4, + vec!["198.51.100.10".to_string(), "192.0.2.5".to_string()], + "should dedupe v4 entries", + ); + assert_eq!(ips.v6, vec!["2001:db8:0:0:0:0:0:1".to_string()]); +} + +#[test] +fn extract_ips_ignores_ips_in_unrelated_lines() { + let raw = " descr: see 192.0.2.99 for context + remarks: https://192.0.2.99/info +"; + + let ips = extract_ips_from_raw(raw); + assert!( + ips.v4.is_empty(), + "should not pick up IPs from descr/remarks lines" + ); +} + +#[test] +fn extract_ips_strips_cidr_suffix_for_v4() { + let raw = "host: 198.51.100.0/24 +"; + let ips = extract_ips_from_raw(raw); + assert_eq!(ips.v4, vec!["198.51.100.0".to_string()]); +} + +#[test] +fn eu_whois_extracts_registrar_nameservers_and_default_status() { + let raw = "% EURid WHOIS + +Domain: + Name: EXAMPLE.EU + +Registrar: + Name: Some Registrar BV + Website: https://www.example.com + +Name servers: + ns1.example.eu + ns2.example.eu + +Technical: + Organisation: Some Tech Org +"; + + let mut r = blank_result(); + parse_eu_whois(raw, &mut r); + + assert_eq!(r.registrar, "Some Registrar BV"); + assert_eq!( + r.nameservers, + vec!["ns1.example.eu".to_string(), "ns2.example.eu".to_string()], + ); + assert_eq!(r.status, vec!["registered".to_string()]); +} + +#[test] +fn eu_whois_falls_back_to_technical_organisation_when_no_registrar_section() { + let raw = "% EURid WHOIS + +Name servers: + ns1.example.eu + +Technical: + Organisation: Some Tech Org +"; + + let mut r = blank_result(); + parse_eu_whois(raw, &mut r); + + assert_eq!(r.registrar, "Some Tech Org"); + assert_eq!(r.nameservers, vec!["ns1.example.eu".to_string()]); +} + +#[test] +fn eu_whois_does_not_overwrite_existing_registrar_with_technical() { + let raw = "Registrar: + Name: First Registrar +Technical: + Organisation: Fallback Org +"; + + let mut r = blank_result(); + parse_eu_whois(raw, &mut r); + + assert_eq!(r.registrar, "First Registrar"); +} + +#[test] +fn eu_whois_ignores_eurid_promo_line() { + let raw = "Name servers: + Please visit www.eurid.eu for more info. + ns1.example.eu +"; + + let mut r = blank_result(); + parse_eu_whois(raw, &mut r); + + assert_eq!(r.nameservers, vec!["ns1.example.eu".to_string()]); +} + +#[test] +fn eu_whois_missing_data_leaves_status_default() { + let raw = "% EURid WHOIS + +Registrar: + Name: Lonely Registrar +"; + let mut r = blank_result(); + parse_eu_whois(raw, &mut r); + assert_eq!(r.registrar, "Lonely Registrar"); + assert_eq!(r.status, vec!["registered".to_string()]); +} diff --git a/backend/src/services/query.rs b/backend/src/services/query.rs index fa025dd..d65ff27 100644 --- a/backend/src/services/query.rs +++ b/backend/src/services/query.rs @@ -39,3 +39,103 @@ pub fn detect_query_type(query: &str) -> &'static str { "unknown" } + +#[cfg(test)] +mod tests { + use super::detect_query_type; + + #[test] + fn asn_plain_digits() { + assert_eq!(detect_query_type("15169"), "asn"); + assert_eq!(detect_query_type("1"), "asn"); + assert_eq!(detect_query_type("00001"), "asn"); + } + + #[test] + fn asn_with_as_prefix_any_case() { + assert_eq!(detect_query_type("AS15169"), "asn"); + assert_eq!(detect_query_type("as15169"), "asn"); + assert_eq!(detect_query_type("As15169"), "asn"); + assert_eq!(detect_query_type("aS15169"), "asn"); + } + + #[test] + fn asn_with_inner_separator_is_not_asn() { + assert_eq!(detect_query_type("AS-15169"), "unknown"); + assert_eq!(detect_query_type("AS 15169"), "unknown"); + assert_eq!(detect_query_type("15169extra"), "unknown"); + assert_eq!(detect_query_type("AS"), "unknown"); + } + + #[test] + fn ipv4_plain_classifies_as_ip() { + assert_eq!(detect_query_type("8.8.8.8"), "ip"); + assert_eq!(detect_query_type("1.1.1.1"), "ip"); + assert_eq!(detect_query_type("192.168.1.1"), "ip"); + assert_eq!(detect_query_type("01.02.03.04"), "ip"); + } + + #[test] + fn ipv4_with_cidr_classifies_as_ip() { + assert_eq!(detect_query_type("8.8.8.0/24"), "ip"); + assert_eq!(detect_query_type("10.0.0.0/8"), "ip"); + assert_eq!(detect_query_type("192.168.1.0/32"), "ip"); + } + + #[test] + fn ipv4_in_brackets_classifies_as_ip() { + assert_eq!(detect_query_type("[8.8.8.8]"), "ip"); + assert_eq!(detect_query_type("[8.8.8.8]/24"), "ip"); + } + + #[test] + fn ipv4_out_of_range_or_short_falls_through_to_whois() { + assert_eq!(detect_query_type("256.1.1.1"), "whois"); + assert_eq!(detect_query_type("8.8.8"), "whois"); + } + + #[test] + fn ipv6_plain_classifies_as_ip() { + assert_eq!(detect_query_type("::1"), "ip"); + assert_eq!(detect_query_type("fe80::1"), "ip"); + assert_eq!(detect_query_type("2001:4860:4860::8888"), "ip"); + assert_eq!(detect_query_type("2001:db8::1"), "ip"); + } + + #[test] + fn ipv6_in_brackets_and_cidr_classifies_as_ip() { + assert_eq!(detect_query_type("[2001:4860::1]"), "ip"); + assert_eq!(detect_query_type("[2001:4860::1]/64"), "ip"); + assert_eq!(detect_query_type("2001:4860::1/64"), "ip"); + } + + #[test] + fn domain_classifies_as_whois() { + assert_eq!(detect_query_type("example.com"), "whois"); + assert_eq!(detect_query_type("sub.example.co.uk"), "whois"); + assert_eq!(detect_query_type("a.b"), "whois"); + } + + #[test] + fn host_with_only_a_dot_falls_through_to_whois() { + assert_eq!(detect_query_type("."), "whois"); + } + + #[test] + fn bare_word_without_dot_or_colon_is_unknown() { + assert_eq!(detect_query_type("foobar"), "unknown"); + assert_eq!(detect_query_type(""), "unknown"); + } + + #[test] + fn label_like_pattern_is_unknown() { + assert_eq!(detect_query_type("-not-a-domain"), "unknown"); + } + + #[test] + fn invalid_ipv6_is_unknown_not_whois() { + assert_eq!(detect_query_type("not:an:ip"), "unknown"); + assert_eq!(detect_query_type(":"), "unknown"); + assert_eq!(detect_query_type("1:2:3:4:5:6:7:8:9"), "unknown"); + } +} diff --git a/backend/src/state.rs b/backend/src/state.rs index 5816bb0..e486542 100644 --- a/backend/src/state.rs +++ b/backend/src/state.rs @@ -73,3 +73,107 @@ impl AppState { }); } } + +#[cfg(test)] +pub(crate) fn make_test_state() -> AppState { + use shared_backend::server::ServerConfig; + + let server_config = ServerConfig { + port: 4404, + site_title: "Trace".to_string(), + base_url: "http://localhost:4404".to_string(), + allowed_origins: String::new(), + pin: None, + enable_translation: false, + enable_themes: true, + enable_print: true, + show_version: true, + show_github: true, + trust_proxy: false, + trusted_proxies: Vec::new(), + max_attempts: 5, + lockout_time_minutes: 15, + cookie_max_age_hours: 24, + shutdown_drain_seconds: 5, + }; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(1)) + .build() + .expect("reqwest client"); + let limiter = Arc::new(UpstreamRateLimiter::new()); + AppState::new(AppConfig(Arc::new(server_config)), client, limiter) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + fn fresh_state() -> AppState { + make_test_state() + } + + #[tokio::test] + async fn rate_limit_admits_first_n_then_denies() { + let state = fresh_state(); + let ip = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)); + let window = Duration::from_secs(60); + for i in 0..5 { + assert!( + state.check_rate_limit(ip, 5, window).await, + "request {i} within budget must succeed", + ); + } + let sixth = state.check_rate_limit(ip, 5, window).await; + assert!(!sixth, "sixth request must be denied"); + } + + #[tokio::test] + async fn rate_limit_is_per_ip() { + let state = fresh_state(); + let window = Duration::from_secs(60); + let ip_a = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)); + let ip_b = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)); + for _ in 0..5 { + assert!(state.check_rate_limit(ip_a, 5, window).await); + } + assert!( + !state.check_rate_limit(ip_a, 5, window).await, + "ip_a is exhausted", + ); + assert!( + state.check_rate_limit(ip_b, 5, window).await, + "ip_b still has its full budget", + ); + } + + #[tokio::test] + async fn rate_limit_window_releases_after_expiry() { + let state = fresh_state(); + let ip = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 5)); + // Pin `now` near-zero so all timestamps fit. Use a tiny window + // (1 ms) and a sleep longer than the window so every prior + // timestamp drops out on the next retain. + let window = Duration::from_millis(1); + assert!(state.check_rate_limit(ip, 5, window).await); + assert!(state.check_rate_limit(ip, 5, window).await); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!( + state.check_rate_limit(ip, 5, window).await, + "stale entries must be evicted by the sliding window", + ); + } + + #[tokio::test] + async fn clean_old_rate_limits_drops_empty_entries() { + let state = fresh_state(); + let ip = IpAddr::V4(Ipv4Addr::UNSPECIFIED); + assert!(state.check_rate_limit(ip, 5, Duration::from_secs(60)).await); + let window = Duration::from_millis(1); + tokio::time::sleep(Duration::from_millis(20)).await; + state.clean_old_rate_limits(window).await; + let map = state.rate_limiter.read().await; + assert!(map.is_empty(), "stale entries should be evicted"); + } +} diff --git a/frontend/src/main.rs b/frontend/src/main.rs index 1d3fdc8..fbc458f 100644 --- a/frontend/src/main.rs +++ b/frontend/src/main.rs @@ -7,6 +7,9 @@ mod i18n; mod types; mod whois_and_navigation_helpers; +#[cfg(test)] +mod types_tests; + fn main() { yew::Renderer::::new().render(); } diff --git a/frontend/src/types_tests.rs b/frontend/src/types_tests.rs new file mode 100644 index 0000000..4dff8cf --- /dev/null +++ b/frontend/src/types_tests.rs @@ -0,0 +1,203 @@ +use crate::types::*; + +#[test] +fn lookup_response_whois_round_trips_via_camel_case_payload() { + let json = r#"{ + "type": "whois", + "data": { + "ldhName": "example.com", + "handle": "EXAMPLE.COM", + "status": ["clientTransferProhibited"], + "ipAddresses": { + "v4": ["192.0.2.1", "198.51.100.1"], + "v6": ["2001:db8::1"] + }, + "events": [ + { "eventAction": "registration", "eventDate": "1995-08-14T04:00:00Z" }, + { "eventAction": "expiration", "eventDate": "2030-08-13T04:00:00Z" }, + { "eventAction": "lastChanged", "eventDate": "2024-08-14T07:01:44Z" } + ], + "nameservers": [ + { "ldhName": "NS1.EXAMPLE.COM" }, + { "ldhName": "NS2.EXAMPLE.COM" } + ], + "entities": [ + { + "roles": ["registrar"], + "vcardArray": [ + "vcard", + [ + ["version", {}, "text", "4.0"], + ["fn", {}, "text", "Example Registrar"], + ["email", {}, "text", ""] + ] + ] + } + ] + } + }"#; + + let parsed: LookupResponse = serde_json::from_str(json).expect("whois lookup parses"); + match &parsed { + LookupResponse::Whois(d) => { + assert_eq!(d.ldh_name, "example.com"); + assert_eq!(d.handle, "EXAMPLE.COM"); + assert_eq!(d.status, vec!["clientTransferProhibited".to_string()]); + assert_eq!( + d.ip_addresses.v4, + vec!["192.0.2.1".to_string(), "198.51.100.1".to_string()], + ); + assert_eq!(d.events.len(), 3); + assert_eq!(d.events[0].event_action, "registration"); + assert_eq!(d.nameservers.len(), 2); + assert_eq!(d.nameservers[0].ldh_name, "NS1.EXAMPLE.COM"); + assert_eq!(d.entities.len(), 1); + assert_eq!(d.entities[0].roles, vec!["registrar".to_string()]); + } + other => panic!("expected Whois variant, got {other:?}"), + } + + let reser = serde_json::to_string(&parsed).expect("serialize"); + let re_parsed: LookupResponse = serde_json::from_str(&reser).expect("round-trip parse"); + assert_eq!(parsed, re_parsed); +} + +#[test] +fn lookup_response_ip_round_trips() { + let json = r#"{ + "type": "ip", + "data": { + "ip": "8.8.8.8", + "version": "IPv4", + "city": "Mountain View", + "region": "California", + "region_code": "CA", + "country_code": "US", + "country_name": "United States", + "postal": "94043", + "latitude": 37.4192, + "longitude": -122.0574, + "timezone": "America/Los_Angeles", + "org": "Google LLC", + "asn": "AS15169", + "source": "ipapi.co", + "network": "8.8.8.0/24", + "continent_code": "NA", + "languages": "en", + "currency": "USD", + "currency_name": "US Dollar", + "country_calling_code": "+1" + } + }"#; + + let parsed: LookupResponse = serde_json::from_str(json).expect("ip lookup parses"); + match &parsed { + LookupResponse::Ip(d) => { + assert_eq!(d.ip, "8.8.8.8"); + assert_eq!(d.version, "IPv4"); + assert_eq!(d.city.as_deref(), Some("Mountain View")); + assert_eq!(d.latitude, Some(37.4192)); + assert_eq!(d.asn.as_deref(), Some("AS15169")); + assert_eq!(d.source, "ipapi.co"); + assert_eq!(d.continent_code.as_deref(), Some("NA")); + assert_eq!(d.country_calling_code.as_deref(), Some("+1")); + } + other => panic!("expected Ip variant, got {other:?}"), + } + + let reser = serde_json::to_string(&parsed).expect("serialize"); + let re_parsed: LookupResponse = serde_json::from_str(&reser).expect("round-trip parse"); + assert_eq!(parsed, re_parsed); +} + +#[test] +fn lookup_response_ip_minimal_payload_defaults_missing_optional_fields() { + let json = r#"{ + "type": "ip", + "data": { + "ip": "1.1.1.1", + "version": "IPv4", + "city": null, + "region": null, + "region_code": null, + "country_code": null, + "country_name": null, + "postal": null, + "latitude": null, + "longitude": null, + "timezone": null, + "org": null, + "asn": null, + "source": "ipapi.co" + } + }"#; + + let parsed: LookupResponse = serde_json::from_str(json).expect("minimal ip parses"); + match parsed { + LookupResponse::Ip(d) => { + assert_eq!(d.ip, "1.1.1.1"); + assert!(d.network.is_none()); + assert!(d.continent_code.is_none()); + assert!(d.languages.is_none()); + assert!(d.currency.is_none()); + assert!(d.currency_name.is_none()); + assert!(d.country_calling_code.is_none()); + } + other => panic!("expected Ip variant, got {other:?}"), + } +} + +#[test] +fn lookup_response_asn_round_trips() { + let json = r#"{ + "type": "asn", + "data": { + "asn": 15169, + "name": "Google LLC", + "description_short": "Google LLC", + "country_code": "US", + "website": "https://www.google.com", + "email_contacts": ["dns-admin@google.com"], + "abuse_contacts": ["network-abuse@google.com"], + "owner_address": ["1600 Amphitheatre Parkway", "Mountain View, CA"], + "rir_allocation": { + "rir_name": "ARIN", + "date_allocated": "1997-03-31" + }, + "traffic_ratio": "Most of Google", + "date_updated": "2024-01-15" + } + }"#; + + let parsed: LookupResponse = serde_json::from_str(json).expect("asn lookup parses"); + match &parsed { + LookupResponse::Asn(d) => { + assert_eq!(d.asn, 15169); + assert_eq!(d.name, "Google LLC"); + assert_eq!(d.country_code.as_deref(), Some("US")); + assert_eq!(d.email_contacts, vec!["dns-admin@google.com".to_string()]); + assert_eq!( + d.abuse_contacts, + vec!["network-abuse@google.com".to_string()], + ); + assert_eq!(d.rir_allocation.rir_name, "ARIN"); + assert_eq!( + d.rir_allocation.date_allocated.as_deref(), + Some("1997-03-31"), + ); + assert_eq!(d.traffic_ratio.as_deref(), Some("Most of Google")); + } + other => panic!("expected Asn variant, got {other:?}"), + } + + let reser = serde_json::to_string(&parsed).expect("serialize"); + let re_parsed: LookupResponse = serde_json::from_str(&reser).expect("round-trip parse"); + assert_eq!(parsed, re_parsed); +} + +#[test] +fn lookup_response_rejects_unknown_type_tag() { + let json = r#"{ "type": "bogus", "data": {} }"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err(), "unknown tag must not deserialize"); +} diff --git a/frontend/src/whois_and_navigation_helpers.rs b/frontend/src/whois_and_navigation_helpers.rs index 898aa52..1aaa745 100644 --- a/frontend/src/whois_and_navigation_helpers.rs +++ b/frontend/src/whois_and_navigation_helpers.rs @@ -48,3 +48,107 @@ pub fn scroll_to_element(id: &str) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn registrar_entity(vcard_array: serde_json::Value) -> WhoisEntity { + WhoisEntity { + roles: vec!["registrar".to_string()], + vcard_array, + } + } + + #[test] + fn format_date_rfc3339_with_timezone() { + assert_eq!( + format_date("2024-01-15T10:00:00+00:00"), + "2024-01-15 10:00:00 UTC", + ); + assert_eq!( + format_date("2024-08-14T07:01:44Z"), + "2024-08-14 07:01:44 UTC", + ); + } + + #[test] + fn format_date_simple_z_form() { + assert_eq!( + format_date("2024-01-15T10:00:00.123Z"), + "2024-01-15 10:00:00 UTC", + ); + } + + #[test] + fn format_date_replaces_t_and_z_when_unparseable() { + assert_eq!(format_date("2024-01-15T10:00:00"), "2024-01-15 10:00:00"); + assert_eq!(format_date("2024-01-15"), "2024-01-15"); + } + + #[test] + fn format_date_passthrough_when_no_recognized_date() { + assert_eq!(format_date("not-a-date"), "not-a-date"); + assert_eq!(format_date(""), ""); + } + + #[test] + fn registrar_fn_returns_fn_field_value() { + let entity = registrar_entity(json!([ + "vcard", + [ + ["version", {}, "text", "4.0"], + ["fn", {}, "text", "Example Registrar"], + ["email", {}, "text", ""] + ] + ])); + assert_eq!( + get_registrar_fn(&entity), + Some("Example Registrar".to_string()) + ); + } + + #[test] + fn registrar_fn_returns_first_fn_only() { + let entity = registrar_entity(json!([ + "vcard", + [ + ["fn", {}, "text", "First Registrar"], + ["fn", {}, "text", "Second Registrar"] + ] + ])); + assert_eq!( + get_registrar_fn(&entity), + Some("First Registrar".to_string()), + "stops at the first fn entry", + ); + } + + #[test] + fn registrar_fn_returns_none_when_fn_missing() { + let entity = registrar_entity(json!([ + "vcard", + [ + ["version", {}, "text", "4.0"], + ["email", {}, "text", "ops@example.com"] + ] + ])); + assert_eq!(get_registrar_fn(&entity), None); + } + + #[test] + fn registrar_fn_returns_none_for_empty_inner_array() { + let entity = registrar_entity(json!(["vcard", []])); + assert_eq!(get_registrar_fn(&entity), None); + } + + #[test] + fn registrar_fn_handles_non_object_vcard_array() { + let entity = registrar_entity(json!("not-a-vcard")); + assert_eq!(get_registrar_fn(&entity), None); + + let entity = registrar_entity(json!(null)); + assert_eq!(get_registrar_fn(&entity), None); + } +}