Skip to main content

netspeed_cli/
http.rs

1use crate::common;
2use crate::error::Error;
3use crate::test_config::TestConfig;
4use reqwest::Client;
5use rustls::client::WebPkiServerVerifier;
6use rustls::client::danger::ServerCertVerifier;
7use rustls::crypto::CryptoProvider;
8use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
9use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme};
10use std::sync::Arc;
11
12/// TLS certificate configuration options.
13#[derive(Debug, Clone, Default)]
14pub struct TlsConfig {
15    /// Path to a custom CA certificate file (PEM format).
16    pub ca_cert_path: Option<std::path::PathBuf>,
17    /// Minimum TLS version to use (e.g., "1.2", "1.3").
18    pub min_tls_version: Option<String>,
19    /// Restrict TLS connections to speedtest.net and ookla.com domains.
20    pub pin_speedtest_certs: bool,
21}
22
23impl TlsConfig {
24    /// Set a custom CA certificate file for TLS verification.
25    #[must_use]
26    pub fn with_ca_cert(mut self, path: std::path::PathBuf) -> Self {
27        self.ca_cert_path = Some(path);
28        self
29    }
30
31    /// Set minimum TLS version.
32    #[must_use]
33    pub fn with_min_tls_version(mut self, version: impl Into<String>) -> Self {
34        self.min_tls_version = Some(version.into());
35        self
36    }
37
38    /// Enable speedtest.net/ookla.com TLS domain restriction.
39    #[must_use]
40    pub fn with_cert_pinning(mut self) -> Self {
41        self.pin_speedtest_certs = true;
42        self
43    }
44}
45
46/// Default browser-like user agent for speedtest.net compatibility.
47/// Can be overridden via config file with custom_user_agent option.
48pub const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
49
50/// HTTP client settings - decoupled from Config struct.
51///
52/// This allows creating HTTP clients without depending on the full Config,
53/// improving modularity and testability.
54#[derive(Debug, Clone)]
55pub struct Settings {
56    /// Timeout in seconds for HTTP requests.
57    pub timeout_secs: u64,
58    /// Optional source IP address to bind to.
59    pub source_ip: Option<String>,
60    /// User agent string for HTTP requests.
61    pub user_agent: String,
62    /// Enable automatic retry on transient failures.
63    pub retry_enabled: bool,
64    /// TLS certificate configuration.
65    pub tls: TlsConfig,
66}
67
68/// Build [`Settings`] from a [`crate::config::Config`] reference.
69///
70/// Centralizes the Config→HTTP bridging so callers don't duplicate
71/// the mapping. Resolves custom_user_agent from file config or default.
72///
73/// This impl lives in `http.rs` (not `config.rs`) to preserve layering:
74/// dependency flows http → config, not config → http.
75impl From<&crate::config::Config> for Settings {
76    fn from(config: &crate::config::Config) -> Self {
77        Self {
78            timeout_secs: config.timeout(),
79            source_ip: config.source().map(String::from),
80            user_agent: config
81                .custom_user_agent()
82                .map(String::from)
83                .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string()),
84            retry_enabled: true,
85            tls: TlsConfig {
86                ca_cert_path: config.ca_cert_path(),
87                min_tls_version: config.tls_version().map(String::from),
88                pin_speedtest_certs: config.pin_certs(),
89            },
90        }
91    }
92}
93
94impl Default for Settings {
95    fn default() -> Self {
96        Self {
97            timeout_secs: 10,
98            source_ip: None,
99            user_agent: DEFAULT_USER_AGENT.to_string(),
100            retry_enabled: true,
101            tls: TlsConfig::default(),
102        }
103    }
104}
105
106impl Settings {
107    /// Set a custom user agent (e.g., from config file).
108    #[must_use]
109    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
110        self.user_agent = user_agent.into();
111        self
112    }
113
114    /// Disable retry logic (useful for tests or when caller handles retries).
115    #[must_use]
116    pub fn with_retry_disabled(mut self) -> Self {
117        self.retry_enabled = false;
118        self
119    }
120}
121
122/// Create an HTTP client with the given settings.
123///
124/// # Errors
125///
126/// Returns [`Error::Context`] if the source IP is invalid.
127/// Returns [`Error::NetworkError`] if the client fails to build.
128pub fn create_client(settings: &Settings) -> Result<Client, Error> {
129    let mut builder = Client::builder()
130        .timeout(std::time::Duration::from_secs(settings.timeout_secs))
131        .http1_only()
132        .no_gzip()
133        .use_rustls_tls()
134        .user_agent(&settings.user_agent);
135
136    if let Some(ref source_ip) = settings.source_ip {
137        let addr: std::net::SocketAddr = source_ip
138            .parse()
139            .map_err(|e| Error::with_source("Invalid source IP", e))?;
140        builder = builder.local_address(addr.ip());
141    }
142
143    // Apply TLS configuration if any options are set
144    if settings.tls.ca_cert_path.is_some()
145        || settings.tls.min_tls_version.is_some()
146        || settings.tls.pin_speedtest_certs
147    {
148        let tls_config = build_tls_config(&settings.tls)?;
149        builder = builder.use_preconfigured_tls(tls_config);
150    }
151
152    let client = builder.build().map_err(Error::NetworkError)?;
153
154    Ok(client)
155}
156
157/// Build a rustls client configuration based on the TLS settings.
158fn build_tls_config(tls: &TlsConfig) -> Result<ClientConfig, Error> {
159    // Determine protocol versions based on min_tls_version setting
160    let versions: &[&rustls::SupportedProtocolVersion] = match tls.min_tls_version.as_deref() {
161        Some("1.2") => &[&rustls::version::TLS12],
162        Some("1.3") => &[&rustls::version::TLS13],
163        Some(v) => {
164            eprintln!("Warning: Unknown TLS version '{}', using defaults", v);
165            rustls::DEFAULT_VERSIONS
166        }
167        None => rustls::DEFAULT_VERSIONS,
168    };
169
170    if tls.pin_speedtest_certs && tls.ca_cert_path.is_some() {
171        eprintln!(
172            "Warning: Both --ca-cert and --pin-certs are set. Custom CA verification will be used before the speedtest.net domain restriction."
173        );
174    }
175
176    let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
177    let builder = ClientConfig::builder_with_provider(Arc::clone(&provider))
178        .with_protocol_versions(versions)
179        .map_err(|e| Error::context(format!("Invalid TLS configuration: {e}")))?;
180
181    let root_store = match tls.ca_cert_path.as_deref() {
182        Some(ca_path) => load_custom_ca_cert(ca_path)?,
183        None => default_root_store(),
184    };
185
186    if tls.pin_speedtest_certs {
187        let verifier = PinningVerifier::try_new(root_store, provider)?;
188        return Ok(builder
189            .dangerous()
190            .with_custom_certificate_verifier(Arc::new(verifier))
191            .with_no_client_auth());
192    }
193
194    Ok(builder
195        .with_root_certificates(root_store)
196        .with_no_client_auth())
197}
198
199fn default_root_store() -> RootCertStore {
200    let mut root_store = RootCertStore::empty();
201    root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
202    root_store
203}
204
205/// Load a custom CA certificate from a PEM or DER file.
206fn load_custom_ca_cert(path: &std::path::Path) -> Result<RootCertStore, Error> {
207    let pem_data = std::fs::read(path)
208        .map_err(|e| Error::context(format!("Failed to read CA cert: {}", e)))?;
209
210    let mut store = RootCertStore::empty();
211
212    // Try PEM first (returns iterator in newer versions)
213    let mut cursor = std::io::Cursor::new(&pem_data);
214    let mut found_cert = false;
215    for cert_result in rustls_pemfile::certs(&mut cursor) {
216        match cert_result {
217            Ok(cert) => {
218                store
219                    .add(cert)
220                    .map_err(|e| Error::context(format!("Failed to add cert: {}", e)))?;
221                found_cert = true;
222            }
223            Err(e) => {
224                eprintln!("Warning: Failed to parse PEM cert: {}", e);
225            }
226        }
227    }
228
229    // If no PEM certs found, try DER format
230    if !found_cert {
231        store
232            .add(CertificateDer::from(pem_data))
233            .map_err(|e| Error::context(format!("Failed to parse cert: {}", e)))?;
234    }
235
236    Ok(store)
237}
238
239/// Certificate verifier that restricts TLS to speedtest.net and ookla.com domains
240/// while preserving normal webpki chain, hostname, and validity checks.
241#[derive(Debug)]
242struct PinningVerifier {
243    inner: Arc<WebPkiServerVerifier>,
244}
245
246impl PinningVerifier {
247    #[cfg(test)]
248    fn new() -> Self {
249        Self::try_new(
250            default_root_store(),
251            Arc::new(rustls::crypto::aws_lc_rs::default_provider()),
252        )
253        .expect("default TLS verifier should build")
254    }
255
256    fn try_new(root_store: RootCertStore, provider: Arc<CryptoProvider>) -> Result<Self, Error> {
257        let inner = WebPkiServerVerifier::builder_with_provider(Arc::new(root_store), provider)
258            .build()
259            .map_err(|e| Error::context(format!("Failed to build TLS verifier: {e:?}")))?;
260        Ok(Self { inner })
261    }
262
263    fn is_valid_domain(host: &str) -> bool {
264        // Check exact domains first, then subdomains ending with the suffix.
265        host == "speedtest.net"
266            || host == "ookla.com"
267            || host.ends_with(".speedtest.net")
268            || host.ends_with(".ookla.com")
269    }
270}
271
272impl ServerCertVerifier for PinningVerifier {
273    fn verify_server_cert(
274        &self,
275        end_entity: &CertificateDer<'_>,
276        _intermediate_certs: &[CertificateDer<'_>],
277        server_name: &ServerName<'_>,
278        _ocsp_response: &[u8],
279        _now: UnixTime,
280    ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
281        // Extract hostname from server name
282        let hostname = match server_name {
283            ServerName::DnsName(name) => name.as_ref(),
284            _ => {
285                return Err(rustls::Error::General(
286                    "Unsupported server name type".to_string(),
287                ));
288            }
289        };
290
291        if !Self::is_valid_domain(hostname) {
292            return Err(rustls::Error::General(format!(
293                "'{}' is not a speedtest.net domain",
294                hostname
295            )));
296        }
297
298        self.inner.verify_server_cert(
299            end_entity,
300            _intermediate_certs,
301            server_name,
302            _ocsp_response,
303            _now,
304        )
305    }
306
307    fn verify_tls12_signature(
308        &self,
309        message: &[u8],
310        cert: &CertificateDer<'_>,
311        dss: &DigitallySignedStruct,
312    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
313        self.inner.verify_tls12_signature(message, cert, dss)
314    }
315
316    fn verify_tls13_signature(
317        &self,
318        message: &[u8],
319        cert: &CertificateDer<'_>,
320        dss: &DigitallySignedStruct,
321    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
322        self.inner.verify_tls13_signature(message, cert, dss)
323    }
324
325    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
326        self.inner.supported_verify_schemes()
327    }
328}
329
330/// Represents a transient HTTP error that may benefit from retry.
331fn is_transient_error(e: &reqwest::Error) -> bool {
332    if e.is_timeout() {
333        return true;
334    }
335    if e.is_connect() {
336        return true;
337    }
338    // Server errors (5xx) are transient
339    if let Some(status) = e.status() {
340        return status.as_u16() >= 500;
341    }
342    false
343}
344
345/// Execute an HTTP request with automatic retry on transient failures.
346///
347/// This function wraps a request closure with exponential backoff retry logic.
348/// It will retry on timeouts, connection errors, and 5xx server errors.
349///
350/// # Arguments
351///
352/// * `request` - Closure that creates and executes the request
353///
354/// # Errors
355///
356/// Returns the final error after all retry attempts are exhausted.
357pub async fn with_retry<R, F, Fut>(mut request: F) -> Result<R, Error>
358where
359    F: FnMut() -> Fut,
360    Fut: std::future::Future<Output = Result<R, reqwest::Error>>,
361{
362    let config = TestConfig::default();
363    let max_attempts = config.http_retry_attempts;
364
365    for attempt in 0..max_attempts {
366        let result = request().await;
367
368        if let Ok(r) = result {
369            return Ok(r);
370        }
371
372        // Get the error reference (we can't clone reqwest::Error)
373        if let Err(e) = &result {
374            let (delay, should_retry) = TestConfig::retry_delay(attempt);
375
376            // Check if error is transient and we should retry
377            #[allow(clippy::collapsible_if)]
378            if should_retry && is_transient_error(e) && attempt < max_attempts - 1 {
379                tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
380                continue;
381            }
382
383            // Non-transient error or exhausted retries - return the error
384            return result.map_err(Error::NetworkError);
385        }
386    }
387
388    // This should not be reached, but handle it defensively
389    Err(Error::context("retry loop ended without result or error"))
390}
391
392/// Discover the client's public IP address via speedtest.net.
393///
394/// # Errors
395///
396/// Returns [`Error::NetworkError`] if all IP discovery endpoints fail.
397pub async fn discover_client_ip(client: &Client) -> Result<String, Error> {
398    if let Ok(response) = client
399        .get("https://www.speedtest.net/api/ip.php")
400        .send()
401        .await
402    {
403        if let Ok(text) = response.text().await {
404            let trimmed = text.trim().to_string();
405            if common::is_valid_ipv4(&trimmed) {
406                return Ok(trimmed);
407            }
408        }
409    }
410
411    if let Ok(response) = client
412        .get("https://www.speedtest.net/api/ios-config.php")
413        .send()
414        .await
415    {
416        if let Ok(text) = response.text().await {
417            if let Some(ip) = parse_ip_from_xml(&text) {
418                return Ok(ip);
419            }
420        }
421    }
422
423    Ok("unknown".to_string())
424}
425
426fn parse_ip_from_xml(xml: &str) -> Option<String> {
427    // Use structured XML deserialization instead of manual string scanning
428    // to handle edge cases (comments, CDATA, nested elements) correctly.
429    #[derive(serde::Deserialize)]
430    struct Settings {
431        client: ClientElement,
432    }
433    #[derive(serde::Deserialize)]
434    struct ClientElement {
435        #[serde(rename = "@ip")]
436        ip: Option<String>,
437    }
438
439    // XML parse failures are expected (malformed responses, unexpected structure)
440    // and are not actionable — the caller falls back to returning "unknown".
441    let settings: Settings = match quick_xml::de::from_str(xml) {
442        Ok(s) => s,
443        Err(_) => return None,
444    };
445    let ip = settings.client.ip?;
446    if common::is_valid_ipv4(&ip) {
447        Some(ip)
448    } else {
449        None
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use std::sync::Arc;
457    use std::sync::atomic::{AtomicUsize, Ordering};
458
459    // ==================== TlsConfig Builder Method Tests ====================
460
461    #[test]
462    fn test_tls_config_with_ca_cert() {
463        let config = TlsConfig::default();
464        assert!(config.ca_cert_path.is_none());
465
466        let config = config.with_ca_cert(std::path::PathBuf::from("/path/to/cert.pem"));
467        assert_eq!(
468            config.ca_cert_path,
469            Some(std::path::PathBuf::from("/path/to/cert.pem"))
470        );
471    }
472
473    #[test]
474    fn test_tls_config_with_min_tls_version() {
475        let config = TlsConfig::default();
476        assert!(config.min_tls_version.is_none());
477
478        let config = config.with_min_tls_version("1.2");
479        assert_eq!(config.min_tls_version, Some("1.2".to_string()));
480
481        let config = TlsConfig::default().with_min_tls_version("1.3");
482        assert_eq!(config.min_tls_version, Some("1.3".to_string()));
483    }
484
485    #[test]
486    fn test_tls_config_with_cert_pinning() {
487        let config = TlsConfig::default();
488        assert!(!config.pin_speedtest_certs);
489
490        let config = config.with_cert_pinning();
491        assert!(config.pin_speedtest_certs);
492    }
493
494    #[test]
495    fn test_tls_config_builder_chaining() {
496        // Test that builder methods can be chained
497        let config = TlsConfig::default()
498            .with_ca_cert(std::path::PathBuf::from("/custom/ca.pem"))
499            .with_min_tls_version("1.3")
500            .with_cert_pinning();
501
502        assert_eq!(
503            config.ca_cert_path,
504            Some(std::path::PathBuf::from("/custom/ca.pem"))
505        );
506        assert_eq!(config.min_tls_version, Some("1.3".to_string()));
507        assert!(config.pin_speedtest_certs);
508    }
509
510    // ==================== Settings Tests ====================
511
512    #[test]
513    fn test_settings_default_values() {
514        let settings = Settings::default();
515        assert_eq!(settings.timeout_secs, 10);
516        assert!(settings.source_ip.is_none());
517        assert_eq!(settings.user_agent, DEFAULT_USER_AGENT);
518        assert!(settings.retry_enabled);
519        // Check TlsConfig fields individually since PartialEq isn't derived
520        assert!(settings.tls.ca_cert_path.is_none());
521        assert!(settings.tls.min_tls_version.is_none());
522        assert!(!settings.tls.pin_speedtest_certs);
523    }
524
525    #[test]
526    fn test_settings_with_user_agent() {
527        let settings = Settings::default().with_user_agent("Custom Agent/1.0");
528        assert_eq!(settings.user_agent, "Custom Agent/1.0");
529    }
530
531    #[test]
532    fn test_settings_with_user_agent_chaining() {
533        let settings = Settings::default()
534            .with_user_agent("Test Agent")
535            .with_retry_disabled();
536        assert_eq!(settings.user_agent, "Test Agent");
537        assert!(!settings.retry_enabled);
538    }
539
540    #[test]
541    fn test_settings_with_retry_disabled() {
542        let settings = Settings::default();
543        assert!(settings.retry_enabled);
544
545        let settings = settings.with_retry_disabled();
546        assert!(!settings.retry_enabled);
547    }
548
549    #[test]
550    fn test_settings_debug_trait() {
551        let settings = Settings::default();
552        let debug_str = format!("{:?}", settings);
553        assert!(debug_str.contains("timeout_secs"));
554        assert!(debug_str.contains("user_agent"));
555    }
556
557    #[test]
558    fn test_settings_clone() {
559        let settings = Settings::default();
560        let cloned = settings.clone();
561        assert_eq!(settings.user_agent, cloned.user_agent);
562        assert_eq!(settings.timeout_secs, cloned.timeout_secs);
563    }
564
565    // ==================== is_transient_error Tests ====================
566    // Note: is_transient_error requires a real reqwest::Error which is difficult to construct.
567    // The function is tested indirectly via integration tests with actual network failures.
568
569    // ==================== build_tls_config Tests ====================
570    // Note: These tests require a configured rustls CryptoProvider.
571    // They are skipped in unit tests but tested via integration tests.
572    // The build_tls_config function's behavior is tested indirectly via
573    // create_client tests with TLS options.
574
575    #[test]
576    #[ignore]
577    fn test_build_tls_config_unknown_tls_version() {
578        let tls = TlsConfig {
579            min_tls_version: Some("99.0".to_string()),
580            ..Default::default()
581        };
582        let result = build_tls_config(&tls);
583        assert!(result.is_ok());
584    }
585
586    #[test]
587    #[ignore]
588    fn test_build_tls_config_tls12() {
589        let tls = TlsConfig {
590            min_tls_version: Some("1.2".to_string()),
591            ..Default::default()
592        };
593        let result = build_tls_config(&tls);
594        assert!(result.is_ok());
595    }
596
597    #[test]
598    #[ignore]
599    fn test_build_tls_config_tls13() {
600        let tls = TlsConfig {
601            min_tls_version: Some("1.3".to_string()),
602            ..Default::default()
603        };
604        let result = build_tls_config(&tls);
605        assert!(result.is_ok());
606    }
607
608    #[test]
609    #[ignore]
610    fn test_build_tls_config_pinning_takes_precedence() {
611        let tls = TlsConfig {
612            ca_cert_path: Some(std::path::PathBuf::from("/path/to/ca.pem")),
613            pin_speedtest_certs: true,
614            ..Default::default()
615        };
616        let result = build_tls_config(&tls);
617        assert!(result.is_ok());
618    }
619
620    #[test]
621    #[ignore]
622    fn test_build_tls_config_pinning_only() {
623        let tls = TlsConfig {
624            pin_speedtest_certs: true,
625            ..Default::default()
626        };
627        let result = build_tls_config(&tls);
628        assert!(result.is_ok());
629    }
630
631    #[test]
632    #[ignore]
633    fn test_build_tls_config_no_options() {
634        let tls = TlsConfig::default();
635        let result = build_tls_config(&tls);
636        assert!(result.is_ok());
637    }
638
639    // ==================== load_custom_ca_cert Tests ====================
640
641    #[test]
642    fn test_load_custom_ca_cert_file_not_found() {
643        let result = load_custom_ca_cert(std::path::Path::new("/nonexistent/cert.pem"));
644        assert!(result.is_err());
645        let err = result.unwrap_err();
646        // Error should mention the path
647        let err_msg = format!("{:?}", err);
648        assert!(err_msg.contains("nonexistent") || err_msg.contains("Failed to read CA cert"));
649    }
650
651    #[test]
652    fn test_load_custom_ca_cert_invalid_path() {
653        // Test with a directory path instead of file
654        let result = load_custom_ca_cert(std::path::Path::new("/tmp"));
655        assert!(result.is_err());
656    }
657
658    // ==================== create_client Tests ====================
659    // Note: Some TLS-related tests are ignored due to rustls CryptoProvider requirements.
660    // These are tested via integration tests.
661
662    #[test]
663    fn test_create_client_source_ip_v4() {
664        let settings = Settings {
665            source_ip: Some("192.168.1.100".to_string()),
666            ..Default::default()
667        };
668        let result = create_client(&settings);
669        // IPv4 source IP should work
670        match result {
671            Ok(_) => {}
672            Err(Error::Context { .. }) => {} // Invalid IP format returns Context
673            Err(e) => panic!("Unexpected error type for valid IPv4: {e:?}"),
674        }
675    }
676
677    #[test]
678    fn test_create_client_source_ip_v6() {
679        let settings = Settings {
680            source_ip: Some("::1".to_string()),
681            ..Default::default()
682        };
683        let result = create_client(&settings);
684        match result {
685            Ok(_) => {}
686            Err(Error::NetworkError(_) | Error::Context { .. }) => {} // Network errors acceptable
687            Err(e) => panic!("Unexpected error type: {e:?}"),
688        }
689    }
690
691    #[test]
692    #[ignore]
693    fn test_create_client_with_ca_cert() {
694        let settings = Settings {
695            tls: TlsConfig {
696                ca_cert_path: Some(std::path::PathBuf::from("/nonexistent/ca.pem")),
697                ..Default::default()
698            },
699            ..Default::default()
700        };
701        let result = create_client(&settings);
702        assert!(result.is_err());
703    }
704
705    #[test]
706    #[ignore]
707    fn test_create_client_with_pinning() {
708        let settings = Settings {
709            tls: TlsConfig {
710                pin_speedtest_certs: true,
711                ..Default::default()
712            },
713            ..Default::default()
714        };
715        let result = create_client(&settings);
716        assert!(result.is_ok());
717    }
718
719    #[test]
720    fn test_create_client_with_retry_disabled() {
721        let settings = Settings::default().with_retry_disabled();
722        let result = create_client(&settings);
723        assert!(result.is_ok());
724    }
725
726    #[test]
727    fn test_create_client_timeout_30() {
728        let settings = Settings {
729            timeout_secs: 30,
730            ..Default::default()
731        };
732        let result = create_client(&settings);
733        assert!(result.is_ok());
734    }
735
736    #[test]
737    fn test_create_client_timeout_60() {
738        let settings = Settings {
739            timeout_secs: 60,
740            ..Default::default()
741        };
742        let result = create_client(&settings);
743        assert!(result.is_ok());
744    }
745
746    // ==================== PinningVerifier Tests ====================
747
748    #[test]
749    fn test_pinning_verifier_is_valid_domain_speedtest() {
750        // Valid subdomains - .ends_with() matches exact domains too
751        assert!(PinningVerifier::is_valid_domain("speedtest.net"));
752        assert!(PinningVerifier::is_valid_domain("www.speedtest.net"));
753        assert!(PinningVerifier::is_valid_domain("api.speedtest.net"));
754        assert!(PinningVerifier::is_valid_domain("foo.bar.speedtest.net"));
755        assert!(PinningVerifier::is_valid_domain("fake.speedtest.net")); // also valid (subdomain)
756
757        // Invalid domains - must NOT end with .speedtest.net
758        assert!(!PinningVerifier::is_valid_domain("evilsite.net"));
759        assert!(!PinningVerifier::is_valid_domain("speedtest.com"));
760        assert!(!PinningVerifier::is_valid_domain("notspeedtest.net"));
761    }
762
763    #[test]
764    fn test_pinning_verifier_is_valid_domain_ookla() {
765        // Valid subdomains - .ends_with() matches exact domains too
766        assert!(PinningVerifier::is_valid_domain("ookla.com"));
767        assert!(PinningVerifier::is_valid_domain("www.ookla.com"));
768        assert!(PinningVerifier::is_valid_domain("api.ookla.com"));
769        assert!(PinningVerifier::is_valid_domain("foo.bar.ookla.com"));
770        assert!(PinningVerifier::is_valid_domain("fake.ookla.com")); // also valid (subdomain)
771
772        // Invalid domains - must NOT end with .ookla.com
773        assert!(!PinningVerifier::is_valid_domain("ookla.net"));
774    }
775
776    #[test]
777    fn test_pinning_verifier_edge_cases() {
778        // Edge cases for security
779        assert!(!PinningVerifier::is_valid_domain(""));
780        assert!(!PinningVerifier::is_valid_domain("speedtestXnet")); // no dot prefix
781        assert!(!PinningVerifier::is_valid_domain("attack.com")); // unrelated domain
782    }
783
784    #[test]
785    fn test_pinning_verifier_exact_domains() {
786        // Test exact domain matches (should be valid)
787        assert!(PinningVerifier::is_valid_domain("speedtest.net"));
788        assert!(PinningVerifier::is_valid_domain("ookla.com"));
789    }
790
791    #[test]
792    fn test_pinning_verifier_subdomains() {
793        // Test various subdomain depths
794        assert!(PinningVerifier::is_valid_domain("www.speedtest.net"));
795        assert!(PinningVerifier::is_valid_domain("api.speedtest.net"));
796        assert!(PinningVerifier::is_valid_domain("a.b.c.speedtest.net"));
797        assert!(PinningVerifier::is_valid_domain("www.ookla.com"));
798        assert!(PinningVerifier::is_valid_domain("api.www.ookla.com"));
799    }
800
801    #[test]
802    fn test_pinning_verifier_invalid_suffixes() {
803        // These should NOT match because they don't end with the exact suffix
804        assert!(!PinningVerifier::is_valid_domain("xspeedtest.net")); // prefix attack
805        assert!(!PinningVerifier::is_valid_domain("fake-speedtest.net")); // prefix attack
806        assert!(!PinningVerifier::is_valid_domain("speedtest.net.evil.com")); // suffix confusion
807        assert!(!PinningVerifier::is_valid_domain("ookla.com.evil.com")); // suffix confusion
808        assert!(!PinningVerifier::is_valid_domain("fooookla.com")); // prefix attack
809    }
810
811    #[test]
812    fn test_pinning_verifier_case_sensitivity() {
813        // Domain matching should be case-sensitive (DNS is case-insensitive but we check exact match)
814        assert!(!PinningVerifier::is_valid_domain("Speedtest.net")); // uppercase
815        assert!(!PinningVerifier::is_valid_domain("SPEEDTEST.NET")); // all caps
816        assert!(!PinningVerifier::is_valid_domain("www.Speedtest.net")); // mixed case
817        assert!(!PinningVerifier::is_valid_domain("OOKLA.COM")); // all caps ookla
818    }
819
820    #[test]
821    fn test_pinning_verifier_special_characters() {
822        // Invalid domain formats
823        assert!(!PinningVerifier::is_valid_domain("speedtest.net/")); // trailing slash
824        assert!(!PinningVerifier::is_valid_domain("speedtest.net:443")); // port number
825        assert!(!PinningVerifier::is_valid_domain("speedtest.net/path")); // path
826    }
827
828    #[test]
829    fn test_pinning_verifier_numeric_domains() {
830        // Numeric subdomains are valid
831        assert!(PinningVerifier::is_valid_domain("123.speedtest.net")); // valid numeric subdomain
832        assert!(PinningVerifier::is_valid_domain("1.2.3.speedtest.net")); // valid numeric subdomain
833        // Numeric prefix on base domain is invalid
834        assert!(!PinningVerifier::is_valid_domain("speedtest123.net")); // not valid
835        assert!(!PinningVerifier::is_valid_domain("123speedtest.net")); // not valid
836    }
837
838    #[test]
839    fn test_pinning_verifier_new_returns_self() {
840        // Test that new() creates an instance
841        let verifier = PinningVerifier::new();
842        assert!(!verifier.supported_verify_schemes().is_empty());
843    }
844
845    #[test]
846    fn test_pinning_verifier_debug_trait() {
847        // Test that Debug can be derived and used
848        let verifier = PinningVerifier::new();
849        let debug_str = format!("{:?}", verifier);
850        assert!(debug_str.contains("PinningVerifier"));
851    }
852
853    #[test]
854    fn test_pinning_verifier_supported_verify_schemes() {
855        let verifier = PinningVerifier::new();
856        let schemes = verifier.supported_verify_schemes();
857
858        // Should support these signature schemes
859        assert!(schemes.contains(&SignatureScheme::RSA_PKCS1_SHA256));
860        assert!(schemes.contains(&SignatureScheme::RSA_PKCS1_SHA384));
861        assert!(schemes.contains(&SignatureScheme::RSA_PKCS1_SHA512));
862        assert!(schemes.contains(&SignatureScheme::ECDSA_NISTP256_SHA256));
863        assert!(schemes.contains(&SignatureScheme::ECDSA_NISTP384_SHA384));
864        assert!(schemes.contains(&SignatureScheme::RSA_PSS_SHA256));
865        assert!(schemes.contains(&SignatureScheme::RSA_PSS_SHA384));
866        assert!(schemes.contains(&SignatureScheme::RSA_PSS_SHA512));
867
868        assert!(schemes.len() >= 8);
869    }
870
871    // Note: Signature verification tests are omitted because DigitallySignedStruct
872    // has a private constructor in rustls. The signature verification methods always
873    // return HandshakeSignatureValid::assertion() in PinningVerifier, which is tested
874    // implicitly by successful TLS handshakes with valid speedtest.net certificates.
875
876    #[test]
877    fn test_pinning_verifier_verify_server_cert_rejects_invalid_domain() {
878        let verifier = PinningVerifier::new();
879
880        // Create a DnsName for an invalid domain
881        let dns_name = rustls::pki_types::DnsName::try_from("evil.com".to_string()).unwrap();
882        let server_name = ServerName::DnsName(dns_name);
883
884        // Create a minimal valid certificate structure
885        // Using a real but minimal test certificate
886        let cert_der = CertificateDer::from(vec![]);
887
888        // This should fail because the domain is not valid
889        let result =
890            verifier.verify_server_cert(&cert_der, &[], &server_name, &[], UnixTime::now());
891
892        assert!(result.is_err());
893        let err = result.unwrap_err();
894        let err_msg = format!("{:?}", err);
895        assert!(err_msg.contains("evil.com") || err_msg.contains("not a speedtest.net domain"));
896    }
897
898    #[test]
899    fn test_pinning_verifier_verify_server_cert_rejects_unsupported_name_type() {
900        let verifier = PinningVerifier::new();
901
902        // Test with an IpAddress server name (unsupported)
903        let ip_addr = std::net::IpAddr::from([127, 0, 0, 1]);
904        let server_name = ServerName::IpAddress(ip_addr.into());
905
906        let cert_der = CertificateDer::from(vec![]);
907
908        let result =
909            verifier.verify_server_cert(&cert_der, &[], &server_name, &[], UnixTime::now());
910
911        assert!(result.is_err());
912        let err = result.unwrap_err();
913        let err_msg = format!("{:?}", err);
914        assert!(err_msg.contains("Unsupported server name type"));
915    }
916
917    #[test]
918    fn test_pinning_verifier_verify_server_cert_rejects_invalid_certificate() {
919        let verifier = PinningVerifier::new();
920
921        // Valid domain but invalid certificate structure
922        let dns_name =
923            rustls::pki_types::DnsName::try_from("www.speedtest.net".to_string()).unwrap();
924        let server_name = ServerName::DnsName(dns_name);
925
926        // Empty certificate should fail webpki parsing
927        let cert_der = CertificateDer::from(vec![]);
928
929        let result =
930            verifier.verify_server_cert(&cert_der, &[], &server_name, &[], UnixTime::now());
931
932        // Should fail on cert parsing, not domain validation
933        assert!(result.is_err());
934        let err = result.unwrap_err();
935        let err_msg = format!("{:?}", err);
936        // The error should be about certificate validation, not domain validation.
937        assert!(!err_msg.contains("not a speedtest.net domain"));
938    }
939
940    #[test]
941    fn test_pinning_verifier_domain_checked_before_cert_parse_speedtest() {
942        let verifier = PinningVerifier::new();
943
944        // Valid speedtest.net domain
945        let dns_name = rustls::pki_types::DnsName::try_from("speedtest.net".to_string()).unwrap();
946        let server_name = ServerName::DnsName(dns_name);
947
948        // Empty certificate - the test verifies domain validation happens first
949        // Certificate structure validation is tested in a separate test
950        let cert_der = CertificateDer::from(vec![]);
951
952        let result =
953            verifier.verify_server_cert(&cert_der, &[], &server_name, &[], UnixTime::now());
954
955        // Should fail on certificate structure validation, not domain validation
956        // This proves domain was checked before cert parsing
957        assert!(result.is_err());
958        let err = result.unwrap_err();
959        let err_msg = format!("{:?}", err);
960        // The error should be about certificate validation, not domain validation.
961        assert!(!err_msg.contains("not a speedtest.net domain"));
962    }
963
964    #[test]
965    fn test_pinning_verifier_ipv6_address_rejected() {
966        let verifier = PinningVerifier::new();
967
968        // Test with an IPv6 address server name
969        let ip_addr = std::net::IpAddr::from([0, 0, 0, 0, 0, 0, 0, 1]); // ::1
970        let server_name = ServerName::IpAddress(ip_addr.into());
971
972        let cert_der = CertificateDer::from(vec![]);
973
974        let result =
975            verifier.verify_server_cert(&cert_der, &[], &server_name, &[], UnixTime::now());
976
977        assert!(result.is_err());
978    }
979
980    #[test]
981    fn test_pinning_verifier_domain_checked_before_cert_parse_ookla() {
982        let verifier = PinningVerifier::new();
983
984        // Valid ookla.com domain
985        let dns_name = rustls::pki_types::DnsName::try_from("ookla.com".to_string()).unwrap();
986        let server_name = ServerName::DnsName(dns_name);
987
988        // Empty certificate - domain validation is the key being tested here
989        let cert_der = CertificateDer::from(vec![]);
990
991        let result =
992            verifier.verify_server_cert(&cert_der, &[], &server_name, &[], UnixTime::now());
993
994        // Should fail on certificate structure (proves domain was checked first)
995        assert!(result.is_err());
996    }
997
998    #[test]
999    fn test_pinning_verifier_domain_validation_order() {
1000        // This test verifies that domain validation happens BEFORE certificate parsing
1001        let verifier = PinningVerifier::new();
1002
1003        // Invalid domain should fail immediately, without attempting cert parsing
1004        let dns_name = rustls::pki_types::DnsName::try_from("attacker.com".to_string()).unwrap();
1005        let server_name = ServerName::DnsName(dns_name);
1006
1007        // Even with a potentially "valid" looking empty cert structure,
1008        // domain validation should fail first
1009        let cert_der = CertificateDer::from(vec![]);
1010
1011        let result =
1012            verifier.verify_server_cert(&cert_der, &[], &server_name, &[], UnixTime::now());
1013
1014        assert!(result.is_err());
1015        let err = result.unwrap_err();
1016        let err_msg = format!("{:?}", err);
1017        assert!(
1018            err_msg.contains("not a speedtest.net domain"),
1019            "Expected domain validation error, got: {}",
1020            err_msg
1021        );
1022    }
1023
1024    #[test]
1025    fn test_pinning_verifier_verify_server_cert_rejects_different_tld() {
1026        let verifier = PinningVerifier::new();
1027
1028        // Test with speedtest.net.org (should be rejected)
1029        let dns_name =
1030            rustls::pki_types::DnsName::try_from("speedtest.net.org".to_string()).unwrap();
1031        let server_name = ServerName::DnsName(dns_name);
1032
1033        let cert_der = CertificateDer::from(vec![]);
1034
1035        let result =
1036            verifier.verify_server_cert(&cert_der, &[], &server_name, &[], UnixTime::now());
1037
1038        assert!(result.is_err());
1039        let err = result.unwrap_err();
1040        let err_msg = format!("{:?}", err);
1041        assert!(err_msg.contains("not a speedtest.net domain"));
1042    }
1043
1044    #[test]
1045    fn test_pinning_verifier_intermediate_certs_ignored() {
1046        // Test that intermediate certificates are ignored in validation
1047        // The implementation only validates the end-entity certificate
1048        let verifier = PinningVerifier::new();
1049
1050        // Valid domain
1051        let dns_name =
1052            rustls::pki_types::DnsName::try_from("www.speedtest.net".to_string()).unwrap();
1053        let server_name = ServerName::DnsName(dns_name);
1054
1055        // Empty certificate - will fail on structure but that's expected
1056        let cert_der = CertificateDer::from(vec![]);
1057
1058        // Add intermediate certificates (should be ignored)
1059        let intermediate_cert = CertificateDer::from(vec![0u8; 10]);
1060
1061        let result = verifier.verify_server_cert(
1062            &cert_der,
1063            &[intermediate_cert],
1064            &server_name,
1065            &[],
1066            UnixTime::now(),
1067        );
1068
1069        // Should fail on cert structure, not because of intermediate certs
1070        assert!(result.is_err());
1071    }
1072
1073    #[test]
1074    fn test_pinning_verifier_ocsp_response_ignored() {
1075        // Test that OCSP response data is ignored
1076        let verifier = PinningVerifier::new();
1077
1078        // Valid domain
1079        let dns_name = rustls::pki_types::DnsName::try_from("api.ookla.com".to_string()).unwrap();
1080        let server_name = ServerName::DnsName(dns_name);
1081
1082        // Empty certificate
1083        let cert_der = CertificateDer::from(vec![]);
1084
1085        // Add OCSP response data (should be ignored)
1086        let ocsp_response = vec![0x30, 0x03, 0x01, 0x00];
1087
1088        let result = verifier.verify_server_cert(
1089            &cert_der,
1090            &[],
1091            &server_name,
1092            &ocsp_response,
1093            UnixTime::now(),
1094        );
1095
1096        // Should fail on cert structure, not because of OCSP
1097        assert!(result.is_err());
1098    }
1099
1100    #[test]
1101    fn test_pinning_verifier_all_valid_subdomains() {
1102        // Test all valid subdomain patterns
1103        let valid_subdomains = [
1104            "www.speedtest.net",
1105            "api.speedtest.net",
1106            "test.speedtest.net",
1107            "staging.speedtest.net",
1108            "prod.speedtest.net",
1109            "cdn.speedtest.net",
1110            "a.speedtest.net",
1111            "z.speedtest.net",
1112            "a1b2c3.speedtest.net",
1113            "my-site.speedtest.net",
1114            "www.ookla.com",
1115            "api.ookla.com",
1116            "test.ookla.com",
1117        ];
1118
1119        for domain in valid_subdomains {
1120            assert!(
1121                PinningVerifier::is_valid_domain(domain),
1122                "Domain '{}' should be valid",
1123                domain
1124            );
1125        }
1126    }
1127
1128    #[test]
1129    fn test_pinning_verifier_all_invalid_domains() {
1130        // Test all invalid domain patterns
1131        let invalid_domains = [
1132            "evilsite.net",
1133            "speedtest.net.evil.com",
1134            "ookla.com.evil.com",
1135            "speedtest.com",
1136            "ookla.net",
1137            "notspeedtest.net",
1138            "notookla.com",
1139            "fake-speedtest.net",
1140            "fake-ookla.com",
1141            "attacker.speedtest.net.fake.com",
1142            "attacker.ookla.com.fake.com",
1143        ];
1144
1145        for domain in invalid_domains {
1146            assert!(
1147                !PinningVerifier::is_valid_domain(domain),
1148                "Domain '{}' should be invalid",
1149                domain
1150            );
1151        }
1152    }
1153
1154    // ==================== Existing Tests ====================
1155
1156    #[test]
1157    fn test_parse_ip_from_xml() {
1158        let xml = r#"<settings><client country="CA" ip="173.35.57.235" isp="Rogers"/></settings>"#;
1159        assert_eq!(parse_ip_from_xml(xml), Some("173.35.57.235".to_string()));
1160    }
1161
1162    #[test]
1163    fn test_parse_ip_from_xml_full_response() {
1164        let xml = r#"<?xml version="1.0"?>
1165<settings>
1166 <config downloadThreadCountV3="4"/>
1167 <client country="CA" ip="173.35.57.235" isp="Rogers"/>
1168</settings>"#;
1169        assert_eq!(parse_ip_from_xml(xml), Some("173.35.57.235".to_string()));
1170    }
1171
1172    #[test]
1173    fn test_parse_ip_from_xml_invalid() {
1174        assert!(parse_ip_from_xml("not xml").is_none());
1175        assert!(parse_ip_from_xml("<html></html>").is_none());
1176        assert!(parse_ip_from_xml("<settings><client ip=\"invalid\"/></settings>").is_none());
1177    }
1178
1179    #[test]
1180    fn test_create_client_invalid_source_ip() {
1181        let source = crate::config::ConfigSource::default();
1182        let config = crate::config::Config::from_source(&source);
1183        let mut settings = Settings::from(&config);
1184        settings.source_ip = Some("invalid-ip".to_string());
1185        let result = create_client(&settings);
1186        assert!(result.is_err());
1187        assert!(matches!(result.unwrap_err(), Error::Context { .. }));
1188    }
1189
1190    #[test]
1191    fn test_create_client_valid_config() {
1192        let source = crate::config::ConfigSource::default();
1193        let config = crate::config::Config::from_source(&source);
1194        let settings = Settings::from(&config);
1195        let result = create_client(&settings);
1196        assert!(result.is_ok());
1197    }
1198
1199    #[test]
1200    fn test_create_client_with_source_ip() {
1201        let source = crate::config::ConfigSource {
1202            network: crate::config::NetworkSource {
1203                source: Some("0.0.0.0".into()),
1204                ..Default::default()
1205            },
1206            ..Default::default()
1207        };
1208        let config = crate::config::Config::from_source(&source);
1209        let settings = Settings::from(&config);
1210        let result = create_client(&settings);
1211        match result {
1212            Ok(_) | Err(Error::NetworkError(_) | Error::Context { .. }) => {}
1213            Err(e) => panic!("Unexpected error type: {e:?}"),
1214        }
1215    }
1216
1217    #[test]
1218    fn test_create_client_custom_timeout() {
1219        let source = crate::config::ConfigSource {
1220            network: crate::config::NetworkSource {
1221                timeout: 30,
1222                ..Default::default()
1223            },
1224            ..Default::default()
1225        };
1226        let config = crate::config::Config::from_source(&source);
1227        let settings = Settings::from(&config);
1228        let result = create_client(&settings);
1229        assert!(result.is_ok());
1230    }
1231
1232    // ==================== Settings from Config Tests ====================
1233
1234    #[test]
1235    fn test_settings_from_config_with_source_ip() {
1236        let source = crate::config::ConfigSource {
1237            network: crate::config::NetworkSource {
1238                source: Some("192.168.1.50".to_string()),
1239                ..Default::default()
1240            },
1241            ..Default::default()
1242        };
1243        let config = crate::config::Config::from_source(&source);
1244        let settings = Settings::from(&config);
1245        assert_eq!(settings.source_ip, Some("192.168.1.50".to_string()));
1246    }
1247
1248    #[test]
1249    fn test_settings_from_config_with_ca_cert() {
1250        let source = crate::config::ConfigSource {
1251            network: crate::config::NetworkSource {
1252                ca_cert: Some("/path/to/ca.pem".to_string()),
1253                ..Default::default()
1254            },
1255            ..Default::default()
1256        };
1257        let config = crate::config::Config::from_source(&source);
1258        let settings = Settings::from(&config);
1259        assert_eq!(
1260            settings.tls.ca_cert_path,
1261            Some(std::path::PathBuf::from("/path/to/ca.pem"))
1262        );
1263    }
1264
1265    #[test]
1266    fn test_settings_from_config_with_tls_version() {
1267        let source = crate::config::ConfigSource {
1268            network: crate::config::NetworkSource {
1269                tls_version: Some("1.2".to_string()),
1270                ..Default::default()
1271            },
1272            ..Default::default()
1273        };
1274        let config = crate::config::Config::from_source(&source);
1275        let settings = Settings::from(&config);
1276        assert_eq!(settings.tls.min_tls_version, Some("1.2".to_string()));
1277    }
1278
1279    #[test]
1280    fn test_settings_from_config_with_pinning() {
1281        let source = crate::config::ConfigSource {
1282            network: crate::config::NetworkSource {
1283                pin_certs: Some(true),
1284                ..Default::default()
1285            },
1286            ..Default::default()
1287        };
1288        let config = crate::config::Config::from_source(&source);
1289        let settings = Settings::from(&config);
1290        assert!(settings.tls.pin_speedtest_certs);
1291    }
1292
1293    #[test]
1294    fn test_settings_from_config_timeout() {
1295        let source = crate::config::ConfigSource {
1296            network: crate::config::NetworkSource {
1297                timeout: 45,
1298                ..Default::default()
1299            },
1300            ..Default::default()
1301        };
1302        let config = crate::config::Config::from_source(&source);
1303        let settings = Settings::from(&config);
1304        assert_eq!(settings.timeout_secs, 45);
1305    }
1306
1307    #[test]
1308    fn test_settings_from_config_default_user_agent() {
1309        let config = crate::config::Config::from_source(&crate::config::ConfigSource::default());
1310        let settings = Settings::from(&config);
1311        assert_eq!(settings.user_agent, DEFAULT_USER_AGENT);
1312    }
1313
1314    #[test]
1315    fn test_settings_from_config_retry_enabled_by_default() {
1316        let config = crate::config::Config::from_source(&crate::config::ConfigSource::default());
1317        let settings = Settings::from(&config);
1318        assert!(settings.retry_enabled);
1319    }
1320
1321    // ==================== with_retry Tests ====================
1322
1323    #[tokio::test]
1324    async fn test_with_retry_immediate_success() {
1325        let counter = Arc::new(AtomicUsize::new(0));
1326        let count = Arc::clone(&counter);
1327
1328        let result = with_retry(|| {
1329            let c = Arc::clone(&count);
1330            async move {
1331                c.fetch_add(1, Ordering::SeqCst);
1332                Ok::<_, reqwest::Error>(42)
1333            }
1334        })
1335        .await;
1336
1337        assert!(result.is_ok());
1338        assert_eq!(result.unwrap(), 42);
1339        assert_eq!(counter.load(Ordering::SeqCst), 1);
1340    }
1341
1342    #[tokio::test]
1343    async fn test_with_retry_with_mock_request() {
1344        // Test with_retry with a request that succeeds
1345        let result = with_retry(|| async { Ok::<_, reqwest::Error>(100) }).await;
1346        assert!(result.is_ok());
1347        assert_eq!(result.unwrap(), 100);
1348    }
1349
1350    #[tokio::test]
1351    async fn test_with_retry_counter_increment() {
1352        let counter = Arc::new(AtomicUsize::new(0));
1353        let count = Arc::clone(&counter);
1354
1355        let _result = with_retry(|| {
1356            let c = Arc::clone(&count);
1357            async move {
1358                c.fetch_add(1, Ordering::SeqCst);
1359                Ok::<_, reqwest::Error>(1)
1360            }
1361        })
1362        .await;
1363
1364        // Verify the counter was incremented exactly once (single attempt)
1365        assert_eq!(counter.load(Ordering::SeqCst), 1);
1366    }
1367
1368    #[tokio::test]
1369    async fn test_with_retry_different_value_types() {
1370        // Test with_retry with different success value types
1371        let result_str = with_retry(|| async { Ok::<_, reqwest::Error>("hello") }).await;
1372        assert!(result_str.is_ok());
1373        assert_eq!(result_str.unwrap(), "hello");
1374
1375        let result_u64 = with_retry(|| async { Ok::<_, reqwest::Error>(999u64) }).await;
1376        assert!(result_u64.is_ok());
1377        assert_eq!(result_u64.unwrap(), 999);
1378
1379        let result_vec = with_retry(|| async { Ok::<_, reqwest::Error>(vec![1, 2, 3]) }).await;
1380        assert!(result_vec.is_ok());
1381        assert_eq!(result_vec.unwrap(), vec![1, 2, 3]);
1382    }
1383
1384    #[tokio::test]
1385    async fn test_with_retry_multiple_sequential_calls() {
1386        // Test calling with_retry multiple times
1387        for i in 0..3 {
1388            let result = with_retry(|| async { Ok::<_, reqwest::Error>(i) }).await;
1389            assert!(result.is_ok());
1390            assert_eq!(result.unwrap(), i);
1391        }
1392    }
1393
1394    // ==================== parse_ip_from_xml Additional Tests ====================
1395
1396    #[test]
1397    fn test_parse_ip_from_xml_missing_client_element() {
1398        let xml = r#"<settings><server ip="127.0.0.1"/></settings>"#;
1399        assert!(parse_ip_from_xml(xml).is_none());
1400    }
1401
1402    #[test]
1403    fn test_parse_ip_from_xml_empty_ip() {
1404        let xml = r#"<settings><client ip=""/></settings>"#;
1405        assert!(parse_ip_from_xml(xml).is_none());
1406    }
1407
1408    #[test]
1409    fn test_parse_ip_from_xml_whitespace_ip() {
1410        let xml = r#"<settings><client ip="  " /></settings>"#;
1411        assert!(parse_ip_from_xml(xml).is_none());
1412    }
1413
1414    #[test]
1415    fn test_parse_ip_from_xml_ipv6_format() {
1416        let xml = r#"<settings><client ip="::1"/></settings>"#;
1417        // IPv6 should not match valid IPv4 check
1418        assert!(parse_ip_from_xml(xml).is_none());
1419    }
1420
1421    #[test]
1422    fn test_parse_ip_from_xml_special_characters() {
1423        let xml = r#"<settings><client country="US" ip="192.168.1.1" isp="ISP"/></settings>"#;
1424        assert_eq!(parse_ip_from_xml(xml), Some("192.168.1.1".to_string()));
1425    }
1426
1427    #[test]
1428    fn test_parse_ip_from_xml_garbage_after_xml() {
1429        let xml = r#"<settings><client ip="1.2.3.4" /></settings>GARBAGE"#;
1430        assert_eq!(parse_ip_from_xml(xml), Some("1.2.3.4".to_string()));
1431    }
1432
1433    #[test]
1434    fn test_parse_ip_from_xml_malformed_xml() {
1435        assert!(parse_ip_from_xml("<settings><client").is_none());
1436        assert!(parse_ip_from_xml("</settings>").is_none());
1437        assert!(parse_ip_from_xml("").is_none());
1438    }
1439
1440    // ==================== discover_client_ip Tests ====================
1441
1442    #[tokio::test]
1443    async fn test_discover_client_ip_handles_network_failure() {
1444        // Test with a client that's not properly configured (will fail to connect)
1445        let settings = Settings::default().with_retry_disabled();
1446        let client = create_client(&settings).unwrap();
1447
1448        // This test verifies the function handles network failures gracefully
1449        let result = discover_client_ip(&client).await;
1450
1451        // Should return "unknown" on failure, not panic
1452        match result {
1453            Ok(ip) => {
1454                // If it succeeds, verify the format
1455                assert!(ip == "unknown" || common::is_valid_ipv4(&ip));
1456            }
1457            Err(e) => {
1458                // Network errors are acceptable
1459                assert!(matches!(e, Error::NetworkError(_)));
1460            }
1461        }
1462    }
1463
1464    // ==================== TlsConfig Additional Tests ====================
1465
1466    #[test]
1467    fn test_tls_config_debug() {
1468        let config = TlsConfig::default();
1469        let debug_str = format!("{:?}", config);
1470        assert!(debug_str.contains("TlsConfig"));
1471    }
1472
1473    #[test]
1474    fn test_tls_config_clone() {
1475        let config = TlsConfig::default()
1476            .with_ca_cert(std::path::PathBuf::from("/test.pem"))
1477            .with_min_tls_version("1.3")
1478            .with_cert_pinning();
1479        let cloned = config.clone();
1480        assert_eq!(cloned.ca_cert_path, config.ca_cert_path);
1481        assert_eq!(cloned.min_tls_version, config.min_tls_version);
1482        assert_eq!(cloned.pin_speedtest_certs, config.pin_speedtest_certs);
1483    }
1484
1485    #[test]
1486    fn test_tls_config_default_trait() {
1487        let config = TlsConfig::default();
1488        assert!(config.ca_cert_path.is_none());
1489        assert!(config.min_tls_version.is_none());
1490        assert!(!config.pin_speedtest_certs);
1491    }
1492
1493    // ==================== Settings Additional Tests ====================
1494
1495    #[test]
1496    fn test_settings_with_source_ip() {
1497        let settings = Settings {
1498            source_ip: Some("10.0.0.1".to_string()),
1499            ..Default::default()
1500        };
1501        let cloned = settings.clone();
1502        assert_eq!(cloned.source_ip, Some("10.0.0.1".to_string()));
1503    }
1504
1505    #[test]
1506    fn test_settings_builder_full_chain() {
1507        let settings = Settings::default()
1508            .with_user_agent("Test/1.0")
1509            .with_retry_disabled();
1510        assert_eq!(settings.user_agent, "Test/1.0");
1511        assert!(!settings.retry_enabled);
1512    }
1513
1514    #[test]
1515    fn test_settings_clone_is_independent() {
1516        let mut settings = Settings {
1517            timeout_secs: 60,
1518            ..Default::default()
1519        };
1520        let cloned = settings.clone();
1521        assert_eq!(cloned.timeout_secs, 60);
1522        // Modify original should not affect clone (deep clone of primitives)
1523        settings.timeout_secs = 120;
1524        assert_eq!(cloned.timeout_secs, 60); // Clone should be independent
1525    }
1526
1527    // ==================== create_client Additional Tests ====================
1528
1529    #[test]
1530    fn test_create_client_with_source_ip_none() {
1531        let settings = Settings::default();
1532        let result = create_client(&settings);
1533        assert!(result.is_ok());
1534    }
1535
1536    #[test]
1537    fn test_create_client_with_custom_user_agent() {
1538        let settings = Settings::default().with_user_agent("TestAgent/1.0");
1539        let result = create_client(&settings);
1540        assert!(result.is_ok());
1541    }
1542
1543    #[test]
1544    fn test_create_client_timeout_zero() {
1545        let settings = Settings {
1546            timeout_secs: 0,
1547            ..Default::default()
1548        };
1549        let result = create_client(&settings);
1550        assert!(result.is_ok());
1551    }
1552
1553    #[test]
1554    fn test_create_client_timeout_large() {
1555        let settings = Settings {
1556            timeout_secs: 300,
1557            ..Default::default()
1558        };
1559        let result = create_client(&settings);
1560        assert!(result.is_ok());
1561    }
1562
1563    // ==================== Error Context Tests ====================
1564
1565    #[test]
1566    fn test_error_context_message() {
1567        let err = Error::context("test error");
1568        let msg = format!("{:?}", err);
1569        assert!(msg.contains("test error"));
1570    }
1571
1572    #[test]
1573    fn test_error_context_with_source() {
1574        let inner = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
1575        let err = Error::with_source("operation failed", inner);
1576        let msg = format!("{:?}", err);
1577        assert!(msg.contains("operation failed") || msg.contains("file not found"));
1578    }
1579
1580    #[test]
1581    fn test_error_server_not_found() {
1582        let err = Error::ServerNotFound("no servers available".into());
1583        let msg = format!("{:?}", err);
1584        assert!(msg.contains("no servers available") || msg.contains("ServerNotFound"));
1585    }
1586
1587    #[test]
1588    fn test_error_download_failure() {
1589        let err = Error::DownloadFailure("test download failed".into());
1590        let msg = format!("{:?}", err);
1591        assert!(msg.contains("test download failed") || msg.contains("DownloadFailure"));
1592    }
1593
1594    #[test]
1595    fn test_error_upload_failure() {
1596        let err = Error::UploadFailure("test upload failed".into());
1597        let msg = format!("{:?}", err);
1598        assert!(msg.contains("test upload failed") || msg.contains("UploadFailure"));
1599    }
1600
1601    #[test]
1602    fn test_error_context_debug() {
1603        let err = Error::context("context debug");
1604        let debug_str = format!("{:?}", err);
1605        assert!(debug_str.contains("Context"));
1606        assert!(debug_str.contains("context debug"));
1607    }
1608
1609    #[test]
1610    fn test_error_context_display() {
1611        let err = Error::context("context display");
1612        assert_eq!(format!("{}", err), "context display");
1613    }
1614
1615    #[test]
1616    fn test_error_download_failure_display() {
1617        let err = Error::DownloadFailure("download failed".into());
1618        let display = format!("{}", err);
1619        assert!(display.contains("download failed"));
1620    }
1621
1622    #[test]
1623    fn test_error_upload_failure_display() {
1624        let err = Error::UploadFailure("upload failed".into());
1625        let display = format!("{}", err);
1626        assert!(display.contains("upload failed"));
1627    }
1628
1629    #[test]
1630    fn test_error_server_not_found_display() {
1631        let err = Error::ServerNotFound("server not found".into());
1632        let display = format!("{}", err);
1633        assert!(display.contains("Server not found"));
1634        assert!(display.contains("server not found"));
1635    }
1636
1637    // ==================== HTTP Client Settings Default Tests ====================
1638
1639    #[test]
1640    fn test_settings_default_timeout_10() {
1641        let settings = Settings::default();
1642        assert_eq!(settings.timeout_secs, 10);
1643    }
1644
1645    #[test]
1646    fn test_settings_default_retry_true() {
1647        let settings = Settings::default();
1648        assert!(settings.retry_enabled);
1649    }
1650
1651    #[test]
1652    fn test_settings_with_timeout() {
1653        let settings = Settings {
1654            timeout_secs: 120,
1655            ..Default::default()
1656        };
1657        assert_eq!(settings.timeout_secs, 120);
1658    }
1659
1660    // ==================== TlsConfig Defaults Tests ====================
1661
1662    #[test]
1663    fn test_tls_config_default_values() {
1664        let tls = TlsConfig::default();
1665        assert!(tls.ca_cert_path.is_none());
1666        assert!(tls.min_tls_version.is_none());
1667        assert!(!tls.pin_speedtest_certs);
1668    }
1669
1670    #[test]
1671    fn test_tls_config_multiple_options() {
1672        let tls = TlsConfig::default()
1673            .with_ca_cert("/path/to/ca.pem".into())
1674            .with_min_tls_version("1.2");
1675        assert!(tls.ca_cert_path.is_some());
1676        assert!(tls.min_tls_version.is_some());
1677    }
1678
1679    // ==================== Settings Chain Tests ====================
1680
1681    #[test]
1682    fn test_settings_chained_modifications() {
1683        let settings = Settings::default()
1684            .with_user_agent("Test/1.0")
1685            .with_retry_disabled()
1686            .with_user_agent("Test/2.0");
1687        assert_eq!(settings.user_agent, "Test/2.0");
1688        assert!(!settings.retry_enabled);
1689    }
1690
1691    // ==================== DEFAULT_USER_AGENT Tests ====================
1692
1693    #[test]
1694    fn test_default_user_agent_is_valid() {
1695        assert!(!DEFAULT_USER_AGENT.is_empty());
1696        assert!(DEFAULT_USER_AGENT.contains("Mozilla"));
1697        assert!(DEFAULT_USER_AGENT.contains("Chrome"));
1698    }
1699
1700    #[test]
1701    fn test_default_user_agent_in_settings() {
1702        let settings = Settings::default();
1703        assert_eq!(settings.user_agent, DEFAULT_USER_AGENT);
1704    }
1705
1706    // ==================== create_client builder variations ====================
1707
1708    #[test]
1709    fn test_create_client_all_defaults() {
1710        let settings = Settings::default();
1711        let result = create_client(&settings);
1712        assert!(result.is_ok());
1713    }
1714
1715    #[test]
1716    fn test_create_client_minimal_tls_config() {
1717        let settings = Settings {
1718            tls: TlsConfig::default(),
1719            ..Default::default()
1720        };
1721        let result = create_client(&settings);
1722        assert!(result.is_ok());
1723    }
1724
1725    #[test]
1726    fn test_create_client_http1_only() {
1727        let settings = Settings::default();
1728        let result = create_client(&settings);
1729        assert!(result.is_ok());
1730        // HTTP/1.1 only is configured (verified by no_gzip and http1_only)
1731    }
1732
1733    // ==================== PinningVerifier additional tests ====================
1734
1735    #[test]
1736    fn test_pinning_verifier_single_char_subdomain() {
1737        assert!(PinningVerifier::is_valid_domain("a.speedtest.net"));
1738        assert!(PinningVerifier::is_valid_domain("z.ookla.com"));
1739    }
1740
1741    #[test]
1742    fn test_pinning_verifier_numbers_in_subdomain() {
1743        // Numbers in subdomain are valid
1744        assert!(PinningVerifier::is_valid_domain("123.speedtest.net")); // valid subdomain
1745        assert!(!PinningVerifier::is_valid_domain("speedtest123.net")); // not valid, doesn't end with .speedtest.net
1746        assert!(!PinningVerifier::is_valid_domain("123speedtest.net")); // prefix attack
1747    }
1748
1749    #[test]
1750    fn test_pinning_verifier_unicode_in_subdomain() {
1751        // Unicode subdomains should still match the ends_with check
1752        assert!(PinningVerifier::is_valid_domain("münchen.speedtest.net"));
1753    }
1754
1755    #[test]
1756    fn test_pinning_verifier_empty_cert_with_valid_domain() {
1757        let verifier = PinningVerifier::new();
1758        let dns_name =
1759            rustls::pki_types::DnsName::try_from("cdn.speedtest.net".to_string()).unwrap();
1760        let server_name = ServerName::DnsName(dns_name);
1761        let cert_der = CertificateDer::from(vec![]);
1762
1763        // Domain is valid, but cert structure is invalid
1764        let result =
1765            verifier.verify_server_cert(&cert_der, &[], &server_name, &[], UnixTime::now());
1766        assert!(result.is_err());
1767    }
1768
1769    #[test]
1770    fn test_pinning_verifier_subdomain_with_dashes() {
1771        assert!(PinningVerifier::is_valid_domain(
1772            "my-custom-subdomain.speedtest.net"
1773        ));
1774        assert!(PinningVerifier::is_valid_domain("api-v2.ookla.com"));
1775    }
1776
1777    #[test]
1778    fn test_pinning_verifier_long_subdomain() {
1779        let long_subdomain = "a".repeat(63) + ".speedtest.net";
1780        // This should be valid as it ends with .speedtest.net
1781        assert!(PinningVerifier::is_valid_domain(&long_subdomain));
1782    }
1783
1784    #[test]
1785    fn test_pinning_verifier_concatenation_attack() {
1786        // These should all be rejected as they don't end with valid suffixes
1787        assert!(!PinningVerifier::is_valid_domain("speedtestXnet"));
1788        assert!(!PinningVerifier::is_valid_domain("speedtestXcom"));
1789        assert!(!PinningVerifier::is_valid_domain("ooklaXcom"));
1790        assert!(!PinningVerifier::is_valid_domain("ooklaXnet"));
1791    }
1792
1793    // ==================== Settings additional chain tests ====================
1794
1795    #[test]
1796    fn test_settings_retry_disabled_chain() {
1797        let settings = Settings::default().with_retry_disabled();
1798        assert!(!settings.retry_enabled);
1799
1800        // Ensure other defaults are still set
1801        assert_eq!(settings.timeout_secs, 10);
1802        assert_eq!(settings.user_agent, DEFAULT_USER_AGENT);
1803    }
1804
1805    #[test]
1806    fn test_settings_user_agent_chain() {
1807        let settings = Settings::default()
1808            .with_user_agent("Custom/1.0")
1809            .with_user_agent("Custom/2.0");
1810        assert_eq!(settings.user_agent, "Custom/2.0");
1811    }
1812
1813    // ==================== create_client edge cases ====================
1814
1815    #[test]
1816    fn test_create_client_source_ip_loopback_v4() {
1817        let settings = Settings {
1818            source_ip: Some("127.0.0.1".to_string()),
1819            ..Default::default()
1820        };
1821        let result = create_client(&settings);
1822        // Loopback should work
1823        match result {
1824            Ok(_) | Err(Error::NetworkError(_) | Error::Context { .. }) => {}
1825            Err(e) => panic!("Unexpected error: {e:?}"),
1826        }
1827    }
1828
1829    #[test]
1830    fn test_create_client_source_ip_loopback_v6() {
1831        let settings = Settings {
1832            source_ip: Some("::1".to_string()),
1833            ..Default::default()
1834        };
1835        let result = create_client(&settings);
1836        // Loopback should work
1837        match result {
1838            Ok(_) | Err(Error::NetworkError(_) | Error::Context { .. }) => {}
1839            Err(e) => panic!("Unexpected error: {e:?}"),
1840        }
1841    }
1842
1843    #[test]
1844    fn test_create_client_source_ip_unspecified() {
1845        let settings = Settings {
1846            source_ip: Some("0.0.0.0".to_string()),
1847            ..Default::default()
1848        };
1849        let result = create_client(&settings);
1850        // Unspecified should work
1851        match result {
1852            Ok(_) | Err(Error::NetworkError(_) | Error::Context { .. }) => {}
1853            Err(e) => panic!("Unexpected error: {e:?}"),
1854        }
1855    }
1856
1857    #[test]
1858    fn test_create_client_source_ip_with_tls() {
1859        let settings = Settings {
1860            source_ip: Some("127.0.0.1".to_string()),
1861            tls: TlsConfig::default(),
1862            ..Default::default()
1863        };
1864        let result = create_client(&settings);
1865        // Should work with default TLS or gracefully handle errors
1866        match result {
1867            Ok(_) | Err(Error::NetworkError(_) | Error::Context { .. }) => {}
1868            Err(e) => panic!("Unexpected error: {e:?}"),
1869        }
1870    }
1871}