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