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