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