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