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