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