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