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 vault_role: String::new(),
567 vault_addr: String::new(),
568 };
569 let p = get_provider_with_config("proxmox", §ion).unwrap();
570 assert_eq!(p.name(), "proxmox");
571 }
572
573 #[test]
574 fn test_get_provider_with_config_non_proxmox_delegates() {
575 let section = config::ProviderSection {
576 provider: "digitalocean".to_string(),
577 token: "do-token".to_string(),
578 alias_prefix: "do-".to_string(),
579 user: String::new(),
580 identity_file: String::new(),
581 url: String::new(),
582 verify_tls: true,
583 auto_sync: true,
584 profile: String::new(),
585 regions: String::new(),
586 project: String::new(),
587 compartment: String::new(),
588 vault_role: String::new(),
589 vault_addr: String::new(),
590 };
591 let p = get_provider_with_config("digitalocean", §ion).unwrap();
592 assert_eq!(p.name(), "digitalocean");
593 }
594
595 #[test]
596 fn test_get_provider_with_config_gcp_uses_project_and_zones() {
597 let section = config::ProviderSection {
598 provider: "gcp".to_string(),
599 token: "sa.json".to_string(),
600 alias_prefix: "gcp".to_string(),
601 user: String::new(),
602 identity_file: String::new(),
603 url: String::new(),
604 verify_tls: true,
605 auto_sync: true,
606 profile: String::new(),
607 regions: "us-central1-a, europe-west1-b".to_string(),
608 project: "my-project".to_string(),
609 compartment: String::new(),
610 vault_role: String::new(),
611 vault_addr: String::new(),
612 };
613 let p = get_provider_with_config("gcp", §ion).unwrap();
614 assert_eq!(p.name(), "gcp");
615 }
616
617 #[test]
618 fn test_get_provider_with_config_unknown_returns_none() {
619 let section = config::ProviderSection {
620 provider: "unknown_provider".to_string(),
621 token: String::new(),
622 alias_prefix: String::new(),
623 user: String::new(),
624 identity_file: String::new(),
625 url: String::new(),
626 verify_tls: true,
627 auto_sync: true,
628 profile: String::new(),
629 regions: String::new(),
630 project: String::new(),
631 compartment: String::new(),
632 vault_role: String::new(),
633 vault_addr: String::new(),
634 };
635 assert!(get_provider_with_config("unknown_provider", §ion).is_none());
636 }
637
638 #[test]
643 fn test_display_name_all_providers() {
644 assert_eq!(provider_display_name("digitalocean"), "DigitalOcean");
645 assert_eq!(provider_display_name("vultr"), "Vultr");
646 assert_eq!(provider_display_name("linode"), "Linode");
647 assert_eq!(provider_display_name("hetzner"), "Hetzner");
648 assert_eq!(provider_display_name("upcloud"), "UpCloud");
649 assert_eq!(provider_display_name("proxmox"), "Proxmox VE");
650 assert_eq!(provider_display_name("aws"), "AWS EC2");
651 assert_eq!(provider_display_name("scaleway"), "Scaleway");
652 assert_eq!(provider_display_name("gcp"), "GCP");
653 assert_eq!(provider_display_name("azure"), "Azure");
654 assert_eq!(provider_display_name("tailscale"), "Tailscale");
655 assert_eq!(provider_display_name("oracle"), "Oracle Cloud");
656 assert_eq!(provider_display_name("ovh"), "OVHcloud");
657 assert_eq!(provider_display_name("leaseweb"), "Leaseweb");
658 assert_eq!(provider_display_name("i3d"), "i3D.net");
659 assert_eq!(provider_display_name("transip"), "TransIP");
660 }
661
662 #[test]
663 fn test_display_name_unknown_returns_input() {
664 assert_eq!(
665 provider_display_name("unknown_provider"),
666 "unknown_provider"
667 );
668 assert_eq!(provider_display_name(""), "");
669 }
670
671 #[test]
676 fn test_provider_names_count() {
677 assert_eq!(PROVIDER_NAMES.len(), 16);
678 }
679
680 #[test]
681 fn test_provider_names_contains_all() {
682 assert!(PROVIDER_NAMES.contains(&"digitalocean"));
683 assert!(PROVIDER_NAMES.contains(&"vultr"));
684 assert!(PROVIDER_NAMES.contains(&"linode"));
685 assert!(PROVIDER_NAMES.contains(&"hetzner"));
686 assert!(PROVIDER_NAMES.contains(&"upcloud"));
687 assert!(PROVIDER_NAMES.contains(&"proxmox"));
688 assert!(PROVIDER_NAMES.contains(&"aws"));
689 assert!(PROVIDER_NAMES.contains(&"scaleway"));
690 assert!(PROVIDER_NAMES.contains(&"gcp"));
691 assert!(PROVIDER_NAMES.contains(&"azure"));
692 assert!(PROVIDER_NAMES.contains(&"tailscale"));
693 assert!(PROVIDER_NAMES.contains(&"oracle"));
694 assert!(PROVIDER_NAMES.contains(&"ovh"));
695 assert!(PROVIDER_NAMES.contains(&"leaseweb"));
696 assert!(PROVIDER_NAMES.contains(&"i3d"));
697 assert!(PROVIDER_NAMES.contains(&"transip"));
698 }
699
700 #[test]
705 fn test_provider_error_display_http() {
706 let err = ProviderError::Http("connection refused".to_string());
707 assert_eq!(format!("{}", err), "HTTP error: connection refused");
708 }
709
710 #[test]
711 fn test_provider_error_display_parse() {
712 let err = ProviderError::Parse("invalid JSON".to_string());
713 assert_eq!(format!("{}", err), "Failed to parse response: invalid JSON");
714 }
715
716 #[test]
717 fn test_provider_error_display_auth() {
718 let err = ProviderError::AuthFailed;
719 assert!(format!("{}", err).contains("Authentication failed"));
720 }
721
722 #[test]
723 fn test_provider_error_display_rate_limited() {
724 let err = ProviderError::RateLimited;
725 assert!(format!("{}", err).contains("Rate limited"));
726 }
727
728 #[test]
729 fn test_provider_error_display_cancelled() {
730 let err = ProviderError::Cancelled;
731 assert_eq!(format!("{}", err), "Cancelled.");
732 }
733
734 #[test]
735 fn test_provider_error_display_partial_result() {
736 let err = ProviderError::PartialResult {
737 hosts: vec![],
738 failures: 3,
739 total: 10,
740 };
741 assert!(format!("{}", err).contains("3 of 10 failed"));
742 }
743
744 #[test]
749 fn test_provider_host_construction() {
750 let host = ProviderHost::new(
751 "12345".to_string(),
752 "web-01".to_string(),
753 "1.2.3.4".to_string(),
754 vec!["prod".to_string(), "web".to_string()],
755 );
756 assert_eq!(host.server_id, "12345");
757 assert_eq!(host.name, "web-01");
758 assert_eq!(host.ip, "1.2.3.4");
759 assert_eq!(host.tags.len(), 2);
760 }
761
762 #[test]
763 fn test_provider_host_clone() {
764 let host = ProviderHost::new(
765 "1".to_string(),
766 "a".to_string(),
767 "1.1.1.1".to_string(),
768 vec![],
769 );
770 let cloned = host.clone();
771 assert_eq!(cloned.server_id, host.server_id);
772 assert_eq!(cloned.name, host.name);
773 }
774
775 #[test]
780 fn test_strip_cidr_ipv6_with_64() {
781 assert_eq!(strip_cidr("2a01:4f8::1/64"), "2a01:4f8::1");
782 }
783
784 #[test]
785 fn test_strip_cidr_ipv4_with_32() {
786 assert_eq!(strip_cidr("1.2.3.4/32"), "1.2.3.4");
787 }
788
789 #[test]
790 fn test_strip_cidr_ipv4_with_8() {
791 assert_eq!(strip_cidr("10.0.0.1/8"), "10.0.0.1");
792 }
793
794 #[test]
795 fn test_strip_cidr_just_slash() {
796 assert_eq!(strip_cidr("/"), "/");
798 }
799
800 #[test]
801 fn test_strip_cidr_slash_with_letters() {
802 assert_eq!(strip_cidr("10.0.0.1/abc"), "10.0.0.1/abc");
803 }
804
805 #[test]
806 fn test_strip_cidr_multiple_slashes() {
807 assert_eq!(strip_cidr("10.0.0.1/24/48"), "10.0.0.1/24");
809 }
810
811 #[test]
812 fn test_strip_cidr_ipv6_full_notation() {
813 assert_eq!(
814 strip_cidr("2001:0db8:85a3:0000:0000:8a2e:0370:7334/128"),
815 "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
816 );
817 }
818
819 #[test]
824 fn test_provider_error_debug_http() {
825 let err = ProviderError::Http("timeout".to_string());
826 let debug = format!("{:?}", err);
827 assert!(debug.contains("Http"));
828 assert!(debug.contains("timeout"));
829 }
830
831 #[test]
832 fn test_provider_error_debug_partial_result() {
833 let err = ProviderError::PartialResult {
834 hosts: vec![ProviderHost::new(
835 "1".to_string(),
836 "web".to_string(),
837 "1.2.3.4".to_string(),
838 vec![],
839 )],
840 failures: 2,
841 total: 5,
842 };
843 let debug = format!("{:?}", err);
844 assert!(debug.contains("PartialResult"));
845 assert!(debug.contains("failures: 2"));
846 }
847
848 #[test]
853 fn test_provider_host_empty_fields() {
854 let host = ProviderHost::new(String::new(), String::new(), String::new(), vec![]);
855 assert!(host.server_id.is_empty());
856 assert!(host.name.is_empty());
857 assert!(host.ip.is_empty());
858 }
859
860 #[test]
865 fn test_get_provider_with_config_all_providers() {
866 for &name in PROVIDER_NAMES {
867 let section = config::ProviderSection {
868 provider: name.to_string(),
869 token: "tok".to_string(),
870 alias_prefix: "test".to_string(),
871 user: String::new(),
872 identity_file: String::new(),
873 url: if name == "proxmox" {
874 "https://pve:8006".to_string()
875 } else {
876 String::new()
877 },
878 verify_tls: true,
879 auto_sync: true,
880 profile: String::new(),
881 regions: String::new(),
882 project: String::new(),
883 compartment: String::new(),
884 vault_role: String::new(),
885 vault_addr: String::new(),
886 };
887 let p = get_provider_with_config(name, §ion);
888 assert!(
889 p.is_some(),
890 "get_provider_with_config({}) should return Some",
891 name
892 );
893 assert_eq!(p.unwrap().name(), name);
894 }
895 }
896
897 #[test]
902 fn test_provider_fetch_hosts_delegates_to_cancellable() {
903 let provider = get_provider("digitalocean").unwrap();
904 let result = provider.fetch_hosts("fake-token");
908 assert!(result.is_err()); }
910
911 #[test]
916 fn test_strip_cidr_digit_then_letters_not_stripped() {
917 assert_eq!(strip_cidr("10.0.0.1/24abc"), "10.0.0.1/24abc");
918 }
919
920 #[test]
925 fn test_provider_display_name_all() {
926 assert_eq!(provider_display_name("digitalocean"), "DigitalOcean");
927 assert_eq!(provider_display_name("vultr"), "Vultr");
928 assert_eq!(provider_display_name("linode"), "Linode");
929 assert_eq!(provider_display_name("hetzner"), "Hetzner");
930 assert_eq!(provider_display_name("upcloud"), "UpCloud");
931 assert_eq!(provider_display_name("proxmox"), "Proxmox VE");
932 assert_eq!(provider_display_name("aws"), "AWS EC2");
933 assert_eq!(provider_display_name("scaleway"), "Scaleway");
934 assert_eq!(provider_display_name("gcp"), "GCP");
935 assert_eq!(provider_display_name("azure"), "Azure");
936 assert_eq!(provider_display_name("tailscale"), "Tailscale");
937 assert_eq!(provider_display_name("oracle"), "Oracle Cloud");
938 assert_eq!(provider_display_name("ovh"), "OVHcloud");
939 assert_eq!(provider_display_name("leaseweb"), "Leaseweb");
940 assert_eq!(provider_display_name("i3d"), "i3D.net");
941 assert_eq!(provider_display_name("transip"), "TransIP");
942 }
943
944 #[test]
945 fn test_provider_display_name_unknown() {
946 assert_eq!(
947 provider_display_name("unknown_provider"),
948 "unknown_provider"
949 );
950 }
951
952 #[test]
957 fn test_get_provider_all_known() {
958 for name in PROVIDER_NAMES {
959 assert!(
960 get_provider(name).is_some(),
961 "get_provider({}) should return Some",
962 name
963 );
964 }
965 }
966
967 #[test]
968 fn test_get_provider_case_sensitive_and_unknown() {
969 assert!(get_provider("unknown_provider").is_none());
970 assert!(get_provider("DigitalOcean").is_none()); assert!(get_provider("VULTR").is_none());
972 assert!(get_provider("").is_none());
973 }
974
975 #[test]
980 fn test_provider_names_has_all_sixteen() {
981 assert_eq!(PROVIDER_NAMES.len(), 16);
982 assert!(PROVIDER_NAMES.contains(&"digitalocean"));
983 assert!(PROVIDER_NAMES.contains(&"proxmox"));
984 assert!(PROVIDER_NAMES.contains(&"aws"));
985 assert!(PROVIDER_NAMES.contains(&"scaleway"));
986 assert!(PROVIDER_NAMES.contains(&"azure"));
987 assert!(PROVIDER_NAMES.contains(&"tailscale"));
988 assert!(PROVIDER_NAMES.contains(&"oracle"));
989 assert!(PROVIDER_NAMES.contains(&"ovh"));
990 assert!(PROVIDER_NAMES.contains(&"leaseweb"));
991 assert!(PROVIDER_NAMES.contains(&"i3d"));
992 assert!(PROVIDER_NAMES.contains(&"transip"));
993 }
994
995 #[test]
1000 fn test_provider_short_labels() {
1001 let cases = [
1002 ("digitalocean", "do"),
1003 ("vultr", "vultr"),
1004 ("linode", "linode"),
1005 ("hetzner", "hetzner"),
1006 ("upcloud", "uc"),
1007 ("proxmox", "pve"),
1008 ("aws", "aws"),
1009 ("scaleway", "scw"),
1010 ("gcp", "gcp"),
1011 ("azure", "az"),
1012 ("tailscale", "ts"),
1013 ("oracle", "oci"),
1014 ("ovh", "ovh"),
1015 ("leaseweb", "lsw"),
1016 ("i3d", "i3d"),
1017 ("transip", "tip"),
1018 ];
1019 for (name, expected_label) in &cases {
1020 let p = get_provider(name).unwrap();
1021 assert_eq!(p.short_label(), *expected_label, "short_label for {}", name);
1022 }
1023 }
1024
1025 #[test]
1030 fn test_http_agent_creates_agent() {
1031 let _agent = http_agent();
1033 }
1034
1035 #[test]
1036 fn test_http_agent_insecure_creates_agent() {
1037 let agent = http_agent_insecure();
1039 assert!(agent.is_ok());
1040 }
1041
1042 #[test]
1047 fn test_map_ureq_error_401_is_auth_failed() {
1048 let err = map_ureq_error(ureq::Error::StatusCode(401));
1049 assert!(matches!(err, ProviderError::AuthFailed));
1050 }
1051
1052 #[test]
1053 fn test_map_ureq_error_403_is_auth_failed() {
1054 let err = map_ureq_error(ureq::Error::StatusCode(403));
1055 assert!(matches!(err, ProviderError::AuthFailed));
1056 }
1057
1058 #[test]
1059 fn test_map_ureq_error_429_is_rate_limited() {
1060 let err = map_ureq_error(ureq::Error::StatusCode(429));
1061 assert!(matches!(err, ProviderError::RateLimited));
1062 }
1063
1064 #[test]
1065 fn test_map_ureq_error_500_is_http() {
1066 let err = map_ureq_error(ureq::Error::StatusCode(500));
1067 match err {
1068 ProviderError::Http(msg) => assert_eq!(msg, "HTTP 500"),
1069 other => panic!("expected Http, got {:?}", other),
1070 }
1071 }
1072
1073 #[test]
1074 fn test_map_ureq_error_404_is_http() {
1075 let err = map_ureq_error(ureq::Error::StatusCode(404));
1076 match err {
1077 ProviderError::Http(msg) => assert_eq!(msg, "HTTP 404"),
1078 other => panic!("expected Http, got {:?}", other),
1079 }
1080 }
1081
1082 #[test]
1083 fn test_map_ureq_error_502_is_http() {
1084 let err = map_ureq_error(ureq::Error::StatusCode(502));
1085 match err {
1086 ProviderError::Http(msg) => assert_eq!(msg, "HTTP 502"),
1087 other => panic!("expected Http, got {:?}", other),
1088 }
1089 }
1090
1091 #[test]
1092 fn test_map_ureq_error_503_is_http() {
1093 let err = map_ureq_error(ureq::Error::StatusCode(503));
1094 match err {
1095 ProviderError::Http(msg) => assert_eq!(msg, "HTTP 503"),
1096 other => panic!("expected Http, got {:?}", other),
1097 }
1098 }
1099
1100 #[test]
1101 fn test_map_ureq_error_200_is_http() {
1102 let err = map_ureq_error(ureq::Error::StatusCode(200));
1104 match err {
1105 ProviderError::Http(msg) => assert_eq!(msg, "HTTP 200"),
1106 other => panic!("expected Http, got {:?}", other),
1107 }
1108 }
1109
1110 #[test]
1111 fn test_map_ureq_error_non_status_is_http() {
1112 let err = map_ureq_error(ureq::Error::HostNotFound);
1114 match err {
1115 ProviderError::Http(msg) => assert!(!msg.is_empty()),
1116 other => panic!("expected Http, got {:?}", other),
1117 }
1118 }
1119
1120 #[test]
1121 fn test_map_ureq_error_all_auth_codes_covered() {
1122 for code in [400, 402, 405, 406, 407, 408, 409, 410] {
1124 let err = map_ureq_error(ureq::Error::StatusCode(code));
1125 assert!(
1126 matches!(err, ProviderError::Http(_)),
1127 "status {} should be Http, not AuthFailed",
1128 code
1129 );
1130 }
1131 }
1132
1133 #[test]
1134 fn test_map_ureq_error_only_429_is_rate_limited() {
1135 for code in [428, 430, 431] {
1137 let err = map_ureq_error(ureq::Error::StatusCode(code));
1138 assert!(
1139 !matches!(err, ProviderError::RateLimited),
1140 "status {} should not be RateLimited",
1141 code
1142 );
1143 }
1144 }
1145
1146 #[test]
1147 fn test_map_ureq_error_io_error() {
1148 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1149 let err = map_ureq_error(ureq::Error::Io(io_err));
1150 match err {
1151 ProviderError::Http(msg) => assert!(msg.contains("refused"), "got: {}", msg),
1152 other => panic!("expected Http, got {:?}", other),
1153 }
1154 }
1155
1156 #[test]
1157 fn test_map_ureq_error_timeout() {
1158 let err = map_ureq_error(ureq::Error::Timeout(ureq::Timeout::Global));
1159 match err {
1160 ProviderError::Http(msg) => assert!(!msg.is_empty()),
1161 other => panic!("expected Http, got {:?}", other),
1162 }
1163 }
1164
1165 #[test]
1166 fn test_map_ureq_error_connection_failed() {
1167 let err = map_ureq_error(ureq::Error::ConnectionFailed);
1168 match err {
1169 ProviderError::Http(msg) => assert!(!msg.is_empty()),
1170 other => panic!("expected Http, got {:?}", other),
1171 }
1172 }
1173
1174 #[test]
1175 fn test_map_ureq_error_bad_uri() {
1176 let err = map_ureq_error(ureq::Error::BadUri("no scheme".to_string()));
1177 match err {
1178 ProviderError::Http(msg) => assert!(msg.contains("no scheme"), "got: {}", msg),
1179 other => panic!("expected Http, got {:?}", other),
1180 }
1181 }
1182
1183 #[test]
1184 fn test_map_ureq_error_too_many_redirects() {
1185 let err = map_ureq_error(ureq::Error::TooManyRedirects);
1186 match err {
1187 ProviderError::Http(msg) => assert!(!msg.is_empty()),
1188 other => panic!("expected Http, got {:?}", other),
1189 }
1190 }
1191
1192 #[test]
1193 fn test_map_ureq_error_redirect_failed() {
1194 let err = map_ureq_error(ureq::Error::RedirectFailed);
1195 match err {
1196 ProviderError::Http(msg) => assert!(!msg.is_empty()),
1197 other => panic!("expected Http, got {:?}", other),
1198 }
1199 }
1200
1201 #[test]
1202 fn test_map_ureq_error_all_status_codes_1xx_to_5xx() {
1203 for code in [
1205 100, 200, 201, 301, 302, 400, 401, 403, 404, 429, 500, 502, 503, 504,
1206 ] {
1207 let err = map_ureq_error(ureq::Error::StatusCode(code));
1208 match code {
1209 401 | 403 => assert!(
1210 matches!(err, ProviderError::AuthFailed),
1211 "status {} should be AuthFailed",
1212 code
1213 ),
1214 429 => assert!(
1215 matches!(err, ProviderError::RateLimited),
1216 "status {} should be RateLimited",
1217 code
1218 ),
1219 _ => assert!(
1220 matches!(err, ProviderError::Http(_)),
1221 "status {} should be Http",
1222 code
1223 ),
1224 }
1225 }
1226 }
1227
1228 #[test]
1234 fn test_http_get_json_response() {
1235 let mut server = mockito::Server::new();
1236 let mock = server
1237 .mock("GET", "/api/test")
1238 .with_status(200)
1239 .with_header("content-type", "application/json")
1240 .with_body(r#"{"name": "test-server", "id": 42}"#)
1241 .create();
1242
1243 let agent = http_agent();
1244 let mut resp = agent
1245 .get(&format!("{}/api/test", server.url()))
1246 .call()
1247 .unwrap();
1248
1249 #[derive(serde::Deserialize)]
1250 struct TestResp {
1251 name: String,
1252 id: u32,
1253 }
1254
1255 let body: TestResp = resp.body_mut().read_json().unwrap();
1256 assert_eq!(body.name, "test-server");
1257 assert_eq!(body.id, 42);
1258 mock.assert();
1259 }
1260
1261 #[test]
1262 fn test_http_get_with_bearer_header() {
1263 let mut server = mockito::Server::new();
1264 let mock = server
1265 .mock("GET", "/api/hosts")
1266 .match_header("Authorization", "Bearer my-secret-token")
1267 .with_status(200)
1268 .with_header("content-type", "application/json")
1269 .with_body(r#"{"hosts": []}"#)
1270 .create();
1271
1272 let agent = http_agent();
1273 let resp = agent
1274 .get(&format!("{}/api/hosts", server.url()))
1275 .header("Authorization", "Bearer my-secret-token")
1276 .call();
1277
1278 assert!(resp.is_ok());
1279 mock.assert();
1280 }
1281
1282 #[test]
1283 fn test_http_get_with_custom_header() {
1284 let mut server = mockito::Server::new();
1285 let mock = server
1286 .mock("GET", "/api/servers")
1287 .match_header("X-Auth-Token", "scw-token-123")
1288 .with_status(200)
1289 .with_header("content-type", "application/json")
1290 .with_body(r#"{"servers": []}"#)
1291 .create();
1292
1293 let agent = http_agent();
1294 let resp = agent
1295 .get(&format!("{}/api/servers", server.url()))
1296 .header("X-Auth-Token", "scw-token-123")
1297 .call();
1298
1299 assert!(resp.is_ok());
1300 mock.assert();
1301 }
1302
1303 #[test]
1304 fn test_http_401_maps_to_auth_failed() {
1305 let mut server = mockito::Server::new();
1306 let mock = server
1307 .mock("GET", "/api/test")
1308 .with_status(401)
1309 .with_body("Unauthorized")
1310 .create();
1311
1312 let agent = http_agent();
1313 let err = agent
1314 .get(&format!("{}/api/test", server.url()))
1315 .call()
1316 .unwrap_err();
1317
1318 let provider_err = map_ureq_error(err);
1319 assert!(matches!(provider_err, ProviderError::AuthFailed));
1320 mock.assert();
1321 }
1322
1323 #[test]
1324 fn test_http_403_maps_to_auth_failed() {
1325 let mut server = mockito::Server::new();
1326 let mock = server
1327 .mock("GET", "/api/test")
1328 .with_status(403)
1329 .with_body("Forbidden")
1330 .create();
1331
1332 let agent = http_agent();
1333 let err = agent
1334 .get(&format!("{}/api/test", server.url()))
1335 .call()
1336 .unwrap_err();
1337
1338 let provider_err = map_ureq_error(err);
1339 assert!(matches!(provider_err, ProviderError::AuthFailed));
1340 mock.assert();
1341 }
1342
1343 #[test]
1344 fn test_http_429_maps_to_rate_limited() {
1345 let mut server = mockito::Server::new();
1346 let mock = server
1347 .mock("GET", "/api/test")
1348 .with_status(429)
1349 .with_body("Too Many Requests")
1350 .create();
1351
1352 let agent = http_agent();
1353 let err = agent
1354 .get(&format!("{}/api/test", server.url()))
1355 .call()
1356 .unwrap_err();
1357
1358 let provider_err = map_ureq_error(err);
1359 assert!(matches!(provider_err, ProviderError::RateLimited));
1360 mock.assert();
1361 }
1362
1363 #[test]
1364 fn test_http_500_maps_to_http_error() {
1365 let mut server = mockito::Server::new();
1366 let mock = server
1367 .mock("GET", "/api/test")
1368 .with_status(500)
1369 .with_body("Internal Server Error")
1370 .create();
1371
1372 let agent = http_agent();
1373 let err = agent
1374 .get(&format!("{}/api/test", server.url()))
1375 .call()
1376 .unwrap_err();
1377
1378 let provider_err = map_ureq_error(err);
1379 match provider_err {
1380 ProviderError::Http(msg) => assert_eq!(msg, "HTTP 500"),
1381 other => panic!("expected Http, got {:?}", other),
1382 }
1383 mock.assert();
1384 }
1385
1386 #[test]
1387 fn test_http_post_form_encoding() {
1388 let mut server = mockito::Server::new();
1389 let mock = server
1390 .mock("POST", "/oauth/token")
1391 .match_header("content-type", "application/x-www-form-urlencoded")
1392 .match_body(
1393 "grant_type=client_credentials&client_id=my-app&client_secret=secret123&scope=api",
1394 )
1395 .with_status(200)
1396 .with_header("content-type", "application/json")
1397 .with_body(r#"{"access_token": "eyJ.abc.def"}"#)
1398 .create();
1399
1400 let agent = http_agent();
1401 let client_id = "my-app".to_string();
1402 let client_secret = "secret123".to_string();
1403 let mut resp = agent
1404 .post(&format!("{}/oauth/token", server.url()))
1405 .send_form([
1406 ("grant_type", "client_credentials"),
1407 ("client_id", client_id.as_str()),
1408 ("client_secret", client_secret.as_str()),
1409 ("scope", "api"),
1410 ])
1411 .unwrap();
1412
1413 #[derive(serde::Deserialize)]
1414 struct TokenResp {
1415 access_token: String,
1416 }
1417
1418 let body: TokenResp = resp.body_mut().read_json().unwrap();
1419 assert_eq!(body.access_token, "eyJ.abc.def");
1420 mock.assert();
1421 }
1422
1423 #[test]
1424 fn test_http_read_to_string() {
1425 let mut server = mockito::Server::new();
1426 let mock = server
1427 .mock("GET", "/api/xml")
1428 .with_status(200)
1429 .with_header("content-type", "text/xml")
1430 .with_body("<root><item>hello</item></root>")
1431 .create();
1432
1433 let agent = http_agent();
1434 let mut resp = agent
1435 .get(&format!("{}/api/xml", server.url()))
1436 .call()
1437 .unwrap();
1438
1439 let body = resp.body_mut().read_to_string().unwrap();
1440 assert_eq!(body, "<root><item>hello</item></root>");
1441 mock.assert();
1442 }
1443
1444 #[test]
1445 fn test_http_body_reader_with_take() {
1446 use std::io::Read;
1448
1449 let mut server = mockito::Server::new();
1450 let mock = server
1451 .mock("GET", "/download")
1452 .with_status(200)
1453 .with_body("binary-content-here-12345")
1454 .create();
1455
1456 let agent = http_agent();
1457 let mut resp = agent
1458 .get(&format!("{}/download", server.url()))
1459 .call()
1460 .unwrap();
1461
1462 let mut bytes = Vec::new();
1463 resp.body_mut()
1464 .as_reader()
1465 .take(1_048_576)
1466 .read_to_end(&mut bytes)
1467 .unwrap();
1468
1469 assert_eq!(bytes, b"binary-content-here-12345");
1470 mock.assert();
1471 }
1472
1473 #[test]
1474 fn test_http_body_reader_take_truncates() {
1475 use std::io::Read;
1477
1478 let mut server = mockito::Server::new();
1479 let mock = server
1480 .mock("GET", "/large")
1481 .with_status(200)
1482 .with_body("abcdefghijklmnopqrstuvwxyz")
1483 .create();
1484
1485 let agent = http_agent();
1486 let mut resp = agent
1487 .get(&format!("{}/large", server.url()))
1488 .call()
1489 .unwrap();
1490
1491 let mut bytes = Vec::new();
1492 resp.body_mut()
1493 .as_reader()
1494 .take(10) .read_to_end(&mut bytes)
1496 .unwrap();
1497
1498 assert_eq!(bytes, b"abcdefghij");
1499 mock.assert();
1500 }
1501
1502 #[test]
1503 fn test_http_no_redirects() {
1504 let mut server = mockito::Server::new();
1508 let redirect_mock = server
1509 .mock("GET", "/redirect")
1510 .with_status(302)
1511 .with_header("Location", "/target")
1512 .create();
1513 let target_mock = server.mock("GET", "/target").with_status(200).create();
1514
1515 let agent = http_agent();
1516 let resp = agent
1517 .get(&format!("{}/redirect", server.url()))
1518 .call()
1519 .unwrap();
1520
1521 assert_eq!(resp.status(), 302);
1522 redirect_mock.assert();
1523 target_mock.expect(0); }
1525
1526 #[test]
1527 fn test_http_invalid_json_returns_parse_error() {
1528 let mut server = mockito::Server::new();
1529 let mock = server
1530 .mock("GET", "/api/bad")
1531 .with_status(200)
1532 .with_header("content-type", "application/json")
1533 .with_body("this is not json")
1534 .create();
1535
1536 let agent = http_agent();
1537 let mut resp = agent
1538 .get(&format!("{}/api/bad", server.url()))
1539 .call()
1540 .unwrap();
1541
1542 #[derive(serde::Deserialize)]
1543 #[allow(dead_code)]
1544 struct Expected {
1545 name: String,
1546 }
1547
1548 let result: Result<Expected, _> = resp.body_mut().read_json();
1549 assert!(result.is_err());
1550 mock.assert();
1551 }
1552
1553 #[test]
1554 fn test_http_empty_json_body_returns_parse_error() {
1555 let mut server = mockito::Server::new();
1556 let mock = server
1557 .mock("GET", "/api/empty")
1558 .with_status(200)
1559 .with_header("content-type", "application/json")
1560 .with_body("")
1561 .create();
1562
1563 let agent = http_agent();
1564 let mut resp = agent
1565 .get(&format!("{}/api/empty", server.url()))
1566 .call()
1567 .unwrap();
1568
1569 #[derive(serde::Deserialize)]
1570 #[allow(dead_code)]
1571 struct Expected {
1572 name: String,
1573 }
1574
1575 let result: Result<Expected, _> = resp.body_mut().read_json();
1576 assert!(result.is_err());
1577 mock.assert();
1578 }
1579
1580 #[test]
1581 fn test_http_multiple_headers() {
1582 let mut server = mockito::Server::new();
1584 let mock = server
1585 .mock("GET", "/api/aws")
1586 .match_header("Authorization", "AWS4-HMAC-SHA256 cred=test")
1587 .match_header("x-amz-date", "20260324T120000Z")
1588 .with_status(200)
1589 .with_header("content-type", "text/xml")
1590 .with_body("<result/>")
1591 .create();
1592
1593 let agent = http_agent();
1594 let mut resp = agent
1595 .get(&format!("{}/api/aws", server.url()))
1596 .header("Authorization", "AWS4-HMAC-SHA256 cred=test")
1597 .header("x-amz-date", "20260324T120000Z")
1598 .call()
1599 .unwrap();
1600
1601 let body = resp.body_mut().read_to_string().unwrap();
1602 assert_eq!(body, "<result/>");
1603 mock.assert();
1604 }
1605
1606 #[test]
1607 fn test_http_connection_refused_maps_to_http_error() {
1608 let agent = http_agent();
1610 let err = agent.get("http://127.0.0.1:1").call().unwrap_err();
1611
1612 let provider_err = map_ureq_error(err);
1613 match provider_err {
1614 ProviderError::Http(msg) => assert!(!msg.is_empty()),
1615 other => panic!("expected Http, got {:?}", other),
1616 }
1617 }
1618
1619 #[test]
1620 fn test_http_nested_json_deserialization() {
1621 let mut server = mockito::Server::new();
1623 let mock = server
1624 .mock("GET", "/api/droplets")
1625 .with_status(200)
1626 .with_header("content-type", "application/json")
1627 .with_body(
1628 r#"{
1629 "data": [
1630 {"id": "1", "name": "web-01", "ip": "1.2.3.4"},
1631 {"id": "2", "name": "web-02", "ip": "5.6.7.8"}
1632 ],
1633 "meta": {"total": 2}
1634 }"#,
1635 )
1636 .create();
1637
1638 #[derive(serde::Deserialize)]
1639 #[allow(dead_code)]
1640 struct Host {
1641 id: String,
1642 name: String,
1643 ip: String,
1644 }
1645 #[derive(serde::Deserialize)]
1646 #[allow(dead_code)]
1647 struct Meta {
1648 total: u32,
1649 }
1650 #[derive(serde::Deserialize)]
1651 #[allow(dead_code)]
1652 struct Resp {
1653 data: Vec<Host>,
1654 meta: Meta,
1655 }
1656
1657 let agent = http_agent();
1658 let mut resp = agent
1659 .get(&format!("{}/api/droplets", server.url()))
1660 .call()
1661 .unwrap();
1662
1663 let body: Resp = resp.body_mut().read_json().unwrap();
1664 assert_eq!(body.data.len(), 2);
1665 assert_eq!(body.data[0].name, "web-01");
1666 assert_eq!(body.data[1].ip, "5.6.7.8");
1667 assert_eq!(body.meta.total, 2);
1668 mock.assert();
1669 }
1670
1671 #[test]
1672 fn test_http_xml_deserialization_with_quick_xml() {
1673 let mut server = mockito::Server::new();
1675 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1676 <DescribeInstancesResponse>
1677 <reservationSet>
1678 <item>
1679 <instancesSet>
1680 <item>
1681 <instanceId>i-abc123</instanceId>
1682 <instanceState><name>running</name></instanceState>
1683 </item>
1684 </instancesSet>
1685 </item>
1686 </reservationSet>
1687 </DescribeInstancesResponse>"#;
1688
1689 let mock = server
1690 .mock("GET", "/ec2")
1691 .with_status(200)
1692 .with_header("content-type", "text/xml")
1693 .with_body(xml)
1694 .create();
1695
1696 let agent = http_agent();
1697 let mut resp = agent.get(&format!("{}/ec2", server.url())).call().unwrap();
1698
1699 let body = resp.body_mut().read_to_string().unwrap();
1700 #[derive(serde::Deserialize)]
1702 struct InstanceState {
1703 name: String,
1704 }
1705 #[derive(serde::Deserialize)]
1706 struct Instance {
1707 #[serde(rename = "instanceId")]
1708 instance_id: String,
1709 #[serde(rename = "instanceState")]
1710 instance_state: InstanceState,
1711 }
1712 #[derive(serde::Deserialize)]
1713 struct InstanceSet {
1714 item: Vec<Instance>,
1715 }
1716 #[derive(serde::Deserialize)]
1717 struct Reservation {
1718 #[serde(rename = "instancesSet")]
1719 instances_set: InstanceSet,
1720 }
1721 #[derive(serde::Deserialize)]
1722 struct ReservationSet {
1723 item: Vec<Reservation>,
1724 }
1725 #[derive(serde::Deserialize)]
1726 struct DescribeResp {
1727 #[serde(rename = "reservationSet")]
1728 reservation_set: ReservationSet,
1729 }
1730
1731 let parsed: DescribeResp = quick_xml::de::from_str(&body).unwrap();
1732 assert_eq!(
1733 parsed.reservation_set.item[0].instances_set.item[0].instance_id,
1734 "i-abc123"
1735 );
1736 assert_eq!(
1737 parsed.reservation_set.item[0].instances_set.item[0]
1738 .instance_state
1739 .name,
1740 "running"
1741 );
1742 mock.assert();
1743 }
1744}