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