Skip to main content

purple_ssh/providers/
scaleway.rs

1use std::collections::HashSet;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4use serde::Deserialize;
5
6use super::{Provider, ProviderError, ProviderHost, map_ureq_error};
7
8pub struct Scaleway {
9    pub zones: Vec<String>,
10}
11
12/// All Scaleway availability zones with display names.
13/// Single source of truth. SCW_ZONE_GROUPS references slices of this array.
14pub const SCW_ZONES: &[(&str, &str)] = &[
15    // Paris (0..3)
16    ("fr-par-1", "Paris 1"),
17    ("fr-par-2", "Paris 2"),
18    ("fr-par-3", "Paris 3"),
19    // Amsterdam (3..6)
20    ("nl-ams-1", "Amsterdam 1"),
21    ("nl-ams-2", "Amsterdam 2"),
22    ("nl-ams-3", "Amsterdam 3"),
23    // Warsaw (6..9)
24    ("pl-waw-1", "Warsaw 1"),
25    ("pl-waw-2", "Warsaw 2"),
26    ("pl-waw-3", "Warsaw 3"),
27    // Milan (9..10)
28    ("it-mil-1", "Milan 1"),
29];
30
31/// Zone group labels with start..end indices into SCW_ZONES.
32pub const SCW_ZONE_GROUPS: &[(&str, usize, usize)] = &[
33    ("Paris", 0, 3),
34    ("Amsterdam", 3, 6),
35    ("Warsaw", 6, 9),
36    ("Milan", 9, 10),
37];
38
39// --- Serde response models ---
40
41#[derive(Deserialize)]
42struct ListServersResponse {
43    #[serde(default)]
44    servers: Vec<ScalewayServer>,
45    #[serde(default)]
46    total_count: u64,
47}
48
49#[derive(Deserialize)]
50struct ScalewayServer {
51    id: String,
52    name: String,
53    #[serde(default)]
54    state: String,
55    #[serde(default)]
56    commercial_type: String,
57    #[serde(default)]
58    tags: Vec<String>,
59    #[serde(default)]
60    public_ips: Vec<ServerIp>,
61    #[serde(default)]
62    public_ip: Option<LegacyPublicIp>,
63    #[serde(default)]
64    private_ip: Option<String>,
65    #[serde(default)]
66    image: Option<ScalewayImage>,
67    #[serde(default)]
68    #[allow(dead_code)]
69    // Deserialized from API but we use the zone parameter from the request URL
70    zone: String,
71}
72
73#[derive(Deserialize)]
74struct ServerIp {
75    #[serde(default)]
76    address: String,
77    #[serde(default)]
78    family: String,
79}
80
81#[derive(Deserialize)]
82struct LegacyPublicIp {
83    #[serde(default)]
84    address: String,
85}
86
87#[derive(Deserialize)]
88struct ScalewayImage {
89    #[serde(default)]
90    name: Option<String>,
91}
92
93/// Build metadata key-value pairs for a server.
94fn build_metadata(server: &ScalewayServer, zone: &str) -> Vec<(String, String)> {
95    let mut metadata = Vec::new();
96    if !zone.is_empty() {
97        metadata.push(("zone".to_string(), zone.to_string()));
98    }
99    if !server.commercial_type.is_empty() {
100        metadata.push(("type".to_string(), server.commercial_type.clone()));
101    }
102    if let Some(ref image) = server.image {
103        if let Some(ref name) = image.name {
104            if !name.is_empty() {
105                metadata.push(("image".to_string(), name.clone()));
106            }
107        }
108    }
109    if !server.state.is_empty() {
110        metadata.push(("status".to_string(), server.state.clone()));
111    }
112    metadata
113}
114
115/// Select the best IP for a server.
116/// Prefers public IPv4 > public IPv6 > legacy public_ip > private_ip.
117fn select_ip(server: &ScalewayServer) -> Option<String> {
118    // Prefer public IPv4 from public_ips
119    if let Some(ip) = server
120        .public_ips
121        .iter()
122        .find(|ip| ip.family == "inet" && !ip.address.is_empty())
123    {
124        return Some(super::strip_cidr(&ip.address).to_string());
125    }
126    // Fall back to public IPv6 from public_ips
127    if let Some(ip) = server
128        .public_ips
129        .iter()
130        .find(|ip| ip.family == "inet6" && !ip.address.is_empty())
131    {
132        return Some(super::strip_cidr(&ip.address).to_string());
133    }
134    // Fall back to legacy public_ip field
135    if let Some(ref legacy) = server.public_ip {
136        if !legacy.address.is_empty() {
137            return Some(legacy.address.clone());
138        }
139    }
140    // Fall back to private_ip
141    if let Some(ref priv_ip) = server.private_ip {
142        if !priv_ip.is_empty() {
143            return Some(priv_ip.clone());
144        }
145    }
146    None
147}
148
149impl Provider for Scaleway {
150    fn name(&self) -> &str {
151        "scaleway"
152    }
153
154    fn short_label(&self) -> &str {
155        "scw"
156    }
157
158    fn fetch_hosts_cancellable(
159        &self,
160        token: &str,
161        cancel: &AtomicBool,
162        _env: &crate::runtime::env::Env,
163    ) -> Result<Vec<ProviderHost>, ProviderError> {
164        self.fetch_hosts_with_progress(token, cancel, _env, &|_| {})
165    }
166
167    fn fetch_hosts_with_progress(
168        &self,
169        token: &str,
170        cancel: &AtomicBool,
171        _env: &crate::runtime::env::Env,
172        progress: &dyn Fn(&str),
173    ) -> Result<Vec<ProviderHost>, ProviderError> {
174        if self.zones.is_empty() {
175            return Err(ProviderError::Http(
176                "No Scaleway zones configured. Add zones in the provider settings.".to_string(),
177            ));
178        }
179
180        let valid_codes: HashSet<&str> = SCW_ZONES.iter().map(|(c, _)| *c).collect();
181        for zone in &self.zones {
182            if !valid_codes.contains(zone.as_str()) {
183                return Err(ProviderError::Http(format!(
184                    "Unknown Scaleway zone '{}'. Check your provider settings.",
185                    zone
186                )));
187            }
188        }
189
190        let agent = super::http_agent();
191        let total_zones = self.zones.len();
192        let mut all_hosts = Vec::new();
193        let mut failed_zones = 0usize;
194
195        for (i, zone) in self.zones.iter().enumerate() {
196            if cancel.load(Ordering::Relaxed) {
197                return Err(ProviderError::Cancelled);
198            }
199
200            progress(&format!("Fetching {} ({}/{})...", zone, i + 1, total_zones));
201
202            match fetch_zone(&agent, token, zone, cancel) {
203                Ok(hosts) => all_hosts.extend(hosts),
204                Err(ProviderError::Cancelled) => return Err(ProviderError::Cancelled),
205                Err(ProviderError::AuthFailed) => return Err(ProviderError::AuthFailed),
206                Err(ProviderError::RateLimited) => return Err(ProviderError::RateLimited),
207                Err(_) => {
208                    failed_zones += 1;
209                    continue;
210                }
211            }
212        }
213
214        // Summary
215        let mut parts = vec![format!("{} instances", all_hosts.len())];
216        if failed_zones > 0 {
217            parts.push(format!("{} of {} zones failed", failed_zones, total_zones));
218        }
219        progress(&parts.join(", "));
220
221        if failed_zones > 0 {
222            if all_hosts.is_empty() {
223                return Err(ProviderError::Http(format!(
224                    "All {} zones failed. Check your credentials and zone configuration.",
225                    total_zones,
226                )));
227            }
228            return Err(ProviderError::PartialResult {
229                hosts: all_hosts,
230                failures: failed_zones,
231                total: total_zones,
232            });
233        }
234
235        Ok(all_hosts)
236    }
237}
238
239/// Fetch all servers in a single zone (handles pagination).
240fn fetch_zone(
241    agent: &ureq::Agent,
242    token: &str,
243    zone: &str,
244    cancel: &AtomicBool,
245) -> Result<Vec<ProviderHost>, ProviderError> {
246    let mut hosts = Vec::new();
247    let mut page = 1u64;
248    let per_page = 100;
249
250    loop {
251        if cancel.load(Ordering::Relaxed) {
252            return Err(ProviderError::Cancelled);
253        }
254
255        let url = format!(
256            "https://api.scaleway.com/instance/v1/zones/{}/servers?page={}&per_page={}",
257            zone, page, per_page
258        );
259        let resp: ListServersResponse = agent
260            .get(&url)
261            .header("X-Auth-Token", token)
262            .call()
263            .map_err(map_ureq_error)?
264            .body_mut()
265            .read_json()
266            .map_err(|e| ProviderError::Parse(format!("{}: {}", zone, e)))?;
267
268        if resp.servers.is_empty() {
269            break;
270        }
271
272        let count = resp.servers.len();
273
274        for server in &resp.servers {
275            if let Some(ip) = select_ip(server) {
276                hosts.push(ProviderHost {
277                    server_id: server.id.clone(),
278                    name: server.name.clone(),
279                    ip,
280                    tags: server.tags.clone(),
281                    metadata: build_metadata(server, zone),
282                });
283            }
284        }
285
286        let total = resp.total_count;
287        if (count as u64) < per_page || (total > 0 && page * per_page >= total) {
288            break;
289        }
290        page += 1;
291        if page > 500 {
292            break;
293        }
294    }
295
296    Ok(hosts)
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    // =========================================================================
304    // Response parsing
305    // =========================================================================
306
307    #[test]
308    fn test_parse_list_servers_response() {
309        let json = r#"{
310            "servers": [
311                {
312                    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
313                    "name": "web-1",
314                    "state": "running",
315                    "commercial_type": "DEV1-S",
316                    "tags": ["production"],
317                    "public_ips": [
318                        {"id": "ip-1", "address": "51.15.1.2", "family": "inet"}
319                    ],
320                    "zone": "fr-par-1"
321                }
322            ]
323        }"#;
324        let resp: ListServersResponse = serde_json::from_str(json).unwrap();
325        assert_eq!(resp.servers.len(), 1);
326        assert_eq!(resp.servers[0].name, "web-1");
327        assert_eq!(resp.servers[0].state, "running");
328        assert_eq!(resp.servers[0].commercial_type, "DEV1-S");
329    }
330
331    #[test]
332    fn test_parse_server_with_public_ips() {
333        let json = r#"{
334            "servers": [{
335                "id": "abc",
336                "name": "dual",
337                "public_ips": [
338                    {"address": "51.15.1.2", "family": "inet"},
339                    {"address": "2001:bc8::1", "family": "inet6"}
340                ],
341                "tags": []
342            }]
343        }"#;
344        let resp: ListServersResponse = serde_json::from_str(json).unwrap();
345        assert_eq!(resp.servers[0].public_ips.len(), 2);
346        assert_eq!(resp.servers[0].public_ips[0].family, "inet");
347        assert_eq!(resp.servers[0].public_ips[1].family, "inet6");
348    }
349
350    #[test]
351    fn test_parse_server_with_legacy_public_ip() {
352        let json = r#"{
353            "servers": [{
354                "id": "abc",
355                "name": "legacy",
356                "public_ips": [],
357                "public_ip": {"address": "51.15.1.2", "dynamic": false},
358                "tags": []
359            }]
360        }"#;
361        let resp: ListServersResponse = serde_json::from_str(json).unwrap();
362        assert_eq!(
363            resp.servers[0].public_ip.as_ref().unwrap().address,
364            "51.15.1.2"
365        );
366    }
367
368    #[test]
369    fn test_parse_server_extra_fields_ignored() {
370        let json = r#"{
371            "servers": [{
372                "id": "abc",
373                "name": "full",
374                "state": "running",
375                "commercial_type": "GP1-M",
376                "tags": ["web"],
377                "public_ips": [{"address": "1.2.3.4", "family": "inet"}],
378                "created_at": "2024-01-01T00:00:00Z",
379                "disk": 25,
380                "memory": 2147483648,
381                "arch": "x86_64",
382                "hostname": "full",
383                "zone": "fr-par-1"
384            }]
385        }"#;
386        let resp: ListServersResponse = serde_json::from_str(json).unwrap();
387        assert_eq!(resp.servers[0].name, "full");
388    }
389
390    // =========================================================================
391    // IP selection
392    // =========================================================================
393
394    fn server_with_ips(
395        public_ips: Vec<ServerIp>,
396        public_ip: Option<LegacyPublicIp>,
397        private_ip: Option<String>,
398    ) -> ScalewayServer {
399        ScalewayServer {
400            id: "test".to_string(),
401            name: "test".to_string(),
402            state: String::new(),
403            commercial_type: String::new(),
404            tags: vec![],
405            public_ips,
406            public_ip,
407            private_ip,
408            image: None,
409            zone: String::new(),
410        }
411    }
412
413    #[test]
414    fn test_select_ip_prefers_v4_over_v6() {
415        let server = server_with_ips(
416            vec![
417                ServerIp {
418                    address: "51.15.1.2".to_string(),
419                    family: "inet".to_string(),
420                },
421                ServerIp {
422                    address: "2001:bc8::1".to_string(),
423                    family: "inet6".to_string(),
424                },
425            ],
426            None,
427            None,
428        );
429        assert_eq!(select_ip(&server), Some("51.15.1.2".to_string()));
430    }
431
432    #[test]
433    fn test_select_ip_v6_only() {
434        let server = server_with_ips(
435            vec![ServerIp {
436                address: "2001:bc8::1".to_string(),
437                family: "inet6".to_string(),
438            }],
439            None,
440            None,
441        );
442        assert_eq!(select_ip(&server), Some("2001:bc8::1".to_string()));
443    }
444
445    #[test]
446    fn test_select_ip_empty_public_ips_uses_legacy() {
447        let server = server_with_ips(
448            vec![],
449            Some(LegacyPublicIp {
450                address: "51.15.1.2".to_string(),
451            }),
452            None,
453        );
454        assert_eq!(select_ip(&server), Some("51.15.1.2".to_string()));
455    }
456
457    #[test]
458    fn test_select_ip_falls_back_to_private() {
459        let server = server_with_ips(vec![], None, Some("10.0.0.5".to_string()));
460        assert_eq!(select_ip(&server), Some("10.0.0.5".to_string()));
461    }
462
463    #[test]
464    fn test_select_ip_no_ip_returns_none() {
465        let server = server_with_ips(vec![], None, None);
466        assert_eq!(select_ip(&server), None);
467    }
468
469    #[test]
470    fn test_select_ip_empty_address_skipped() {
471        let server = server_with_ips(
472            vec![ServerIp {
473                address: String::new(),
474                family: "inet".to_string(),
475            }],
476            None,
477            None,
478        );
479        assert_eq!(select_ip(&server), None);
480    }
481
482    #[test]
483    fn test_select_ip_v6_cidr_stripped() {
484        let server = server_with_ips(
485            vec![ServerIp {
486                address: "2001:bc8::1/128".to_string(),
487                family: "inet6".to_string(),
488            }],
489            None,
490            None,
491        );
492        assert_eq!(select_ip(&server), Some("2001:bc8::1".to_string()));
493    }
494
495    #[test]
496    fn test_select_ip_multiple_v4_uses_first() {
497        let server = server_with_ips(
498            vec![
499                ServerIp {
500                    address: "51.15.1.2".to_string(),
501                    family: "inet".to_string(),
502                },
503                ServerIp {
504                    address: "51.15.1.3".to_string(),
505                    family: "inet".to_string(),
506                },
507            ],
508            None,
509            None,
510        );
511        assert_eq!(select_ip(&server), Some("51.15.1.2".to_string()));
512    }
513
514    #[test]
515    fn test_select_ip_empty_private_skipped() {
516        let server = server_with_ips(vec![], None, Some(String::new()));
517        assert_eq!(select_ip(&server), None);
518    }
519
520    // =========================================================================
521    // Tags
522    // =========================================================================
523
524    #[test]
525    fn test_tags_preserved() {
526        let json = r#"{
527            "servers": [{
528                "id": "abc",
529                "name": "tagged",
530                "public_ips": [{"address": "1.2.3.4", "family": "inet"}],
531                "tags": ["web", "production", "eu"]
532            }]
533        }"#;
534        let resp: ListServersResponse = serde_json::from_str(json).unwrap();
535        assert_eq!(resp.servers[0].tags, vec!["web", "production", "eu"]);
536    }
537
538    #[test]
539    fn test_default_tags_empty() {
540        let json = r#"{
541            "servers": [{"id": "abc", "name": "no-tags", "public_ips": []}]
542        }"#;
543        let resp: ListServersResponse = serde_json::from_str(json).unwrap();
544        assert!(resp.servers[0].tags.is_empty());
545    }
546
547    // =========================================================================
548    // Metadata
549    // =========================================================================
550
551    #[test]
552    fn test_metadata_from_server() {
553        let server = ScalewayServer {
554            id: "abc".to_string(),
555            name: "web-1".to_string(),
556            state: "running".to_string(),
557            commercial_type: "DEV1-S".to_string(),
558            tags: vec![],
559            public_ips: vec![ServerIp {
560                address: "1.2.3.4".to_string(),
561                family: "inet".to_string(),
562            }],
563            public_ip: None,
564            private_ip: None,
565            image: Some(ScalewayImage {
566                name: Some("Ubuntu 22.04 Jammy Jellyfish".to_string()),
567            }),
568            zone: "fr-par-1".to_string(),
569        };
570        let ip = select_ip(&server).unwrap();
571        assert_eq!(ip, "1.2.3.4");
572
573        let metadata = build_metadata(&server, "fr-par-1");
574        assert_eq!(
575            metadata,
576            vec![
577                ("zone".to_string(), "fr-par-1".to_string()),
578                ("type".to_string(), "DEV1-S".to_string()),
579                (
580                    "image".to_string(),
581                    "Ubuntu 22.04 Jammy Jellyfish".to_string()
582                ),
583                ("status".to_string(), "running".to_string()),
584            ]
585        );
586    }
587
588    #[test]
589    fn test_metadata_uses_zone_param_not_server_field() {
590        let server = ScalewayServer {
591            id: "abc".to_string(),
592            name: "web-1".to_string(),
593            state: "running".to_string(),
594            commercial_type: String::new(),
595            tags: vec![],
596            public_ips: vec![],
597            public_ip: None,
598            private_ip: None,
599            image: None,
600            zone: "nl-ams-2".to_string(),
601        };
602        let metadata = build_metadata(&server, "fr-par-1");
603        assert_eq!(metadata[0], ("zone".to_string(), "fr-par-1".to_string()));
604    }
605
606    #[test]
607    fn test_metadata_empty_fields_omitted() {
608        let server = ScalewayServer {
609            id: "abc".to_string(),
610            name: "bare".to_string(),
611            state: String::new(),
612            commercial_type: String::new(),
613            tags: vec![],
614            public_ips: vec![ServerIp {
615                address: "1.2.3.4".to_string(),
616                family: "inet".to_string(),
617            }],
618            public_ip: None,
619            private_ip: None,
620            image: None,
621            zone: String::new(),
622        };
623        let metadata = build_metadata(&server, "");
624        assert!(metadata.is_empty());
625    }
626
627    // =========================================================================
628    // Pagination
629    // =========================================================================
630
631    #[test]
632    fn test_empty_server_list_stops_pagination() {
633        let json = r#"{"servers": []}"#;
634        let resp: ListServersResponse = serde_json::from_str(json).unwrap();
635        assert!(resp.servers.is_empty());
636    }
637
638    // =========================================================================
639    // Zone constants
640    // =========================================================================
641
642    #[test]
643    fn test_scw_zones_count() {
644        assert_eq!(SCW_ZONES.len(), 10);
645    }
646
647    #[test]
648    fn test_scw_zone_groups_cover_all_zones() {
649        let total: usize = SCW_ZONE_GROUPS.iter().map(|&(_, s, e)| e - s).sum();
650        assert_eq!(total, SCW_ZONES.len());
651        let mut expected_start = 0;
652        for &(_, start, end) in SCW_ZONE_GROUPS {
653            assert_eq!(start, expected_start, "Gap or overlap in zone groups");
654            assert!(end > start, "Empty zone group");
655            expected_start = end;
656        }
657        assert_eq!(expected_start, SCW_ZONES.len());
658    }
659
660    #[test]
661    fn test_scw_zones_no_duplicates() {
662        let mut seen = HashSet::new();
663        for (code, _) in SCW_ZONES {
664            assert!(seen.insert(code), "Duplicate zone: {}", code);
665        }
666    }
667
668    #[test]
669    fn test_scw_zones_contains_common() {
670        let codes: Vec<&str> = SCW_ZONES.iter().map(|(c, _)| *c).collect();
671        assert!(codes.contains(&"fr-par-1"));
672        assert!(codes.contains(&"nl-ams-1"));
673        assert!(codes.contains(&"pl-waw-1"));
674        assert!(codes.contains(&"it-mil-1"));
675    }
676
677    // =========================================================================
678    // Provider trait
679    // =========================================================================
680
681    #[test]
682    fn test_scaleway_provider_name() {
683        let scw = Scaleway { zones: vec![] };
684        assert_eq!(scw.name(), "scaleway");
685        assert_eq!(scw.short_label(), "scw");
686    }
687
688    #[test]
689    fn test_scaleway_no_zones_error() {
690        let scw = Scaleway { zones: vec![] };
691        let result = scw.fetch_hosts("fake-token", &crate::runtime::env::Env::empty());
692        match result {
693            Err(ProviderError::Http(msg)) => assert!(msg.contains("No Scaleway zones")),
694            other => panic!("Expected Http error, got: {:?}", other),
695        }
696    }
697
698    #[test]
699    fn test_scaleway_invalid_zone_error() {
700        let scw = Scaleway {
701            zones: vec!["xx-invalid-1".to_string()],
702        };
703        let result = scw.fetch_hosts("fake-token", &crate::runtime::env::Env::empty());
704        match result {
705            Err(ProviderError::Http(msg)) => assert!(msg.contains("Unknown Scaleway zone")),
706            other => panic!("Expected Http error for invalid zone, got: {:?}", other),
707        }
708    }
709
710    #[test]
711    fn test_scaleway_mixed_valid_invalid_zone_error() {
712        let scw = Scaleway {
713            zones: vec!["fr-par-1".to_string(), "xx-fake-9".to_string()],
714        };
715        let result = scw.fetch_hosts("fake-token", &crate::runtime::env::Env::empty());
716        match result {
717            Err(ProviderError::Http(msg)) => assert!(msg.contains("xx-fake-9")),
718            other => panic!("Expected Http error for invalid zone, got: {:?}", other),
719        }
720    }
721
722    // =========================================================================
723    // Server ID is UUID string
724    // =========================================================================
725
726    #[test]
727    fn test_server_id_is_uuid_string() {
728        let json = r#"{
729            "servers": [{
730                "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
731                "name": "uuid-test",
732                "public_ips": [],
733                "tags": []
734            }]
735        }"#;
736        let resp: ListServersResponse = serde_json::from_str(json).unwrap();
737        assert_eq!(resp.servers[0].id, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
738    }
739
740    // =========================================================================
741    // Image parsing
742    // =========================================================================
743
744    #[test]
745    fn test_image_name_parsed() {
746        let json = r#"{
747            "servers": [{
748                "id": "abc",
749                "name": "with-image",
750                "image": {"id": "img-1", "name": "Ubuntu 22.04 Jammy Jellyfish"},
751                "public_ips": [],
752                "tags": []
753            }]
754        }"#;
755        let resp: ListServersResponse = serde_json::from_str(json).unwrap();
756        assert_eq!(
757            resp.servers[0].image.as_ref().unwrap().name.as_deref(),
758            Some("Ubuntu 22.04 Jammy Jellyfish")
759        );
760    }
761
762    #[test]
763    fn test_image_null_handled() {
764        let json = r#"{
765            "servers": [{
766                "id": "abc",
767                "name": "no-image",
768                "image": null,
769                "public_ips": [],
770                "tags": []
771            }]
772        }"#;
773        let resp: ListServersResponse = serde_json::from_str(json).unwrap();
774        assert!(resp.servers[0].image.is_none());
775    }
776
777    // =========================================================================
778    // Private IP field
779    // =========================================================================
780
781    #[test]
782    fn test_private_ip_parsed() {
783        let json = r#"{
784            "servers": [{
785                "id": "abc",
786                "name": "priv",
787                "private_ip": "10.1.2.3",
788                "public_ips": [],
789                "tags": []
790            }]
791        }"#;
792        let resp: ListServersResponse = serde_json::from_str(json).unwrap();
793        assert_eq!(resp.servers[0].private_ip.as_deref(), Some("10.1.2.3"));
794    }
795
796    // =========================================================================
797    // HTTP roundtrip tests (mockito)
798    // =========================================================================
799
800    #[test]
801    fn test_http_list_servers_roundtrip() {
802        let mut server = mockito::Server::new();
803        let mock = server
804            .mock("GET", "/instance/v1/zones/fr-par-1/servers")
805            .match_query(mockito::Matcher::AllOf(vec![
806                mockito::Matcher::UrlEncoded("page".into(), "1".into()),
807                mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
808            ]))
809            .match_header("X-Auth-Token", "scw-secret-token-123")
810            .with_status(200)
811            .with_header("content-type", "application/json")
812            .with_body(
813                r#"{
814                    "servers": [
815                        {
816                            "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
817                            "name": "web-prod-1",
818                            "state": "running",
819                            "commercial_type": "DEV1-S",
820                            "tags": ["production", "web"],
821                            "public_ips": [
822                                {"address": "51.15.42.10", "family": "inet"},
823                                {"address": "2001:bc8:1200::1", "family": "inet6"}
824                            ],
825                            "private_ip": "10.68.0.5",
826                            "image": {"id": "img-1", "name": "Ubuntu 22.04 Jammy Jellyfish"},
827                            "zone": "fr-par-1"
828                        }
829                    ],
830                    "total_count": 1
831                }"#,
832            )
833            .create();
834
835        let agent = super::super::http_agent();
836        let url = format!(
837            "{}/instance/v1/zones/fr-par-1/servers?page=1&per_page=100",
838            server.url()
839        );
840        let resp: ListServersResponse = agent
841            .get(&url)
842            .header("X-Auth-Token", "scw-secret-token-123")
843            .call()
844            .unwrap()
845            .body_mut()
846            .read_json()
847            .unwrap();
848
849        assert_eq!(resp.servers.len(), 1);
850        let s = &resp.servers[0];
851        assert_eq!(s.id, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
852        assert_eq!(s.name, "web-prod-1");
853        assert_eq!(s.state, "running");
854        assert_eq!(s.commercial_type, "DEV1-S");
855        assert_eq!(s.tags, vec!["production", "web"]);
856        assert_eq!(s.public_ips.len(), 2);
857        assert_eq!(s.public_ips[0].address, "51.15.42.10");
858        assert_eq!(s.public_ips[0].family, "inet");
859        assert_eq!(select_ip(s), Some("51.15.42.10".to_string()));
860        assert_eq!(resp.total_count, 1);
861        mock.assert();
862    }
863
864    #[test]
865    fn test_http_list_servers_pagination() {
866        let mut server = mockito::Server::new();
867        let page1 = server
868            .mock("GET", "/instance/v1/zones/nl-ams-1/servers")
869            .match_query(mockito::Matcher::AllOf(vec![
870                mockito::Matcher::UrlEncoded("page".into(), "1".into()),
871                mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
872            ]))
873            .with_status(200)
874            .with_header("content-type", "application/json")
875            .with_body(
876                r#"{
877                    "servers": [{"id": "s1", "name": "a", "public_ips": [{"address": "1.1.1.1", "family": "inet"}], "tags": []}],
878                    "total_count": 2
879                }"#,
880            )
881            .create();
882        let page2 = server
883            .mock("GET", "/instance/v1/zones/nl-ams-1/servers")
884            .match_query(mockito::Matcher::AllOf(vec![
885                mockito::Matcher::UrlEncoded("page".into(), "2".into()),
886                mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
887            ]))
888            .with_status(200)
889            .with_header("content-type", "application/json")
890            .with_body(
891                r#"{
892                    "servers": [{"id": "s2", "name": "b", "public_ips": [{"address": "2.2.2.2", "family": "inet"}], "tags": []}],
893                    "total_count": 2
894                }"#,
895            )
896            .create();
897
898        let agent = super::super::http_agent();
899        // Page 1
900        let r1: ListServersResponse = agent
901            .get(&format!(
902                "{}/instance/v1/zones/nl-ams-1/servers?page=1&per_page=100",
903                server.url()
904            ))
905            .header("X-Auth-Token", "tk")
906            .call()
907            .unwrap()
908            .body_mut()
909            .read_json()
910            .unwrap();
911        assert_eq!(r1.servers.len(), 1);
912        assert_eq!(r1.total_count, 2);
913        // Page 2
914        let r2: ListServersResponse = agent
915            .get(&format!(
916                "{}/instance/v1/zones/nl-ams-1/servers?page=2&per_page=100",
917                server.url()
918            ))
919            .header("X-Auth-Token", "tk")
920            .call()
921            .unwrap()
922            .body_mut()
923            .read_json()
924            .unwrap();
925        assert_eq!(r2.servers.len(), 1);
926        page1.assert();
927        page2.assert();
928    }
929
930    #[test]
931    fn test_http_list_servers_auth_failure() {
932        let mut server = mockito::Server::new();
933        let mock = server
934            .mock("GET", "/instance/v1/zones/fr-par-1/servers")
935            .match_query(mockito::Matcher::Any)
936            .with_status(401)
937            .with_body(r#"{"message": "Invalid authentication token"}"#)
938            .create();
939
940        let agent = super::super::http_agent();
941        let result = agent
942            .get(&format!(
943                "{}/instance/v1/zones/fr-par-1/servers?page=1&per_page=100",
944                server.url()
945            ))
946            .header("X-Auth-Token", "bad-token")
947            .call();
948
949        match result {
950            Err(ureq::Error::StatusCode(401)) => {} // expected
951            other => panic!("expected 401 error, got {:?}", other),
952        }
953        mock.assert();
954    }
955}