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