Skip to main content

purple_ssh/providers/
mod.rs

1pub mod aws;
2pub mod azure;
3pub mod config;
4mod digitalocean;
5pub mod gcp;
6mod hetzner;
7mod leaseweb;
8mod linode;
9pub mod oracle;
10pub mod ovh;
11mod proxmox;
12pub mod scaleway;
13pub mod sync;
14mod tailscale;
15mod upcloud;
16mod vultr;
17
18use std::sync::atomic::AtomicBool;
19
20use thiserror::Error;
21
22/// A host discovered from a cloud provider API.
23#[derive(Debug, Clone)]
24#[allow(dead_code)]
25pub struct ProviderHost {
26    /// Provider-assigned server ID.
27    pub server_id: String,
28    /// Server name/label.
29    pub name: String,
30    /// Public IP address (IPv4 or IPv6).
31    pub ip: String,
32    /// Provider tags/labels.
33    pub tags: Vec<String>,
34    /// Provider metadata (region, plan, etc.) as key-value pairs.
35    pub metadata: Vec<(String, String)>,
36}
37
38impl ProviderHost {
39    /// Create a ProviderHost with no metadata.
40    #[allow(dead_code)]
41    pub fn new(server_id: String, name: String, ip: String, tags: Vec<String>) -> Self {
42        Self {
43            server_id,
44            name,
45            ip,
46            tags,
47            metadata: Vec::new(),
48        }
49    }
50}
51
52/// Errors from provider API calls.
53#[derive(Debug, Error)]
54pub enum ProviderError {
55    #[error("HTTP error: {0}")]
56    Http(String),
57    #[error("Failed to parse response: {0}")]
58    Parse(String),
59    #[error("Authentication failed. Check your API token.")]
60    AuthFailed,
61    #[error("Rate limited. Try again in a moment.")]
62    RateLimited,
63    #[error("{0}")]
64    Execute(String),
65    #[error("Cancelled.")]
66    Cancelled,
67    /// Some hosts were fetched but others failed. The caller should use the
68    /// hosts but suppress destructive operations like --remove.
69    #[error("Partial result: {failures} of {total} failed")]
70    PartialResult {
71        hosts: Vec<ProviderHost>,
72        failures: usize,
73        total: usize,
74    },
75}
76
77/// Trait implemented by each cloud provider.
78pub trait Provider {
79    /// Full provider name (e.g. "digitalocean").
80    fn name(&self) -> &str;
81    /// Short label for aliases (e.g. "do").
82    fn short_label(&self) -> &str;
83    /// Fetch hosts with cancellation support.
84    fn fetch_hosts_cancellable(
85        &self,
86        token: &str,
87        cancel: &AtomicBool,
88    ) -> Result<Vec<ProviderHost>, ProviderError>;
89    /// Fetch all servers from the provider API.
90    #[allow(dead_code)]
91    fn fetch_hosts(&self, token: &str) -> Result<Vec<ProviderHost>, ProviderError> {
92        self.fetch_hosts_cancellable(token, &AtomicBool::new(false))
93    }
94    /// Fetch hosts with progress reporting. Default delegates to fetch_hosts_cancellable.
95    fn fetch_hosts_with_progress(
96        &self,
97        token: &str,
98        cancel: &AtomicBool,
99        _progress: &dyn Fn(&str),
100    ) -> Result<Vec<ProviderHost>, ProviderError> {
101        self.fetch_hosts_cancellable(token, cancel)
102    }
103}
104
105/// All known provider names.
106pub const PROVIDER_NAMES: &[&str] = &[
107    "digitalocean",
108    "vultr",
109    "linode",
110    "hetzner",
111    "upcloud",
112    "proxmox",
113    "aws",
114    "scaleway",
115    "gcp",
116    "azure",
117    "tailscale",
118    "oracle",
119    "ovh",
120    "leaseweb",
121];
122
123/// Get a provider implementation by name.
124pub fn get_provider(name: &str) -> Option<Box<dyn Provider>> {
125    match name {
126        "digitalocean" => Some(Box::new(digitalocean::DigitalOcean)),
127        "vultr" => Some(Box::new(vultr::Vultr)),
128        "linode" => Some(Box::new(linode::Linode)),
129        "hetzner" => Some(Box::new(hetzner::Hetzner)),
130        "upcloud" => Some(Box::new(upcloud::UpCloud)),
131        "proxmox" => Some(Box::new(proxmox::Proxmox {
132            base_url: String::new(),
133            verify_tls: true,
134        })),
135        "aws" => Some(Box::new(aws::Aws {
136            regions: Vec::new(),
137            profile: String::new(),
138        })),
139        "scaleway" => Some(Box::new(scaleway::Scaleway { zones: Vec::new() })),
140        "gcp" => Some(Box::new(gcp::Gcp {
141            zones: Vec::new(),
142            project: String::new(),
143        })),
144        "azure" => Some(Box::new(azure::Azure {
145            subscriptions: Vec::new(),
146        })),
147        "tailscale" => Some(Box::new(tailscale::Tailscale)),
148        "oracle" => Some(Box::new(oracle::Oracle {
149            regions: Vec::new(),
150            compartment: String::new(),
151        })),
152        "ovh" => Some(Box::new(ovh::Ovh {
153            project: String::new(),
154            endpoint: String::new(),
155        })),
156        "leaseweb" => Some(Box::new(leaseweb::Leaseweb)),
157        _ => None,
158    }
159}
160
161/// Get a provider implementation configured from a provider section.
162/// For providers that need extra config (e.g. Proxmox base URL), this
163/// creates a properly configured instance.
164pub fn get_provider_with_config(
165    name: &str,
166    section: &config::ProviderSection,
167) -> Option<Box<dyn Provider>> {
168    match name {
169        "proxmox" => Some(Box::new(proxmox::Proxmox {
170            base_url: section.url.clone(),
171            verify_tls: section.verify_tls,
172        })),
173        "aws" => Some(Box::new(aws::Aws {
174            regions: section
175                .regions
176                .split(',')
177                .map(|s| s.trim().to_string())
178                .filter(|s| !s.is_empty())
179                .collect(),
180            profile: section.profile.clone(),
181        })),
182        "scaleway" => Some(Box::new(scaleway::Scaleway {
183            zones: section
184                .regions
185                .split(',')
186                .map(|s| s.trim().to_string())
187                .filter(|s| !s.is_empty())
188                .collect(),
189        })),
190        "gcp" => Some(Box::new(gcp::Gcp {
191            zones: section
192                .regions
193                .split(',')
194                .map(|s| s.trim().to_string())
195                .filter(|s| !s.is_empty())
196                .collect(),
197            project: section.project.clone(),
198        })),
199        "azure" => Some(Box::new(azure::Azure {
200            subscriptions: section
201                .regions
202                .split(',')
203                .map(|s| s.trim().to_string())
204                .filter(|s| !s.is_empty())
205                .collect(),
206        })),
207        "oracle" => Some(Box::new(oracle::Oracle {
208            regions: section
209                .regions
210                .split(',')
211                .map(|s| s.trim().to_string())
212                .filter(|s| !s.is_empty())
213                .collect(),
214            compartment: section.compartment.clone(),
215        })),
216        "ovh" => Some(Box::new(ovh::Ovh {
217            project: section.project.clone(),
218            endpoint: section.regions.clone(),
219        })),
220        _ => get_provider(name),
221    }
222}
223
224/// Display name for a provider (e.g. "digitalocean" -> "DigitalOcean").
225pub fn provider_display_name(name: &str) -> &str {
226    match name {
227        "digitalocean" => "DigitalOcean",
228        "vultr" => "Vultr",
229        "linode" => "Linode",
230        "hetzner" => "Hetzner",
231        "upcloud" => "UpCloud",
232        "proxmox" => "Proxmox VE",
233        "aws" => "AWS EC2",
234        "scaleway" => "Scaleway",
235        "gcp" => "GCP",
236        "azure" => "Azure",
237        "tailscale" => "Tailscale",
238        "oracle" => "Oracle Cloud",
239        "ovh" => "OVHcloud",
240        "leaseweb" => "Leaseweb",
241        other => other,
242    }
243}
244
245/// Create an HTTP agent with explicit timeouts.
246pub(crate) fn http_agent() -> ureq::Agent {
247    ureq::Agent::config_builder()
248        .timeout_global(Some(std::time::Duration::from_secs(30)))
249        .max_redirects(0)
250        .build()
251        .new_agent()
252}
253
254/// Create an HTTP agent that accepts invalid/self-signed TLS certificates.
255pub(crate) fn http_agent_insecure() -> Result<ureq::Agent, ProviderError> {
256    Ok(ureq::Agent::config_builder()
257        .timeout_global(Some(std::time::Duration::from_secs(30)))
258        .max_redirects(0)
259        .tls_config(
260            ureq::tls::TlsConfig::builder()
261                .provider(ureq::tls::TlsProvider::NativeTls)
262                .disable_verification(true)
263                .build(),
264        )
265        .build()
266        .new_agent())
267}
268
269/// Strip CIDR suffix (/64, /128, etc.) from an IP address.
270/// Some provider APIs return IPv6 addresses with prefix length (e.g. "2600:3c00::1/128").
271/// SSH requires bare addresses without CIDR notation.
272pub(crate) fn strip_cidr(ip: &str) -> &str {
273    // Only strip if it looks like a CIDR suffix (slash followed by digits)
274    if let Some(pos) = ip.rfind('/') {
275        if ip[pos + 1..].bytes().all(|b| b.is_ascii_digit()) && pos + 1 < ip.len() {
276            return &ip[..pos];
277        }
278    }
279    ip
280}
281
282/// RFC 3986 percent-encoding for URL query parameters.
283/// Encodes all characters except unreserved ones (A-Z, a-z, 0-9, '-', '_', '.', '~').
284pub(crate) fn percent_encode(s: &str) -> String {
285    let mut result = String::with_capacity(s.len());
286    for byte in s.bytes() {
287        match byte {
288            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
289                result.push(byte as char);
290            }
291            _ => {
292                result.push_str(&format!("%{:02X}", byte));
293            }
294        }
295    }
296    result
297}
298
299/// Date components from a Unix epoch timestamp (no chrono dependency).
300pub(crate) struct EpochDate {
301    pub year: u64,
302    pub month: u64, // 1-based
303    pub day: u64,   // 1-based
304    pub hours: u64,
305    pub minutes: u64,
306    pub seconds: u64,
307    /// Days since epoch (for weekday calculation)
308    pub epoch_days: u64,
309}
310
311/// Convert Unix epoch seconds to date components.
312pub(crate) fn epoch_to_date(epoch_secs: u64) -> EpochDate {
313    let secs_per_day = 86400u64;
314    let epoch_days = epoch_secs / secs_per_day;
315    let mut remaining_days = epoch_days;
316    let day_secs = epoch_secs % secs_per_day;
317
318    let mut year = 1970u64;
319    loop {
320        let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
321        let days_in_year = if leap { 366 } else { 365 };
322        if remaining_days < days_in_year {
323            break;
324        }
325        remaining_days -= days_in_year;
326        year += 1;
327    }
328
329    let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
330    let days_per_month: [u64; 12] = [
331        31,
332        if leap { 29 } else { 28 },
333        31,
334        30,
335        31,
336        30,
337        31,
338        31,
339        30,
340        31,
341        30,
342        31,
343    ];
344    let mut month = 0usize;
345    while month < 12 && remaining_days >= days_per_month[month] {
346        remaining_days -= days_per_month[month];
347        month += 1;
348    }
349
350    EpochDate {
351        year,
352        month: (month + 1) as u64,
353        day: remaining_days + 1,
354        hours: day_secs / 3600,
355        minutes: (day_secs % 3600) / 60,
356        seconds: day_secs % 60,
357        epoch_days,
358    }
359}
360
361/// Map a ureq error to a ProviderError.
362fn map_ureq_error(err: ureq::Error) -> ProviderError {
363    match err {
364        ureq::Error::StatusCode(code) => match code {
365            401 | 403 => ProviderError::AuthFailed,
366            429 => ProviderError::RateLimited,
367            _ => ProviderError::Http(format!("HTTP {}", code)),
368        },
369        other => ProviderError::Http(other.to_string()),
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    // =========================================================================
378    // strip_cidr tests
379    // =========================================================================
380
381    #[test]
382    fn test_strip_cidr_ipv6_with_prefix() {
383        assert_eq!(strip_cidr("2600:3c00::1/128"), "2600:3c00::1");
384        assert_eq!(strip_cidr("2a01:4f8::1/64"), "2a01:4f8::1");
385    }
386
387    #[test]
388    fn test_strip_cidr_bare_ipv6() {
389        assert_eq!(strip_cidr("2600:3c00::1"), "2600:3c00::1");
390    }
391
392    #[test]
393    fn test_strip_cidr_ipv4_passthrough() {
394        assert_eq!(strip_cidr("1.2.3.4"), "1.2.3.4");
395        assert_eq!(strip_cidr("10.0.0.1/24"), "10.0.0.1");
396    }
397
398    #[test]
399    fn test_strip_cidr_empty() {
400        assert_eq!(strip_cidr(""), "");
401    }
402
403    #[test]
404    fn test_strip_cidr_slash_without_digits() {
405        // Shouldn't strip if after slash there are non-digits
406        assert_eq!(strip_cidr("path/to/something"), "path/to/something");
407    }
408
409    #[test]
410    fn test_strip_cidr_trailing_slash() {
411        // Trailing slash with nothing after: pos+1 == ip.len(), should NOT strip
412        assert_eq!(strip_cidr("1.2.3.4/"), "1.2.3.4/");
413    }
414
415    // =========================================================================
416    // percent_encode tests
417    // =========================================================================
418
419    #[test]
420    fn test_percent_encode_unreserved_passthrough() {
421        assert_eq!(percent_encode("abc123-_.~"), "abc123-_.~");
422    }
423
424    #[test]
425    fn test_percent_encode_spaces_and_specials() {
426        assert_eq!(percent_encode("hello world"), "hello%20world");
427        assert_eq!(percent_encode("a=b&c"), "a%3Db%26c");
428        assert_eq!(percent_encode("/path"), "%2Fpath");
429    }
430
431    #[test]
432    fn test_percent_encode_empty() {
433        assert_eq!(percent_encode(""), "");
434    }
435
436    #[test]
437    fn test_percent_encode_plus_equals_slash() {
438        assert_eq!(percent_encode("a+b=c/d"), "a%2Bb%3Dc%2Fd");
439    }
440
441    // =========================================================================
442    // epoch_to_date tests
443    // =========================================================================
444
445    #[test]
446    fn test_epoch_to_date_unix_epoch() {
447        let d = epoch_to_date(0);
448        assert_eq!((d.year, d.month, d.day), (1970, 1, 1));
449        assert_eq!((d.hours, d.minutes, d.seconds), (0, 0, 0));
450    }
451
452    #[test]
453    fn test_epoch_to_date_known_date() {
454        // 2024-01-15 12:30:45 UTC = 1705321845
455        let d = epoch_to_date(1705321845);
456        assert_eq!((d.year, d.month, d.day), (2024, 1, 15));
457        assert_eq!((d.hours, d.minutes, d.seconds), (12, 30, 45));
458    }
459
460    #[test]
461    fn test_epoch_to_date_leap_year() {
462        // 2024-02-29 00:00:00 UTC = 1709164800
463        let d = epoch_to_date(1709164800);
464        assert_eq!((d.year, d.month, d.day), (2024, 2, 29));
465    }
466
467    #[test]
468    fn test_epoch_to_date_end_of_year() {
469        // 2023-12-31 23:59:59 UTC = 1704067199
470        let d = epoch_to_date(1704067199);
471        assert_eq!((d.year, d.month, d.day), (2023, 12, 31));
472        assert_eq!((d.hours, d.minutes, d.seconds), (23, 59, 59));
473    }
474
475    // =========================================================================
476    // get_provider factory tests
477    // =========================================================================
478
479    #[test]
480    fn test_get_provider_digitalocean() {
481        let p = get_provider("digitalocean").unwrap();
482        assert_eq!(p.name(), "digitalocean");
483        assert_eq!(p.short_label(), "do");
484    }
485
486    #[test]
487    fn test_get_provider_vultr() {
488        let p = get_provider("vultr").unwrap();
489        assert_eq!(p.name(), "vultr");
490        assert_eq!(p.short_label(), "vultr");
491    }
492
493    #[test]
494    fn test_get_provider_linode() {
495        let p = get_provider("linode").unwrap();
496        assert_eq!(p.name(), "linode");
497        assert_eq!(p.short_label(), "linode");
498    }
499
500    #[test]
501    fn test_get_provider_hetzner() {
502        let p = get_provider("hetzner").unwrap();
503        assert_eq!(p.name(), "hetzner");
504        assert_eq!(p.short_label(), "hetzner");
505    }
506
507    #[test]
508    fn test_get_provider_upcloud() {
509        let p = get_provider("upcloud").unwrap();
510        assert_eq!(p.name(), "upcloud");
511        assert_eq!(p.short_label(), "uc");
512    }
513
514    #[test]
515    fn test_get_provider_proxmox() {
516        let p = get_provider("proxmox").unwrap();
517        assert_eq!(p.name(), "proxmox");
518        assert_eq!(p.short_label(), "pve");
519    }
520
521    #[test]
522    fn test_get_provider_unknown_returns_none() {
523        assert!(get_provider("unknown_provider").is_none());
524        assert!(get_provider("").is_none());
525        assert!(get_provider("DigitalOcean").is_none()); // case-sensitive
526    }
527
528    #[test]
529    fn test_get_provider_all_names_resolve() {
530        for name in PROVIDER_NAMES {
531            assert!(
532                get_provider(name).is_some(),
533                "Provider '{}' should resolve",
534                name
535            );
536        }
537    }
538
539    // =========================================================================
540    // get_provider_with_config tests
541    // =========================================================================
542
543    #[test]
544    fn test_get_provider_with_config_proxmox_uses_url() {
545        let section = config::ProviderSection {
546            provider: "proxmox".to_string(),
547            token: "user@pam!token=secret".to_string(),
548            alias_prefix: "pve-".to_string(),
549            user: String::new(),
550            identity_file: String::new(),
551            url: "https://pve.example.com:8006".to_string(),
552            verify_tls: false,
553            auto_sync: false,
554            profile: String::new(),
555            regions: String::new(),
556            project: String::new(),
557            compartment: String::new(),
558        };
559        let p = get_provider_with_config("proxmox", &section).unwrap();
560        assert_eq!(p.name(), "proxmox");
561    }
562
563    #[test]
564    fn test_get_provider_with_config_non_proxmox_delegates() {
565        let section = config::ProviderSection {
566            provider: "digitalocean".to_string(),
567            token: "do-token".to_string(),
568            alias_prefix: "do-".to_string(),
569            user: String::new(),
570            identity_file: String::new(),
571            url: String::new(),
572            verify_tls: true,
573            auto_sync: true,
574            profile: String::new(),
575            regions: String::new(),
576            project: String::new(),
577            compartment: String::new(),
578        };
579        let p = get_provider_with_config("digitalocean", &section).unwrap();
580        assert_eq!(p.name(), "digitalocean");
581    }
582
583    #[test]
584    fn test_get_provider_with_config_gcp_uses_project_and_zones() {
585        let section = config::ProviderSection {
586            provider: "gcp".to_string(),
587            token: "sa.json".to_string(),
588            alias_prefix: "gcp".to_string(),
589            user: String::new(),
590            identity_file: String::new(),
591            url: String::new(),
592            verify_tls: true,
593            auto_sync: true,
594            profile: String::new(),
595            regions: "us-central1-a, europe-west1-b".to_string(),
596            project: "my-project".to_string(),
597            compartment: String::new(),
598        };
599        let p = get_provider_with_config("gcp", &section).unwrap();
600        assert_eq!(p.name(), "gcp");
601    }
602
603    #[test]
604    fn test_get_provider_with_config_unknown_returns_none() {
605        let section = config::ProviderSection {
606            provider: "unknown_provider".to_string(),
607            token: String::new(),
608            alias_prefix: String::new(),
609            user: String::new(),
610            identity_file: String::new(),
611            url: String::new(),
612            verify_tls: true,
613            auto_sync: true,
614            profile: String::new(),
615            regions: String::new(),
616            project: String::new(),
617            compartment: String::new(),
618        };
619        assert!(get_provider_with_config("unknown_provider", &section).is_none());
620    }
621
622    // =========================================================================
623    // provider_display_name tests
624    // =========================================================================
625
626    #[test]
627    fn test_display_name_all_providers() {
628        assert_eq!(provider_display_name("digitalocean"), "DigitalOcean");
629        assert_eq!(provider_display_name("vultr"), "Vultr");
630        assert_eq!(provider_display_name("linode"), "Linode");
631        assert_eq!(provider_display_name("hetzner"), "Hetzner");
632        assert_eq!(provider_display_name("upcloud"), "UpCloud");
633        assert_eq!(provider_display_name("proxmox"), "Proxmox VE");
634        assert_eq!(provider_display_name("aws"), "AWS EC2");
635        assert_eq!(provider_display_name("scaleway"), "Scaleway");
636        assert_eq!(provider_display_name("gcp"), "GCP");
637        assert_eq!(provider_display_name("azure"), "Azure");
638        assert_eq!(provider_display_name("tailscale"), "Tailscale");
639        assert_eq!(provider_display_name("oracle"), "Oracle Cloud");
640        assert_eq!(provider_display_name("ovh"), "OVHcloud");
641        assert_eq!(provider_display_name("leaseweb"), "Leaseweb");
642    }
643
644    #[test]
645    fn test_display_name_unknown_returns_input() {
646        assert_eq!(
647            provider_display_name("unknown_provider"),
648            "unknown_provider"
649        );
650        assert_eq!(provider_display_name(""), "");
651    }
652
653    // =========================================================================
654    // PROVIDER_NAMES constant tests
655    // =========================================================================
656
657    #[test]
658    fn test_provider_names_count() {
659        assert_eq!(PROVIDER_NAMES.len(), 14);
660    }
661
662    #[test]
663    fn test_provider_names_contains_all() {
664        assert!(PROVIDER_NAMES.contains(&"digitalocean"));
665        assert!(PROVIDER_NAMES.contains(&"vultr"));
666        assert!(PROVIDER_NAMES.contains(&"linode"));
667        assert!(PROVIDER_NAMES.contains(&"hetzner"));
668        assert!(PROVIDER_NAMES.contains(&"upcloud"));
669        assert!(PROVIDER_NAMES.contains(&"proxmox"));
670        assert!(PROVIDER_NAMES.contains(&"aws"));
671        assert!(PROVIDER_NAMES.contains(&"scaleway"));
672        assert!(PROVIDER_NAMES.contains(&"gcp"));
673        assert!(PROVIDER_NAMES.contains(&"azure"));
674        assert!(PROVIDER_NAMES.contains(&"tailscale"));
675        assert!(PROVIDER_NAMES.contains(&"oracle"));
676        assert!(PROVIDER_NAMES.contains(&"ovh"));
677        assert!(PROVIDER_NAMES.contains(&"leaseweb"));
678    }
679
680    // =========================================================================
681    // ProviderError display tests
682    // =========================================================================
683
684    #[test]
685    fn test_provider_error_display_http() {
686        let err = ProviderError::Http("connection refused".to_string());
687        assert_eq!(format!("{}", err), "HTTP error: connection refused");
688    }
689
690    #[test]
691    fn test_provider_error_display_parse() {
692        let err = ProviderError::Parse("invalid JSON".to_string());
693        assert_eq!(format!("{}", err), "Failed to parse response: invalid JSON");
694    }
695
696    #[test]
697    fn test_provider_error_display_auth() {
698        let err = ProviderError::AuthFailed;
699        assert!(format!("{}", err).contains("Authentication failed"));
700    }
701
702    #[test]
703    fn test_provider_error_display_rate_limited() {
704        let err = ProviderError::RateLimited;
705        assert!(format!("{}", err).contains("Rate limited"));
706    }
707
708    #[test]
709    fn test_provider_error_display_cancelled() {
710        let err = ProviderError::Cancelled;
711        assert_eq!(format!("{}", err), "Cancelled.");
712    }
713
714    #[test]
715    fn test_provider_error_display_partial_result() {
716        let err = ProviderError::PartialResult {
717            hosts: vec![],
718            failures: 3,
719            total: 10,
720        };
721        assert!(format!("{}", err).contains("3 of 10 failed"));
722    }
723
724    // =========================================================================
725    // ProviderHost struct tests
726    // =========================================================================
727
728    #[test]
729    fn test_provider_host_construction() {
730        let host = ProviderHost::new(
731            "12345".to_string(),
732            "web-01".to_string(),
733            "1.2.3.4".to_string(),
734            vec!["prod".to_string(), "web".to_string()],
735        );
736        assert_eq!(host.server_id, "12345");
737        assert_eq!(host.name, "web-01");
738        assert_eq!(host.ip, "1.2.3.4");
739        assert_eq!(host.tags.len(), 2);
740    }
741
742    #[test]
743    fn test_provider_host_clone() {
744        let host = ProviderHost::new(
745            "1".to_string(),
746            "a".to_string(),
747            "1.1.1.1".to_string(),
748            vec![],
749        );
750        let cloned = host.clone();
751        assert_eq!(cloned.server_id, host.server_id);
752        assert_eq!(cloned.name, host.name);
753    }
754
755    // =========================================================================
756    // strip_cidr additional edge cases
757    // =========================================================================
758
759    #[test]
760    fn test_strip_cidr_ipv6_with_64() {
761        assert_eq!(strip_cidr("2a01:4f8::1/64"), "2a01:4f8::1");
762    }
763
764    #[test]
765    fn test_strip_cidr_ipv4_with_32() {
766        assert_eq!(strip_cidr("1.2.3.4/32"), "1.2.3.4");
767    }
768
769    #[test]
770    fn test_strip_cidr_ipv4_with_8() {
771        assert_eq!(strip_cidr("10.0.0.1/8"), "10.0.0.1");
772    }
773
774    #[test]
775    fn test_strip_cidr_just_slash() {
776        // "/" alone: pos=0, pos+1=1=len -> condition fails
777        assert_eq!(strip_cidr("/"), "/");
778    }
779
780    #[test]
781    fn test_strip_cidr_slash_with_letters() {
782        assert_eq!(strip_cidr("10.0.0.1/abc"), "10.0.0.1/abc");
783    }
784
785    #[test]
786    fn test_strip_cidr_multiple_slashes() {
787        // rfind gets last slash: "48" is digits, so it strips the last /48
788        assert_eq!(strip_cidr("10.0.0.1/24/48"), "10.0.0.1/24");
789    }
790
791    #[test]
792    fn test_strip_cidr_ipv6_full_notation() {
793        assert_eq!(
794            strip_cidr("2001:0db8:85a3:0000:0000:8a2e:0370:7334/128"),
795            "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
796        );
797    }
798
799    // =========================================================================
800    // ProviderError Debug
801    // =========================================================================
802
803    #[test]
804    fn test_provider_error_debug_http() {
805        let err = ProviderError::Http("timeout".to_string());
806        let debug = format!("{:?}", err);
807        assert!(debug.contains("Http"));
808        assert!(debug.contains("timeout"));
809    }
810
811    #[test]
812    fn test_provider_error_debug_partial_result() {
813        let err = ProviderError::PartialResult {
814            hosts: vec![ProviderHost::new(
815                "1".to_string(),
816                "web".to_string(),
817                "1.2.3.4".to_string(),
818                vec![],
819            )],
820            failures: 2,
821            total: 5,
822        };
823        let debug = format!("{:?}", err);
824        assert!(debug.contains("PartialResult"));
825        assert!(debug.contains("failures: 2"));
826    }
827
828    // =========================================================================
829    // ProviderHost with empty fields
830    // =========================================================================
831
832    #[test]
833    fn test_provider_host_empty_fields() {
834        let host = ProviderHost::new(String::new(), String::new(), String::new(), vec![]);
835        assert!(host.server_id.is_empty());
836        assert!(host.name.is_empty());
837        assert!(host.ip.is_empty());
838    }
839
840    // =========================================================================
841    // get_provider_with_config for all non-proxmox providers
842    // =========================================================================
843
844    #[test]
845    fn test_get_provider_with_config_all_providers() {
846        for &name in PROVIDER_NAMES {
847            let section = config::ProviderSection {
848                provider: name.to_string(),
849                token: "tok".to_string(),
850                alias_prefix: "test".to_string(),
851                user: String::new(),
852                identity_file: String::new(),
853                url: if name == "proxmox" {
854                    "https://pve:8006".to_string()
855                } else {
856                    String::new()
857                },
858                verify_tls: true,
859                auto_sync: true,
860                profile: String::new(),
861                regions: String::new(),
862                project: String::new(),
863                compartment: String::new(),
864            };
865            let p = get_provider_with_config(name, &section);
866            assert!(
867                p.is_some(),
868                "get_provider_with_config({}) should return Some",
869                name
870            );
871            assert_eq!(p.unwrap().name(), name);
872        }
873    }
874
875    // =========================================================================
876    // Provider trait default methods
877    // =========================================================================
878
879    #[test]
880    fn test_provider_fetch_hosts_delegates_to_cancellable() {
881        let provider = get_provider("digitalocean").unwrap();
882        // fetch_hosts delegates to fetch_hosts_cancellable with AtomicBool(false)
883        // We can't actually test this without a server, but we verify the method exists
884        // by calling it (will fail with network error, which is fine for this test)
885        let result = provider.fetch_hosts("fake-token");
886        assert!(result.is_err()); // Expected: no network
887    }
888
889    // =========================================================================
890    // strip_cidr: suffix starts with digit but contains letters
891    // =========================================================================
892
893    #[test]
894    fn test_strip_cidr_digit_then_letters_not_stripped() {
895        assert_eq!(strip_cidr("10.0.0.1/24abc"), "10.0.0.1/24abc");
896    }
897
898    // =========================================================================
899    // provider_display_name: all known providers
900    // =========================================================================
901
902    #[test]
903    fn test_provider_display_name_all() {
904        assert_eq!(provider_display_name("digitalocean"), "DigitalOcean");
905        assert_eq!(provider_display_name("vultr"), "Vultr");
906        assert_eq!(provider_display_name("linode"), "Linode");
907        assert_eq!(provider_display_name("hetzner"), "Hetzner");
908        assert_eq!(provider_display_name("upcloud"), "UpCloud");
909        assert_eq!(provider_display_name("proxmox"), "Proxmox VE");
910        assert_eq!(provider_display_name("aws"), "AWS EC2");
911        assert_eq!(provider_display_name("scaleway"), "Scaleway");
912        assert_eq!(provider_display_name("gcp"), "GCP");
913        assert_eq!(provider_display_name("azure"), "Azure");
914        assert_eq!(provider_display_name("tailscale"), "Tailscale");
915        assert_eq!(provider_display_name("oracle"), "Oracle Cloud");
916        assert_eq!(provider_display_name("ovh"), "OVHcloud");
917        assert_eq!(provider_display_name("leaseweb"), "Leaseweb");
918    }
919
920    #[test]
921    fn test_provider_display_name_unknown() {
922        assert_eq!(
923            provider_display_name("unknown_provider"),
924            "unknown_provider"
925        );
926    }
927
928    // =========================================================================
929    // get_provider: all known + unknown
930    // =========================================================================
931
932    #[test]
933    fn test_get_provider_all_known() {
934        for name in PROVIDER_NAMES {
935            assert!(
936                get_provider(name).is_some(),
937                "get_provider({}) should return Some",
938                name
939            );
940        }
941    }
942
943    #[test]
944    fn test_get_provider_case_sensitive_and_unknown() {
945        assert!(get_provider("unknown_provider").is_none());
946        assert!(get_provider("DigitalOcean").is_none()); // Case-sensitive
947        assert!(get_provider("VULTR").is_none());
948        assert!(get_provider("").is_none());
949    }
950
951    // =========================================================================
952    // PROVIDER_NAMES constant
953    // =========================================================================
954
955    #[test]
956    fn test_provider_names_has_all_fourteen() {
957        assert_eq!(PROVIDER_NAMES.len(), 14);
958        assert!(PROVIDER_NAMES.contains(&"digitalocean"));
959        assert!(PROVIDER_NAMES.contains(&"proxmox"));
960        assert!(PROVIDER_NAMES.contains(&"aws"));
961        assert!(PROVIDER_NAMES.contains(&"scaleway"));
962        assert!(PROVIDER_NAMES.contains(&"azure"));
963        assert!(PROVIDER_NAMES.contains(&"tailscale"));
964        assert!(PROVIDER_NAMES.contains(&"oracle"));
965        assert!(PROVIDER_NAMES.contains(&"ovh"));
966        assert!(PROVIDER_NAMES.contains(&"leaseweb"));
967    }
968
969    // =========================================================================
970    // Provider short_label via get_provider
971    // =========================================================================
972
973    #[test]
974    fn test_provider_short_labels() {
975        let cases = [
976            ("digitalocean", "do"),
977            ("vultr", "vultr"),
978            ("linode", "linode"),
979            ("hetzner", "hetzner"),
980            ("upcloud", "uc"),
981            ("proxmox", "pve"),
982            ("aws", "aws"),
983            ("scaleway", "scw"),
984            ("gcp", "gcp"),
985            ("azure", "az"),
986            ("tailscale", "ts"),
987            ("oracle", "oci"),
988            ("ovh", "ovh"),
989            ("leaseweb", "lsw"),
990        ];
991        for (name, expected_label) in &cases {
992            let p = get_provider(name).unwrap();
993            assert_eq!(p.short_label(), *expected_label, "short_label for {}", name);
994        }
995    }
996
997    // =========================================================================
998    // http_agent construction tests
999    // =========================================================================
1000
1001    #[test]
1002    fn test_http_agent_creates_agent() {
1003        // Smoke test: agent construction should not panic
1004        let _agent = http_agent();
1005    }
1006
1007    #[test]
1008    fn test_http_agent_insecure_creates_agent() {
1009        // Smoke test: insecure agent construction should succeed
1010        let agent = http_agent_insecure();
1011        assert!(agent.is_ok());
1012    }
1013
1014    // =========================================================================
1015    // map_ureq_error tests
1016    // =========================================================================
1017
1018    #[test]
1019    fn test_map_ureq_error_401_is_auth_failed() {
1020        let err = map_ureq_error(ureq::Error::StatusCode(401));
1021        assert!(matches!(err, ProviderError::AuthFailed));
1022    }
1023
1024    #[test]
1025    fn test_map_ureq_error_403_is_auth_failed() {
1026        let err = map_ureq_error(ureq::Error::StatusCode(403));
1027        assert!(matches!(err, ProviderError::AuthFailed));
1028    }
1029
1030    #[test]
1031    fn test_map_ureq_error_429_is_rate_limited() {
1032        let err = map_ureq_error(ureq::Error::StatusCode(429));
1033        assert!(matches!(err, ProviderError::RateLimited));
1034    }
1035
1036    #[test]
1037    fn test_map_ureq_error_500_is_http() {
1038        let err = map_ureq_error(ureq::Error::StatusCode(500));
1039        match err {
1040            ProviderError::Http(msg) => assert_eq!(msg, "HTTP 500"),
1041            other => panic!("expected Http, got {:?}", other),
1042        }
1043    }
1044
1045    #[test]
1046    fn test_map_ureq_error_404_is_http() {
1047        let err = map_ureq_error(ureq::Error::StatusCode(404));
1048        match err {
1049            ProviderError::Http(msg) => assert_eq!(msg, "HTTP 404"),
1050            other => panic!("expected Http, got {:?}", other),
1051        }
1052    }
1053
1054    #[test]
1055    fn test_map_ureq_error_502_is_http() {
1056        let err = map_ureq_error(ureq::Error::StatusCode(502));
1057        match err {
1058            ProviderError::Http(msg) => assert_eq!(msg, "HTTP 502"),
1059            other => panic!("expected Http, got {:?}", other),
1060        }
1061    }
1062
1063    #[test]
1064    fn test_map_ureq_error_503_is_http() {
1065        let err = map_ureq_error(ureq::Error::StatusCode(503));
1066        match err {
1067            ProviderError::Http(msg) => assert_eq!(msg, "HTTP 503"),
1068            other => panic!("expected Http, got {:?}", other),
1069        }
1070    }
1071
1072    #[test]
1073    fn test_map_ureq_error_200_is_http() {
1074        // Edge case: 200 should still map (even though it shouldn't occur in practice)
1075        let err = map_ureq_error(ureq::Error::StatusCode(200));
1076        match err {
1077            ProviderError::Http(msg) => assert_eq!(msg, "HTTP 200"),
1078            other => panic!("expected Http, got {:?}", other),
1079        }
1080    }
1081
1082    #[test]
1083    fn test_map_ureq_error_non_status_is_http() {
1084        // Transport/other errors should map to Http with a message
1085        let err = map_ureq_error(ureq::Error::HostNotFound);
1086        match err {
1087            ProviderError::Http(msg) => assert!(!msg.is_empty()),
1088            other => panic!("expected Http, got {:?}", other),
1089        }
1090    }
1091
1092    #[test]
1093    fn test_map_ureq_error_all_auth_codes_covered() {
1094        // Verify only 401 and 403 produce AuthFailed (not 400, 402, etc.)
1095        for code in [400, 402, 405, 406, 407, 408, 409, 410] {
1096            let err = map_ureq_error(ureq::Error::StatusCode(code));
1097            assert!(
1098                matches!(err, ProviderError::Http(_)),
1099                "status {} should be Http, not AuthFailed",
1100                code
1101            );
1102        }
1103    }
1104
1105    #[test]
1106    fn test_map_ureq_error_only_429_is_rate_limited() {
1107        // Verify only 429 produces RateLimited
1108        for code in [428, 430, 431] {
1109            let err = map_ureq_error(ureq::Error::StatusCode(code));
1110            assert!(
1111                !matches!(err, ProviderError::RateLimited),
1112                "status {} should not be RateLimited",
1113                code
1114            );
1115        }
1116    }
1117
1118    #[test]
1119    fn test_map_ureq_error_io_error() {
1120        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1121        let err = map_ureq_error(ureq::Error::Io(io_err));
1122        match err {
1123            ProviderError::Http(msg) => assert!(msg.contains("refused"), "got: {}", msg),
1124            other => panic!("expected Http, got {:?}", other),
1125        }
1126    }
1127
1128    #[test]
1129    fn test_map_ureq_error_timeout() {
1130        let err = map_ureq_error(ureq::Error::Timeout(ureq::Timeout::Global));
1131        match err {
1132            ProviderError::Http(msg) => assert!(!msg.is_empty()),
1133            other => panic!("expected Http, got {:?}", other),
1134        }
1135    }
1136
1137    #[test]
1138    fn test_map_ureq_error_connection_failed() {
1139        let err = map_ureq_error(ureq::Error::ConnectionFailed);
1140        match err {
1141            ProviderError::Http(msg) => assert!(!msg.is_empty()),
1142            other => panic!("expected Http, got {:?}", other),
1143        }
1144    }
1145
1146    #[test]
1147    fn test_map_ureq_error_bad_uri() {
1148        let err = map_ureq_error(ureq::Error::BadUri("no scheme".to_string()));
1149        match err {
1150            ProviderError::Http(msg) => assert!(msg.contains("no scheme"), "got: {}", msg),
1151            other => panic!("expected Http, got {:?}", other),
1152        }
1153    }
1154
1155    #[test]
1156    fn test_map_ureq_error_too_many_redirects() {
1157        let err = map_ureq_error(ureq::Error::TooManyRedirects);
1158        match err {
1159            ProviderError::Http(msg) => assert!(!msg.is_empty()),
1160            other => panic!("expected Http, got {:?}", other),
1161        }
1162    }
1163
1164    #[test]
1165    fn test_map_ureq_error_redirect_failed() {
1166        let err = map_ureq_error(ureq::Error::RedirectFailed);
1167        match err {
1168            ProviderError::Http(msg) => assert!(!msg.is_empty()),
1169            other => panic!("expected Http, got {:?}", other),
1170        }
1171    }
1172
1173    #[test]
1174    fn test_map_ureq_error_all_status_codes_1xx_to_5xx() {
1175        // Exhaustive check: every status code maps to some ProviderError
1176        for code in [
1177            100, 200, 201, 301, 302, 400, 401, 403, 404, 429, 500, 502, 503, 504,
1178        ] {
1179            let err = map_ureq_error(ureq::Error::StatusCode(code));
1180            match code {
1181                401 | 403 => assert!(
1182                    matches!(err, ProviderError::AuthFailed),
1183                    "status {} should be AuthFailed",
1184                    code
1185                ),
1186                429 => assert!(
1187                    matches!(err, ProviderError::RateLimited),
1188                    "status {} should be RateLimited",
1189                    code
1190                ),
1191                _ => assert!(
1192                    matches!(err, ProviderError::Http(_)),
1193                    "status {} should be Http",
1194                    code
1195                ),
1196            }
1197        }
1198    }
1199
1200    // =========================================================================
1201    // HTTP integration tests (mockito)
1202    // Verifies end-to-end: agent -> request -> response -> deserialization
1203    // =========================================================================
1204
1205    #[test]
1206    fn test_http_get_json_response() {
1207        let mut server = mockito::Server::new();
1208        let mock = server
1209            .mock("GET", "/api/test")
1210            .with_status(200)
1211            .with_header("content-type", "application/json")
1212            .with_body(r#"{"name": "test-server", "id": 42}"#)
1213            .create();
1214
1215        let agent = http_agent();
1216        let mut resp = agent
1217            .get(&format!("{}/api/test", server.url()))
1218            .call()
1219            .unwrap();
1220
1221        #[derive(serde::Deserialize)]
1222        struct TestResp {
1223            name: String,
1224            id: u32,
1225        }
1226
1227        let body: TestResp = resp.body_mut().read_json().unwrap();
1228        assert_eq!(body.name, "test-server");
1229        assert_eq!(body.id, 42);
1230        mock.assert();
1231    }
1232
1233    #[test]
1234    fn test_http_get_with_bearer_header() {
1235        let mut server = mockito::Server::new();
1236        let mock = server
1237            .mock("GET", "/api/hosts")
1238            .match_header("Authorization", "Bearer my-secret-token")
1239            .with_status(200)
1240            .with_header("content-type", "application/json")
1241            .with_body(r#"{"hosts": []}"#)
1242            .create();
1243
1244        let agent = http_agent();
1245        let resp = agent
1246            .get(&format!("{}/api/hosts", server.url()))
1247            .header("Authorization", "Bearer my-secret-token")
1248            .call();
1249
1250        assert!(resp.is_ok());
1251        mock.assert();
1252    }
1253
1254    #[test]
1255    fn test_http_get_with_custom_header() {
1256        let mut server = mockito::Server::new();
1257        let mock = server
1258            .mock("GET", "/api/servers")
1259            .match_header("X-Auth-Token", "scw-token-123")
1260            .with_status(200)
1261            .with_header("content-type", "application/json")
1262            .with_body(r#"{"servers": []}"#)
1263            .create();
1264
1265        let agent = http_agent();
1266        let resp = agent
1267            .get(&format!("{}/api/servers", server.url()))
1268            .header("X-Auth-Token", "scw-token-123")
1269            .call();
1270
1271        assert!(resp.is_ok());
1272        mock.assert();
1273    }
1274
1275    #[test]
1276    fn test_http_401_maps_to_auth_failed() {
1277        let mut server = mockito::Server::new();
1278        let mock = server
1279            .mock("GET", "/api/test")
1280            .with_status(401)
1281            .with_body("Unauthorized")
1282            .create();
1283
1284        let agent = http_agent();
1285        let err = agent
1286            .get(&format!("{}/api/test", server.url()))
1287            .call()
1288            .unwrap_err();
1289
1290        let provider_err = map_ureq_error(err);
1291        assert!(matches!(provider_err, ProviderError::AuthFailed));
1292        mock.assert();
1293    }
1294
1295    #[test]
1296    fn test_http_403_maps_to_auth_failed() {
1297        let mut server = mockito::Server::new();
1298        let mock = server
1299            .mock("GET", "/api/test")
1300            .with_status(403)
1301            .with_body("Forbidden")
1302            .create();
1303
1304        let agent = http_agent();
1305        let err = agent
1306            .get(&format!("{}/api/test", server.url()))
1307            .call()
1308            .unwrap_err();
1309
1310        let provider_err = map_ureq_error(err);
1311        assert!(matches!(provider_err, ProviderError::AuthFailed));
1312        mock.assert();
1313    }
1314
1315    #[test]
1316    fn test_http_429_maps_to_rate_limited() {
1317        let mut server = mockito::Server::new();
1318        let mock = server
1319            .mock("GET", "/api/test")
1320            .with_status(429)
1321            .with_body("Too Many Requests")
1322            .create();
1323
1324        let agent = http_agent();
1325        let err = agent
1326            .get(&format!("{}/api/test", server.url()))
1327            .call()
1328            .unwrap_err();
1329
1330        let provider_err = map_ureq_error(err);
1331        assert!(matches!(provider_err, ProviderError::RateLimited));
1332        mock.assert();
1333    }
1334
1335    #[test]
1336    fn test_http_500_maps_to_http_error() {
1337        let mut server = mockito::Server::new();
1338        let mock = server
1339            .mock("GET", "/api/test")
1340            .with_status(500)
1341            .with_body("Internal Server Error")
1342            .create();
1343
1344        let agent = http_agent();
1345        let err = agent
1346            .get(&format!("{}/api/test", server.url()))
1347            .call()
1348            .unwrap_err();
1349
1350        let provider_err = map_ureq_error(err);
1351        match provider_err {
1352            ProviderError::Http(msg) => assert_eq!(msg, "HTTP 500"),
1353            other => panic!("expected Http, got {:?}", other),
1354        }
1355        mock.assert();
1356    }
1357
1358    #[test]
1359    fn test_http_post_form_encoding() {
1360        let mut server = mockito::Server::new();
1361        let mock = server
1362            .mock("POST", "/oauth/token")
1363            .match_header("content-type", "application/x-www-form-urlencoded")
1364            .match_body(
1365                "grant_type=client_credentials&client_id=my-app&client_secret=secret123&scope=api",
1366            )
1367            .with_status(200)
1368            .with_header("content-type", "application/json")
1369            .with_body(r#"{"access_token": "eyJ.abc.def"}"#)
1370            .create();
1371
1372        let agent = http_agent();
1373        let client_id = "my-app".to_string();
1374        let client_secret = "secret123".to_string();
1375        let mut resp = agent
1376            .post(&format!("{}/oauth/token", server.url()))
1377            .send_form([
1378                ("grant_type", "client_credentials"),
1379                ("client_id", client_id.as_str()),
1380                ("client_secret", client_secret.as_str()),
1381                ("scope", "api"),
1382            ])
1383            .unwrap();
1384
1385        #[derive(serde::Deserialize)]
1386        struct TokenResp {
1387            access_token: String,
1388        }
1389
1390        let body: TokenResp = resp.body_mut().read_json().unwrap();
1391        assert_eq!(body.access_token, "eyJ.abc.def");
1392        mock.assert();
1393    }
1394
1395    #[test]
1396    fn test_http_read_to_string() {
1397        let mut server = mockito::Server::new();
1398        let mock = server
1399            .mock("GET", "/api/xml")
1400            .with_status(200)
1401            .with_header("content-type", "text/xml")
1402            .with_body("<root><item>hello</item></root>")
1403            .create();
1404
1405        let agent = http_agent();
1406        let mut resp = agent
1407            .get(&format!("{}/api/xml", server.url()))
1408            .call()
1409            .unwrap();
1410
1411        let body = resp.body_mut().read_to_string().unwrap();
1412        assert_eq!(body, "<root><item>hello</item></root>");
1413        mock.assert();
1414    }
1415
1416    #[test]
1417    fn test_http_body_reader_with_take() {
1418        // Simulates the update.rs pattern: body_mut().as_reader().take(N)
1419        use std::io::Read;
1420
1421        let mut server = mockito::Server::new();
1422        let mock = server
1423            .mock("GET", "/download")
1424            .with_status(200)
1425            .with_body("binary-content-here-12345")
1426            .create();
1427
1428        let agent = http_agent();
1429        let mut resp = agent
1430            .get(&format!("{}/download", server.url()))
1431            .call()
1432            .unwrap();
1433
1434        let mut bytes = Vec::new();
1435        resp.body_mut()
1436            .as_reader()
1437            .take(1_048_576)
1438            .read_to_end(&mut bytes)
1439            .unwrap();
1440
1441        assert_eq!(bytes, b"binary-content-here-12345");
1442        mock.assert();
1443    }
1444
1445    #[test]
1446    fn test_http_body_reader_take_truncates() {
1447        // Verify .take() actually limits the read
1448        use std::io::Read;
1449
1450        let mut server = mockito::Server::new();
1451        let mock = server
1452            .mock("GET", "/large")
1453            .with_status(200)
1454            .with_body("abcdefghijklmnopqrstuvwxyz")
1455            .create();
1456
1457        let agent = http_agent();
1458        let mut resp = agent
1459            .get(&format!("{}/large", server.url()))
1460            .call()
1461            .unwrap();
1462
1463        let mut bytes = Vec::new();
1464        resp.body_mut()
1465            .as_reader()
1466            .take(10) // Only read 10 bytes
1467            .read_to_end(&mut bytes)
1468            .unwrap();
1469
1470        assert_eq!(bytes, b"abcdefghij");
1471        mock.assert();
1472    }
1473
1474    #[test]
1475    fn test_http_no_redirects() {
1476        // Verify that our agent does NOT follow redirects (max_redirects=0).
1477        // In ureq v3, 3xx responses are returned as Ok (not errors) when redirects are disabled.
1478        // The target endpoint is never hit, proving no redirect was followed.
1479        let mut server = mockito::Server::new();
1480        let redirect_mock = server
1481            .mock("GET", "/redirect")
1482            .with_status(302)
1483            .with_header("Location", "/target")
1484            .create();
1485        let target_mock = server.mock("GET", "/target").with_status(200).create();
1486
1487        let agent = http_agent();
1488        let resp = agent
1489            .get(&format!("{}/redirect", server.url()))
1490            .call()
1491            .unwrap();
1492
1493        assert_eq!(resp.status(), 302);
1494        redirect_mock.assert();
1495        target_mock.expect(0); // Target must NOT have been hit
1496    }
1497
1498    #[test]
1499    fn test_http_invalid_json_returns_parse_error() {
1500        let mut server = mockito::Server::new();
1501        let mock = server
1502            .mock("GET", "/api/bad")
1503            .with_status(200)
1504            .with_header("content-type", "application/json")
1505            .with_body("this is not json")
1506            .create();
1507
1508        let agent = http_agent();
1509        let mut resp = agent
1510            .get(&format!("{}/api/bad", server.url()))
1511            .call()
1512            .unwrap();
1513
1514        #[derive(serde::Deserialize)]
1515        #[allow(dead_code)]
1516        struct Expected {
1517            name: String,
1518        }
1519
1520        let result: Result<Expected, _> = resp.body_mut().read_json();
1521        assert!(result.is_err());
1522        mock.assert();
1523    }
1524
1525    #[test]
1526    fn test_http_empty_json_body_returns_parse_error() {
1527        let mut server = mockito::Server::new();
1528        let mock = server
1529            .mock("GET", "/api/empty")
1530            .with_status(200)
1531            .with_header("content-type", "application/json")
1532            .with_body("")
1533            .create();
1534
1535        let agent = http_agent();
1536        let mut resp = agent
1537            .get(&format!("{}/api/empty", server.url()))
1538            .call()
1539            .unwrap();
1540
1541        #[derive(serde::Deserialize)]
1542        #[allow(dead_code)]
1543        struct Expected {
1544            name: String,
1545        }
1546
1547        let result: Result<Expected, _> = resp.body_mut().read_json();
1548        assert!(result.is_err());
1549        mock.assert();
1550    }
1551
1552    #[test]
1553    fn test_http_multiple_headers() {
1554        // Simulates AWS pattern: multiple headers on same request
1555        let mut server = mockito::Server::new();
1556        let mock = server
1557            .mock("GET", "/api/aws")
1558            .match_header("Authorization", "AWS4-HMAC-SHA256 cred=test")
1559            .match_header("x-amz-date", "20260324T120000Z")
1560            .with_status(200)
1561            .with_header("content-type", "text/xml")
1562            .with_body("<result/>")
1563            .create();
1564
1565        let agent = http_agent();
1566        let mut resp = agent
1567            .get(&format!("{}/api/aws", server.url()))
1568            .header("Authorization", "AWS4-HMAC-SHA256 cred=test")
1569            .header("x-amz-date", "20260324T120000Z")
1570            .call()
1571            .unwrap();
1572
1573        let body = resp.body_mut().read_to_string().unwrap();
1574        assert_eq!(body, "<result/>");
1575        mock.assert();
1576    }
1577
1578    #[test]
1579    fn test_http_connection_refused_maps_to_http_error() {
1580        // Connect to a port that's not listening
1581        let agent = http_agent();
1582        let err = agent.get("http://127.0.0.1:1").call().unwrap_err();
1583
1584        let provider_err = map_ureq_error(err);
1585        match provider_err {
1586            ProviderError::Http(msg) => assert!(!msg.is_empty()),
1587            other => panic!("expected Http, got {:?}", other),
1588        }
1589    }
1590
1591    #[test]
1592    fn test_http_nested_json_deserialization() {
1593        // Simulates the real provider response pattern with nested structures
1594        let mut server = mockito::Server::new();
1595        let mock = server
1596            .mock("GET", "/api/droplets")
1597            .with_status(200)
1598            .with_header("content-type", "application/json")
1599            .with_body(
1600                r#"{
1601                "data": [
1602                    {"id": "1", "name": "web-01", "ip": "1.2.3.4"},
1603                    {"id": "2", "name": "web-02", "ip": "5.6.7.8"}
1604                ],
1605                "meta": {"total": 2}
1606            }"#,
1607            )
1608            .create();
1609
1610        #[derive(serde::Deserialize)]
1611        #[allow(dead_code)]
1612        struct Host {
1613            id: String,
1614            name: String,
1615            ip: String,
1616        }
1617        #[derive(serde::Deserialize)]
1618        #[allow(dead_code)]
1619        struct Meta {
1620            total: u32,
1621        }
1622        #[derive(serde::Deserialize)]
1623        #[allow(dead_code)]
1624        struct Resp {
1625            data: Vec<Host>,
1626            meta: Meta,
1627        }
1628
1629        let agent = http_agent();
1630        let mut resp = agent
1631            .get(&format!("{}/api/droplets", server.url()))
1632            .call()
1633            .unwrap();
1634
1635        let body: Resp = resp.body_mut().read_json().unwrap();
1636        assert_eq!(body.data.len(), 2);
1637        assert_eq!(body.data[0].name, "web-01");
1638        assert_eq!(body.data[1].ip, "5.6.7.8");
1639        assert_eq!(body.meta.total, 2);
1640        mock.assert();
1641    }
1642
1643    #[test]
1644    fn test_http_xml_deserialization_with_quick_xml() {
1645        // Simulates the AWS EC2 pattern: XML response parsed with quick-xml
1646        let mut server = mockito::Server::new();
1647        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1648            <DescribeInstancesResponse>
1649                <reservationSet>
1650                    <item>
1651                        <instancesSet>
1652                            <item>
1653                                <instanceId>i-abc123</instanceId>
1654                                <instanceState><name>running</name></instanceState>
1655                            </item>
1656                        </instancesSet>
1657                    </item>
1658                </reservationSet>
1659            </DescribeInstancesResponse>"#;
1660
1661        let mock = server
1662            .mock("GET", "/ec2")
1663            .with_status(200)
1664            .with_header("content-type", "text/xml")
1665            .with_body(xml)
1666            .create();
1667
1668        let agent = http_agent();
1669        let mut resp = agent.get(&format!("{}/ec2", server.url())).call().unwrap();
1670
1671        let body = resp.body_mut().read_to_string().unwrap();
1672        // Verify we can parse the XML with quick-xml after reading via ureq v3
1673        #[derive(serde::Deserialize)]
1674        struct InstanceState {
1675            name: String,
1676        }
1677        #[derive(serde::Deserialize)]
1678        struct Instance {
1679            #[serde(rename = "instanceId")]
1680            instance_id: String,
1681            #[serde(rename = "instanceState")]
1682            instance_state: InstanceState,
1683        }
1684        #[derive(serde::Deserialize)]
1685        struct InstanceSet {
1686            item: Vec<Instance>,
1687        }
1688        #[derive(serde::Deserialize)]
1689        struct Reservation {
1690            #[serde(rename = "instancesSet")]
1691            instances_set: InstanceSet,
1692        }
1693        #[derive(serde::Deserialize)]
1694        struct ReservationSet {
1695            item: Vec<Reservation>,
1696        }
1697        #[derive(serde::Deserialize)]
1698        struct DescribeResp {
1699            #[serde(rename = "reservationSet")]
1700            reservation_set: ReservationSet,
1701        }
1702
1703        let parsed: DescribeResp = quick_xml::de::from_str(&body).unwrap();
1704        assert_eq!(
1705            parsed.reservation_set.item[0].instances_set.item[0].instance_id,
1706            "i-abc123"
1707        );
1708        assert_eq!(
1709            parsed.reservation_set.item[0].instances_set.item[0]
1710                .instance_state
1711                .name,
1712            "running"
1713        );
1714        mock.assert();
1715    }
1716}