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