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