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        };
567        let p = get_provider_with_config("proxmox", &section).unwrap();
568        assert_eq!(p.name(), "proxmox");
569    }
570
571    #[test]
572    fn test_get_provider_with_config_non_proxmox_delegates() {
573        let section = config::ProviderSection {
574            provider: "digitalocean".to_string(),
575            token: "do-token".to_string(),
576            alias_prefix: "do-".to_string(),
577            user: String::new(),
578            identity_file: String::new(),
579            url: String::new(),
580            verify_tls: true,
581            auto_sync: true,
582            profile: String::new(),
583            regions: String::new(),
584            project: String::new(),
585            compartment: String::new(),
586        };
587        let p = get_provider_with_config("digitalocean", &section).unwrap();
588        assert_eq!(p.name(), "digitalocean");
589    }
590
591    #[test]
592    fn test_get_provider_with_config_gcp_uses_project_and_zones() {
593        let section = config::ProviderSection {
594            provider: "gcp".to_string(),
595            token: "sa.json".to_string(),
596            alias_prefix: "gcp".to_string(),
597            user: String::new(),
598            identity_file: String::new(),
599            url: String::new(),
600            verify_tls: true,
601            auto_sync: true,
602            profile: String::new(),
603            regions: "us-central1-a, europe-west1-b".to_string(),
604            project: "my-project".to_string(),
605            compartment: String::new(),
606        };
607        let p = get_provider_with_config("gcp", &section).unwrap();
608        assert_eq!(p.name(), "gcp");
609    }
610
611    #[test]
612    fn test_get_provider_with_config_unknown_returns_none() {
613        let section = config::ProviderSection {
614            provider: "unknown_provider".to_string(),
615            token: String::new(),
616            alias_prefix: String::new(),
617            user: String::new(),
618            identity_file: String::new(),
619            url: String::new(),
620            verify_tls: true,
621            auto_sync: true,
622            profile: String::new(),
623            regions: String::new(),
624            project: String::new(),
625            compartment: String::new(),
626        };
627        assert!(get_provider_with_config("unknown_provider", &section).is_none());
628    }
629
630    // =========================================================================
631    // provider_display_name tests
632    // =========================================================================
633
634    #[test]
635    fn test_display_name_all_providers() {
636        assert_eq!(provider_display_name("digitalocean"), "DigitalOcean");
637        assert_eq!(provider_display_name("vultr"), "Vultr");
638        assert_eq!(provider_display_name("linode"), "Linode");
639        assert_eq!(provider_display_name("hetzner"), "Hetzner");
640        assert_eq!(provider_display_name("upcloud"), "UpCloud");
641        assert_eq!(provider_display_name("proxmox"), "Proxmox VE");
642        assert_eq!(provider_display_name("aws"), "AWS EC2");
643        assert_eq!(provider_display_name("scaleway"), "Scaleway");
644        assert_eq!(provider_display_name("gcp"), "GCP");
645        assert_eq!(provider_display_name("azure"), "Azure");
646        assert_eq!(provider_display_name("tailscale"), "Tailscale");
647        assert_eq!(provider_display_name("oracle"), "Oracle Cloud");
648        assert_eq!(provider_display_name("ovh"), "OVHcloud");
649        assert_eq!(provider_display_name("leaseweb"), "Leaseweb");
650        assert_eq!(provider_display_name("i3d"), "i3D.net");
651        assert_eq!(provider_display_name("transip"), "TransIP");
652    }
653
654    #[test]
655    fn test_display_name_unknown_returns_input() {
656        assert_eq!(
657            provider_display_name("unknown_provider"),
658            "unknown_provider"
659        );
660        assert_eq!(provider_display_name(""), "");
661    }
662
663    // =========================================================================
664    // PROVIDER_NAMES constant tests
665    // =========================================================================
666
667    #[test]
668    fn test_provider_names_count() {
669        assert_eq!(PROVIDER_NAMES.len(), 16);
670    }
671
672    #[test]
673    fn test_provider_names_contains_all() {
674        assert!(PROVIDER_NAMES.contains(&"digitalocean"));
675        assert!(PROVIDER_NAMES.contains(&"vultr"));
676        assert!(PROVIDER_NAMES.contains(&"linode"));
677        assert!(PROVIDER_NAMES.contains(&"hetzner"));
678        assert!(PROVIDER_NAMES.contains(&"upcloud"));
679        assert!(PROVIDER_NAMES.contains(&"proxmox"));
680        assert!(PROVIDER_NAMES.contains(&"aws"));
681        assert!(PROVIDER_NAMES.contains(&"scaleway"));
682        assert!(PROVIDER_NAMES.contains(&"gcp"));
683        assert!(PROVIDER_NAMES.contains(&"azure"));
684        assert!(PROVIDER_NAMES.contains(&"tailscale"));
685        assert!(PROVIDER_NAMES.contains(&"oracle"));
686        assert!(PROVIDER_NAMES.contains(&"ovh"));
687        assert!(PROVIDER_NAMES.contains(&"leaseweb"));
688        assert!(PROVIDER_NAMES.contains(&"i3d"));
689        assert!(PROVIDER_NAMES.contains(&"transip"));
690    }
691
692    // =========================================================================
693    // ProviderError display tests
694    // =========================================================================
695
696    #[test]
697    fn test_provider_error_display_http() {
698        let err = ProviderError::Http("connection refused".to_string());
699        assert_eq!(format!("{}", err), "HTTP error: connection refused");
700    }
701
702    #[test]
703    fn test_provider_error_display_parse() {
704        let err = ProviderError::Parse("invalid JSON".to_string());
705        assert_eq!(format!("{}", err), "Failed to parse response: invalid JSON");
706    }
707
708    #[test]
709    fn test_provider_error_display_auth() {
710        let err = ProviderError::AuthFailed;
711        assert!(format!("{}", err).contains("Authentication failed"));
712    }
713
714    #[test]
715    fn test_provider_error_display_rate_limited() {
716        let err = ProviderError::RateLimited;
717        assert!(format!("{}", err).contains("Rate limited"));
718    }
719
720    #[test]
721    fn test_provider_error_display_cancelled() {
722        let err = ProviderError::Cancelled;
723        assert_eq!(format!("{}", err), "Cancelled.");
724    }
725
726    #[test]
727    fn test_provider_error_display_partial_result() {
728        let err = ProviderError::PartialResult {
729            hosts: vec![],
730            failures: 3,
731            total: 10,
732        };
733        assert!(format!("{}", err).contains("3 of 10 failed"));
734    }
735
736    // =========================================================================
737    // ProviderHost struct tests
738    // =========================================================================
739
740    #[test]
741    fn test_provider_host_construction() {
742        let host = ProviderHost::new(
743            "12345".to_string(),
744            "web-01".to_string(),
745            "1.2.3.4".to_string(),
746            vec!["prod".to_string(), "web".to_string()],
747        );
748        assert_eq!(host.server_id, "12345");
749        assert_eq!(host.name, "web-01");
750        assert_eq!(host.ip, "1.2.3.4");
751        assert_eq!(host.tags.len(), 2);
752    }
753
754    #[test]
755    fn test_provider_host_clone() {
756        let host = ProviderHost::new(
757            "1".to_string(),
758            "a".to_string(),
759            "1.1.1.1".to_string(),
760            vec![],
761        );
762        let cloned = host.clone();
763        assert_eq!(cloned.server_id, host.server_id);
764        assert_eq!(cloned.name, host.name);
765    }
766
767    // =========================================================================
768    // strip_cidr additional edge cases
769    // =========================================================================
770
771    #[test]
772    fn test_strip_cidr_ipv6_with_64() {
773        assert_eq!(strip_cidr("2a01:4f8::1/64"), "2a01:4f8::1");
774    }
775
776    #[test]
777    fn test_strip_cidr_ipv4_with_32() {
778        assert_eq!(strip_cidr("1.2.3.4/32"), "1.2.3.4");
779    }
780
781    #[test]
782    fn test_strip_cidr_ipv4_with_8() {
783        assert_eq!(strip_cidr("10.0.0.1/8"), "10.0.0.1");
784    }
785
786    #[test]
787    fn test_strip_cidr_just_slash() {
788        // "/" alone: pos=0, pos+1=1=len -> condition fails
789        assert_eq!(strip_cidr("/"), "/");
790    }
791
792    #[test]
793    fn test_strip_cidr_slash_with_letters() {
794        assert_eq!(strip_cidr("10.0.0.1/abc"), "10.0.0.1/abc");
795    }
796
797    #[test]
798    fn test_strip_cidr_multiple_slashes() {
799        // rfind gets last slash: "48" is digits, so it strips the last /48
800        assert_eq!(strip_cidr("10.0.0.1/24/48"), "10.0.0.1/24");
801    }
802
803    #[test]
804    fn test_strip_cidr_ipv6_full_notation() {
805        assert_eq!(
806            strip_cidr("2001:0db8:85a3:0000:0000:8a2e:0370:7334/128"),
807            "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
808        );
809    }
810
811    // =========================================================================
812    // ProviderError Debug
813    // =========================================================================
814
815    #[test]
816    fn test_provider_error_debug_http() {
817        let err = ProviderError::Http("timeout".to_string());
818        let debug = format!("{:?}", err);
819        assert!(debug.contains("Http"));
820        assert!(debug.contains("timeout"));
821    }
822
823    #[test]
824    fn test_provider_error_debug_partial_result() {
825        let err = ProviderError::PartialResult {
826            hosts: vec![ProviderHost::new(
827                "1".to_string(),
828                "web".to_string(),
829                "1.2.3.4".to_string(),
830                vec![],
831            )],
832            failures: 2,
833            total: 5,
834        };
835        let debug = format!("{:?}", err);
836        assert!(debug.contains("PartialResult"));
837        assert!(debug.contains("failures: 2"));
838    }
839
840    // =========================================================================
841    // ProviderHost with empty fields
842    // =========================================================================
843
844    #[test]
845    fn test_provider_host_empty_fields() {
846        let host = ProviderHost::new(String::new(), String::new(), String::new(), vec![]);
847        assert!(host.server_id.is_empty());
848        assert!(host.name.is_empty());
849        assert!(host.ip.is_empty());
850    }
851
852    // =========================================================================
853    // get_provider_with_config for all non-proxmox providers
854    // =========================================================================
855
856    #[test]
857    fn test_get_provider_with_config_all_providers() {
858        for &name in PROVIDER_NAMES {
859            let section = config::ProviderSection {
860                provider: name.to_string(),
861                token: "tok".to_string(),
862                alias_prefix: "test".to_string(),
863                user: String::new(),
864                identity_file: String::new(),
865                url: if name == "proxmox" {
866                    "https://pve:8006".to_string()
867                } else {
868                    String::new()
869                },
870                verify_tls: true,
871                auto_sync: true,
872                profile: String::new(),
873                regions: String::new(),
874                project: String::new(),
875                compartment: String::new(),
876            };
877            let p = get_provider_with_config(name, &section);
878            assert!(
879                p.is_some(),
880                "get_provider_with_config({}) should return Some",
881                name
882            );
883            assert_eq!(p.unwrap().name(), name);
884        }
885    }
886
887    // =========================================================================
888    // Provider trait default methods
889    // =========================================================================
890
891    #[test]
892    fn test_provider_fetch_hosts_delegates_to_cancellable() {
893        let provider = get_provider("digitalocean").unwrap();
894        // fetch_hosts delegates to fetch_hosts_cancellable with AtomicBool(false)
895        // We can't actually test this without a server, but we verify the method exists
896        // by calling it (will fail with network error, which is fine for this test)
897        let result = provider.fetch_hosts("fake-token");
898        assert!(result.is_err()); // Expected: no network
899    }
900
901    // =========================================================================
902    // strip_cidr: suffix starts with digit but contains letters
903    // =========================================================================
904
905    #[test]
906    fn test_strip_cidr_digit_then_letters_not_stripped() {
907        assert_eq!(strip_cidr("10.0.0.1/24abc"), "10.0.0.1/24abc");
908    }
909
910    // =========================================================================
911    // provider_display_name: all known providers
912    // =========================================================================
913
914    #[test]
915    fn test_provider_display_name_all() {
916        assert_eq!(provider_display_name("digitalocean"), "DigitalOcean");
917        assert_eq!(provider_display_name("vultr"), "Vultr");
918        assert_eq!(provider_display_name("linode"), "Linode");
919        assert_eq!(provider_display_name("hetzner"), "Hetzner");
920        assert_eq!(provider_display_name("upcloud"), "UpCloud");
921        assert_eq!(provider_display_name("proxmox"), "Proxmox VE");
922        assert_eq!(provider_display_name("aws"), "AWS EC2");
923        assert_eq!(provider_display_name("scaleway"), "Scaleway");
924        assert_eq!(provider_display_name("gcp"), "GCP");
925        assert_eq!(provider_display_name("azure"), "Azure");
926        assert_eq!(provider_display_name("tailscale"), "Tailscale");
927        assert_eq!(provider_display_name("oracle"), "Oracle Cloud");
928        assert_eq!(provider_display_name("ovh"), "OVHcloud");
929        assert_eq!(provider_display_name("leaseweb"), "Leaseweb");
930        assert_eq!(provider_display_name("i3d"), "i3D.net");
931        assert_eq!(provider_display_name("transip"), "TransIP");
932    }
933
934    #[test]
935    fn test_provider_display_name_unknown() {
936        assert_eq!(
937            provider_display_name("unknown_provider"),
938            "unknown_provider"
939        );
940    }
941
942    // =========================================================================
943    // get_provider: all known + unknown
944    // =========================================================================
945
946    #[test]
947    fn test_get_provider_all_known() {
948        for name in PROVIDER_NAMES {
949            assert!(
950                get_provider(name).is_some(),
951                "get_provider({}) should return Some",
952                name
953            );
954        }
955    }
956
957    #[test]
958    fn test_get_provider_case_sensitive_and_unknown() {
959        assert!(get_provider("unknown_provider").is_none());
960        assert!(get_provider("DigitalOcean").is_none()); // Case-sensitive
961        assert!(get_provider("VULTR").is_none());
962        assert!(get_provider("").is_none());
963    }
964
965    // =========================================================================
966    // PROVIDER_NAMES constant
967    // =========================================================================
968
969    #[test]
970    fn test_provider_names_has_all_sixteen() {
971        assert_eq!(PROVIDER_NAMES.len(), 16);
972        assert!(PROVIDER_NAMES.contains(&"digitalocean"));
973        assert!(PROVIDER_NAMES.contains(&"proxmox"));
974        assert!(PROVIDER_NAMES.contains(&"aws"));
975        assert!(PROVIDER_NAMES.contains(&"scaleway"));
976        assert!(PROVIDER_NAMES.contains(&"azure"));
977        assert!(PROVIDER_NAMES.contains(&"tailscale"));
978        assert!(PROVIDER_NAMES.contains(&"oracle"));
979        assert!(PROVIDER_NAMES.contains(&"ovh"));
980        assert!(PROVIDER_NAMES.contains(&"leaseweb"));
981        assert!(PROVIDER_NAMES.contains(&"i3d"));
982        assert!(PROVIDER_NAMES.contains(&"transip"));
983    }
984
985    // =========================================================================
986    // Provider short_label via get_provider
987    // =========================================================================
988
989    #[test]
990    fn test_provider_short_labels() {
991        let cases = [
992            ("digitalocean", "do"),
993            ("vultr", "vultr"),
994            ("linode", "linode"),
995            ("hetzner", "hetzner"),
996            ("upcloud", "uc"),
997            ("proxmox", "pve"),
998            ("aws", "aws"),
999            ("scaleway", "scw"),
1000            ("gcp", "gcp"),
1001            ("azure", "az"),
1002            ("tailscale", "ts"),
1003            ("oracle", "oci"),
1004            ("ovh", "ovh"),
1005            ("leaseweb", "lsw"),
1006            ("i3d", "i3d"),
1007            ("transip", "tip"),
1008        ];
1009        for (name, expected_label) in &cases {
1010            let p = get_provider(name).unwrap();
1011            assert_eq!(p.short_label(), *expected_label, "short_label for {}", name);
1012        }
1013    }
1014
1015    // =========================================================================
1016    // http_agent construction tests
1017    // =========================================================================
1018
1019    #[test]
1020    fn test_http_agent_creates_agent() {
1021        // Smoke test: agent construction should not panic
1022        let _agent = http_agent();
1023    }
1024
1025    #[test]
1026    fn test_http_agent_insecure_creates_agent() {
1027        // Smoke test: insecure agent construction should succeed
1028        let agent = http_agent_insecure();
1029        assert!(agent.is_ok());
1030    }
1031
1032    // =========================================================================
1033    // map_ureq_error tests
1034    // =========================================================================
1035
1036    #[test]
1037    fn test_map_ureq_error_401_is_auth_failed() {
1038        let err = map_ureq_error(ureq::Error::StatusCode(401));
1039        assert!(matches!(err, ProviderError::AuthFailed));
1040    }
1041
1042    #[test]
1043    fn test_map_ureq_error_403_is_auth_failed() {
1044        let err = map_ureq_error(ureq::Error::StatusCode(403));
1045        assert!(matches!(err, ProviderError::AuthFailed));
1046    }
1047
1048    #[test]
1049    fn test_map_ureq_error_429_is_rate_limited() {
1050        let err = map_ureq_error(ureq::Error::StatusCode(429));
1051        assert!(matches!(err, ProviderError::RateLimited));
1052    }
1053
1054    #[test]
1055    fn test_map_ureq_error_500_is_http() {
1056        let err = map_ureq_error(ureq::Error::StatusCode(500));
1057        match err {
1058            ProviderError::Http(msg) => assert_eq!(msg, "HTTP 500"),
1059            other => panic!("expected Http, got {:?}", other),
1060        }
1061    }
1062
1063    #[test]
1064    fn test_map_ureq_error_404_is_http() {
1065        let err = map_ureq_error(ureq::Error::StatusCode(404));
1066        match err {
1067            ProviderError::Http(msg) => assert_eq!(msg, "HTTP 404"),
1068            other => panic!("expected Http, got {:?}", other),
1069        }
1070    }
1071
1072    #[test]
1073    fn test_map_ureq_error_502_is_http() {
1074        let err = map_ureq_error(ureq::Error::StatusCode(502));
1075        match err {
1076            ProviderError::Http(msg) => assert_eq!(msg, "HTTP 502"),
1077            other => panic!("expected Http, got {:?}", other),
1078        }
1079    }
1080
1081    #[test]
1082    fn test_map_ureq_error_503_is_http() {
1083        let err = map_ureq_error(ureq::Error::StatusCode(503));
1084        match err {
1085            ProviderError::Http(msg) => assert_eq!(msg, "HTTP 503"),
1086            other => panic!("expected Http, got {:?}", other),
1087        }
1088    }
1089
1090    #[test]
1091    fn test_map_ureq_error_200_is_http() {
1092        // Edge case: 200 should still map (even though it shouldn't occur in practice)
1093        let err = map_ureq_error(ureq::Error::StatusCode(200));
1094        match err {
1095            ProviderError::Http(msg) => assert_eq!(msg, "HTTP 200"),
1096            other => panic!("expected Http, got {:?}", other),
1097        }
1098    }
1099
1100    #[test]
1101    fn test_map_ureq_error_non_status_is_http() {
1102        // Transport/other errors should map to Http with a message
1103        let err = map_ureq_error(ureq::Error::HostNotFound);
1104        match err {
1105            ProviderError::Http(msg) => assert!(!msg.is_empty()),
1106            other => panic!("expected Http, got {:?}", other),
1107        }
1108    }
1109
1110    #[test]
1111    fn test_map_ureq_error_all_auth_codes_covered() {
1112        // Verify only 401 and 403 produce AuthFailed (not 400, 402, etc.)
1113        for code in [400, 402, 405, 406, 407, 408, 409, 410] {
1114            let err = map_ureq_error(ureq::Error::StatusCode(code));
1115            assert!(
1116                matches!(err, ProviderError::Http(_)),
1117                "status {} should be Http, not AuthFailed",
1118                code
1119            );
1120        }
1121    }
1122
1123    #[test]
1124    fn test_map_ureq_error_only_429_is_rate_limited() {
1125        // Verify only 429 produces RateLimited
1126        for code in [428, 430, 431] {
1127            let err = map_ureq_error(ureq::Error::StatusCode(code));
1128            assert!(
1129                !matches!(err, ProviderError::RateLimited),
1130                "status {} should not be RateLimited",
1131                code
1132            );
1133        }
1134    }
1135
1136    #[test]
1137    fn test_map_ureq_error_io_error() {
1138        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1139        let err = map_ureq_error(ureq::Error::Io(io_err));
1140        match err {
1141            ProviderError::Http(msg) => assert!(msg.contains("refused"), "got: {}", msg),
1142            other => panic!("expected Http, got {:?}", other),
1143        }
1144    }
1145
1146    #[test]
1147    fn test_map_ureq_error_timeout() {
1148        let err = map_ureq_error(ureq::Error::Timeout(ureq::Timeout::Global));
1149        match err {
1150            ProviderError::Http(msg) => assert!(!msg.is_empty()),
1151            other => panic!("expected Http, got {:?}", other),
1152        }
1153    }
1154
1155    #[test]
1156    fn test_map_ureq_error_connection_failed() {
1157        let err = map_ureq_error(ureq::Error::ConnectionFailed);
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_bad_uri() {
1166        let err = map_ureq_error(ureq::Error::BadUri("no scheme".to_string()));
1167        match err {
1168            ProviderError::Http(msg) => assert!(msg.contains("no scheme"), "got: {}", msg),
1169            other => panic!("expected Http, got {:?}", other),
1170        }
1171    }
1172
1173    #[test]
1174    fn test_map_ureq_error_too_many_redirects() {
1175        let err = map_ureq_error(ureq::Error::TooManyRedirects);
1176        match err {
1177            ProviderError::Http(msg) => assert!(!msg.is_empty()),
1178            other => panic!("expected Http, got {:?}", other),
1179        }
1180    }
1181
1182    #[test]
1183    fn test_map_ureq_error_redirect_failed() {
1184        let err = map_ureq_error(ureq::Error::RedirectFailed);
1185        match err {
1186            ProviderError::Http(msg) => assert!(!msg.is_empty()),
1187            other => panic!("expected Http, got {:?}", other),
1188        }
1189    }
1190
1191    #[test]
1192    fn test_map_ureq_error_all_status_codes_1xx_to_5xx() {
1193        // Exhaustive check: every status code maps to some ProviderError
1194        for code in [
1195            100, 200, 201, 301, 302, 400, 401, 403, 404, 429, 500, 502, 503, 504,
1196        ] {
1197            let err = map_ureq_error(ureq::Error::StatusCode(code));
1198            match code {
1199                401 | 403 => assert!(
1200                    matches!(err, ProviderError::AuthFailed),
1201                    "status {} should be AuthFailed",
1202                    code
1203                ),
1204                429 => assert!(
1205                    matches!(err, ProviderError::RateLimited),
1206                    "status {} should be RateLimited",
1207                    code
1208                ),
1209                _ => assert!(
1210                    matches!(err, ProviderError::Http(_)),
1211                    "status {} should be Http",
1212                    code
1213                ),
1214            }
1215        }
1216    }
1217
1218    // =========================================================================
1219    // HTTP integration tests (mockito)
1220    // Verifies end-to-end: agent -> request -> response -> deserialization
1221    // =========================================================================
1222
1223    #[test]
1224    fn test_http_get_json_response() {
1225        let mut server = mockito::Server::new();
1226        let mock = server
1227            .mock("GET", "/api/test")
1228            .with_status(200)
1229            .with_header("content-type", "application/json")
1230            .with_body(r#"{"name": "test-server", "id": 42}"#)
1231            .create();
1232
1233        let agent = http_agent();
1234        let mut resp = agent
1235            .get(&format!("{}/api/test", server.url()))
1236            .call()
1237            .unwrap();
1238
1239        #[derive(serde::Deserialize)]
1240        struct TestResp {
1241            name: String,
1242            id: u32,
1243        }
1244
1245        let body: TestResp = resp.body_mut().read_json().unwrap();
1246        assert_eq!(body.name, "test-server");
1247        assert_eq!(body.id, 42);
1248        mock.assert();
1249    }
1250
1251    #[test]
1252    fn test_http_get_with_bearer_header() {
1253        let mut server = mockito::Server::new();
1254        let mock = server
1255            .mock("GET", "/api/hosts")
1256            .match_header("Authorization", "Bearer my-secret-token")
1257            .with_status(200)
1258            .with_header("content-type", "application/json")
1259            .with_body(r#"{"hosts": []}"#)
1260            .create();
1261
1262        let agent = http_agent();
1263        let resp = agent
1264            .get(&format!("{}/api/hosts", server.url()))
1265            .header("Authorization", "Bearer my-secret-token")
1266            .call();
1267
1268        assert!(resp.is_ok());
1269        mock.assert();
1270    }
1271
1272    #[test]
1273    fn test_http_get_with_custom_header() {
1274        let mut server = mockito::Server::new();
1275        let mock = server
1276            .mock("GET", "/api/servers")
1277            .match_header("X-Auth-Token", "scw-token-123")
1278            .with_status(200)
1279            .with_header("content-type", "application/json")
1280            .with_body(r#"{"servers": []}"#)
1281            .create();
1282
1283        let agent = http_agent();
1284        let resp = agent
1285            .get(&format!("{}/api/servers", server.url()))
1286            .header("X-Auth-Token", "scw-token-123")
1287            .call();
1288
1289        assert!(resp.is_ok());
1290        mock.assert();
1291    }
1292
1293    #[test]
1294    fn test_http_401_maps_to_auth_failed() {
1295        let mut server = mockito::Server::new();
1296        let mock = server
1297            .mock("GET", "/api/test")
1298            .with_status(401)
1299            .with_body("Unauthorized")
1300            .create();
1301
1302        let agent = http_agent();
1303        let err = agent
1304            .get(&format!("{}/api/test", server.url()))
1305            .call()
1306            .unwrap_err();
1307
1308        let provider_err = map_ureq_error(err);
1309        assert!(matches!(provider_err, ProviderError::AuthFailed));
1310        mock.assert();
1311    }
1312
1313    #[test]
1314    fn test_http_403_maps_to_auth_failed() {
1315        let mut server = mockito::Server::new();
1316        let mock = server
1317            .mock("GET", "/api/test")
1318            .with_status(403)
1319            .with_body("Forbidden")
1320            .create();
1321
1322        let agent = http_agent();
1323        let err = agent
1324            .get(&format!("{}/api/test", server.url()))
1325            .call()
1326            .unwrap_err();
1327
1328        let provider_err = map_ureq_error(err);
1329        assert!(matches!(provider_err, ProviderError::AuthFailed));
1330        mock.assert();
1331    }
1332
1333    #[test]
1334    fn test_http_429_maps_to_rate_limited() {
1335        let mut server = mockito::Server::new();
1336        let mock = server
1337            .mock("GET", "/api/test")
1338            .with_status(429)
1339            .with_body("Too Many Requests")
1340            .create();
1341
1342        let agent = http_agent();
1343        let err = agent
1344            .get(&format!("{}/api/test", server.url()))
1345            .call()
1346            .unwrap_err();
1347
1348        let provider_err = map_ureq_error(err);
1349        assert!(matches!(provider_err, ProviderError::RateLimited));
1350        mock.assert();
1351    }
1352
1353    #[test]
1354    fn test_http_500_maps_to_http_error() {
1355        let mut server = mockito::Server::new();
1356        let mock = server
1357            .mock("GET", "/api/test")
1358            .with_status(500)
1359            .with_body("Internal Server Error")
1360            .create();
1361
1362        let agent = http_agent();
1363        let err = agent
1364            .get(&format!("{}/api/test", server.url()))
1365            .call()
1366            .unwrap_err();
1367
1368        let provider_err = map_ureq_error(err);
1369        match provider_err {
1370            ProviderError::Http(msg) => assert_eq!(msg, "HTTP 500"),
1371            other => panic!("expected Http, got {:?}", other),
1372        }
1373        mock.assert();
1374    }
1375
1376    #[test]
1377    fn test_http_post_form_encoding() {
1378        let mut server = mockito::Server::new();
1379        let mock = server
1380            .mock("POST", "/oauth/token")
1381            .match_header("content-type", "application/x-www-form-urlencoded")
1382            .match_body(
1383                "grant_type=client_credentials&client_id=my-app&client_secret=secret123&scope=api",
1384            )
1385            .with_status(200)
1386            .with_header("content-type", "application/json")
1387            .with_body(r#"{"access_token": "eyJ.abc.def"}"#)
1388            .create();
1389
1390        let agent = http_agent();
1391        let client_id = "my-app".to_string();
1392        let client_secret = "secret123".to_string();
1393        let mut resp = agent
1394            .post(&format!("{}/oauth/token", server.url()))
1395            .send_form([
1396                ("grant_type", "client_credentials"),
1397                ("client_id", client_id.as_str()),
1398                ("client_secret", client_secret.as_str()),
1399                ("scope", "api"),
1400            ])
1401            .unwrap();
1402
1403        #[derive(serde::Deserialize)]
1404        struct TokenResp {
1405            access_token: String,
1406        }
1407
1408        let body: TokenResp = resp.body_mut().read_json().unwrap();
1409        assert_eq!(body.access_token, "eyJ.abc.def");
1410        mock.assert();
1411    }
1412
1413    #[test]
1414    fn test_http_read_to_string() {
1415        let mut server = mockito::Server::new();
1416        let mock = server
1417            .mock("GET", "/api/xml")
1418            .with_status(200)
1419            .with_header("content-type", "text/xml")
1420            .with_body("<root><item>hello</item></root>")
1421            .create();
1422
1423        let agent = http_agent();
1424        let mut resp = agent
1425            .get(&format!("{}/api/xml", server.url()))
1426            .call()
1427            .unwrap();
1428
1429        let body = resp.body_mut().read_to_string().unwrap();
1430        assert_eq!(body, "<root><item>hello</item></root>");
1431        mock.assert();
1432    }
1433
1434    #[test]
1435    fn test_http_body_reader_with_take() {
1436        // Simulates the update.rs pattern: body_mut().as_reader().take(N)
1437        use std::io::Read;
1438
1439        let mut server = mockito::Server::new();
1440        let mock = server
1441            .mock("GET", "/download")
1442            .with_status(200)
1443            .with_body("binary-content-here-12345")
1444            .create();
1445
1446        let agent = http_agent();
1447        let mut resp = agent
1448            .get(&format!("{}/download", server.url()))
1449            .call()
1450            .unwrap();
1451
1452        let mut bytes = Vec::new();
1453        resp.body_mut()
1454            .as_reader()
1455            .take(1_048_576)
1456            .read_to_end(&mut bytes)
1457            .unwrap();
1458
1459        assert_eq!(bytes, b"binary-content-here-12345");
1460        mock.assert();
1461    }
1462
1463    #[test]
1464    fn test_http_body_reader_take_truncates() {
1465        // Verify .take() actually limits the read
1466        use std::io::Read;
1467
1468        let mut server = mockito::Server::new();
1469        let mock = server
1470            .mock("GET", "/large")
1471            .with_status(200)
1472            .with_body("abcdefghijklmnopqrstuvwxyz")
1473            .create();
1474
1475        let agent = http_agent();
1476        let mut resp = agent
1477            .get(&format!("{}/large", server.url()))
1478            .call()
1479            .unwrap();
1480
1481        let mut bytes = Vec::new();
1482        resp.body_mut()
1483            .as_reader()
1484            .take(10) // Only read 10 bytes
1485            .read_to_end(&mut bytes)
1486            .unwrap();
1487
1488        assert_eq!(bytes, b"abcdefghij");
1489        mock.assert();
1490    }
1491
1492    #[test]
1493    fn test_http_no_redirects() {
1494        // Verify that our agent does NOT follow redirects (max_redirects=0).
1495        // In ureq v3, 3xx responses are returned as Ok (not errors) when redirects are disabled.
1496        // The target endpoint is never hit, proving no redirect was followed.
1497        let mut server = mockito::Server::new();
1498        let redirect_mock = server
1499            .mock("GET", "/redirect")
1500            .with_status(302)
1501            .with_header("Location", "/target")
1502            .create();
1503        let target_mock = server.mock("GET", "/target").with_status(200).create();
1504
1505        let agent = http_agent();
1506        let resp = agent
1507            .get(&format!("{}/redirect", server.url()))
1508            .call()
1509            .unwrap();
1510
1511        assert_eq!(resp.status(), 302);
1512        redirect_mock.assert();
1513        target_mock.expect(0); // Target must NOT have been hit
1514    }
1515
1516    #[test]
1517    fn test_http_invalid_json_returns_parse_error() {
1518        let mut server = mockito::Server::new();
1519        let mock = server
1520            .mock("GET", "/api/bad")
1521            .with_status(200)
1522            .with_header("content-type", "application/json")
1523            .with_body("this is not json")
1524            .create();
1525
1526        let agent = http_agent();
1527        let mut resp = agent
1528            .get(&format!("{}/api/bad", server.url()))
1529            .call()
1530            .unwrap();
1531
1532        #[derive(serde::Deserialize)]
1533        #[allow(dead_code)]
1534        struct Expected {
1535            name: String,
1536        }
1537
1538        let result: Result<Expected, _> = resp.body_mut().read_json();
1539        assert!(result.is_err());
1540        mock.assert();
1541    }
1542
1543    #[test]
1544    fn test_http_empty_json_body_returns_parse_error() {
1545        let mut server = mockito::Server::new();
1546        let mock = server
1547            .mock("GET", "/api/empty")
1548            .with_status(200)
1549            .with_header("content-type", "application/json")
1550            .with_body("")
1551            .create();
1552
1553        let agent = http_agent();
1554        let mut resp = agent
1555            .get(&format!("{}/api/empty", server.url()))
1556            .call()
1557            .unwrap();
1558
1559        #[derive(serde::Deserialize)]
1560        #[allow(dead_code)]
1561        struct Expected {
1562            name: String,
1563        }
1564
1565        let result: Result<Expected, _> = resp.body_mut().read_json();
1566        assert!(result.is_err());
1567        mock.assert();
1568    }
1569
1570    #[test]
1571    fn test_http_multiple_headers() {
1572        // Simulates AWS pattern: multiple headers on same request
1573        let mut server = mockito::Server::new();
1574        let mock = server
1575            .mock("GET", "/api/aws")
1576            .match_header("Authorization", "AWS4-HMAC-SHA256 cred=test")
1577            .match_header("x-amz-date", "20260324T120000Z")
1578            .with_status(200)
1579            .with_header("content-type", "text/xml")
1580            .with_body("<result/>")
1581            .create();
1582
1583        let agent = http_agent();
1584        let mut resp = agent
1585            .get(&format!("{}/api/aws", server.url()))
1586            .header("Authorization", "AWS4-HMAC-SHA256 cred=test")
1587            .header("x-amz-date", "20260324T120000Z")
1588            .call()
1589            .unwrap();
1590
1591        let body = resp.body_mut().read_to_string().unwrap();
1592        assert_eq!(body, "<result/>");
1593        mock.assert();
1594    }
1595
1596    #[test]
1597    fn test_http_connection_refused_maps_to_http_error() {
1598        // Connect to a port that's not listening
1599        let agent = http_agent();
1600        let err = agent.get("http://127.0.0.1:1").call().unwrap_err();
1601
1602        let provider_err = map_ureq_error(err);
1603        match provider_err {
1604            ProviderError::Http(msg) => assert!(!msg.is_empty()),
1605            other => panic!("expected Http, got {:?}", other),
1606        }
1607    }
1608
1609    #[test]
1610    fn test_http_nested_json_deserialization() {
1611        // Simulates the real provider response pattern with nested structures
1612        let mut server = mockito::Server::new();
1613        let mock = server
1614            .mock("GET", "/api/droplets")
1615            .with_status(200)
1616            .with_header("content-type", "application/json")
1617            .with_body(
1618                r#"{
1619                "data": [
1620                    {"id": "1", "name": "web-01", "ip": "1.2.3.4"},
1621                    {"id": "2", "name": "web-02", "ip": "5.6.7.8"}
1622                ],
1623                "meta": {"total": 2}
1624            }"#,
1625            )
1626            .create();
1627
1628        #[derive(serde::Deserialize)]
1629        #[allow(dead_code)]
1630        struct Host {
1631            id: String,
1632            name: String,
1633            ip: String,
1634        }
1635        #[derive(serde::Deserialize)]
1636        #[allow(dead_code)]
1637        struct Meta {
1638            total: u32,
1639        }
1640        #[derive(serde::Deserialize)]
1641        #[allow(dead_code)]
1642        struct Resp {
1643            data: Vec<Host>,
1644            meta: Meta,
1645        }
1646
1647        let agent = http_agent();
1648        let mut resp = agent
1649            .get(&format!("{}/api/droplets", server.url()))
1650            .call()
1651            .unwrap();
1652
1653        let body: Resp = resp.body_mut().read_json().unwrap();
1654        assert_eq!(body.data.len(), 2);
1655        assert_eq!(body.data[0].name, "web-01");
1656        assert_eq!(body.data[1].ip, "5.6.7.8");
1657        assert_eq!(body.meta.total, 2);
1658        mock.assert();
1659    }
1660
1661    #[test]
1662    fn test_http_xml_deserialization_with_quick_xml() {
1663        // Simulates the AWS EC2 pattern: XML response parsed with quick-xml
1664        let mut server = mockito::Server::new();
1665        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1666            <DescribeInstancesResponse>
1667                <reservationSet>
1668                    <item>
1669                        <instancesSet>
1670                            <item>
1671                                <instanceId>i-abc123</instanceId>
1672                                <instanceState><name>running</name></instanceState>
1673                            </item>
1674                        </instancesSet>
1675                    </item>
1676                </reservationSet>
1677            </DescribeInstancesResponse>"#;
1678
1679        let mock = server
1680            .mock("GET", "/ec2")
1681            .with_status(200)
1682            .with_header("content-type", "text/xml")
1683            .with_body(xml)
1684            .create();
1685
1686        let agent = http_agent();
1687        let mut resp = agent.get(&format!("{}/ec2", server.url())).call().unwrap();
1688
1689        let body = resp.body_mut().read_to_string().unwrap();
1690        // Verify we can parse the XML with quick-xml after reading via ureq v3
1691        #[derive(serde::Deserialize)]
1692        struct InstanceState {
1693            name: String,
1694        }
1695        #[derive(serde::Deserialize)]
1696        struct Instance {
1697            #[serde(rename = "instanceId")]
1698            instance_id: String,
1699            #[serde(rename = "instanceState")]
1700            instance_state: InstanceState,
1701        }
1702        #[derive(serde::Deserialize)]
1703        struct InstanceSet {
1704            item: Vec<Instance>,
1705        }
1706        #[derive(serde::Deserialize)]
1707        struct Reservation {
1708            #[serde(rename = "instancesSet")]
1709            instances_set: InstanceSet,
1710        }
1711        #[derive(serde::Deserialize)]
1712        struct ReservationSet {
1713            item: Vec<Reservation>,
1714        }
1715        #[derive(serde::Deserialize)]
1716        struct DescribeResp {
1717            #[serde(rename = "reservationSet")]
1718            reservation_set: ReservationSet,
1719        }
1720
1721        let parsed: DescribeResp = quick_xml::de::from_str(&body).unwrap();
1722        assert_eq!(
1723            parsed.reservation_set.item[0].instances_set.item[0].instance_id,
1724            "i-abc123"
1725        );
1726        assert_eq!(
1727            parsed.reservation_set.item[0].instances_set.item[0]
1728                .instance_state
1729                .name,
1730            "running"
1731        );
1732        mock.assert();
1733    }
1734}