Skip to main content

purple_ssh/providers/
oracle.rs

1use std::collections::HashMap;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::time::SystemTime;
4
5use base64::Engine;
6use base64::engine::general_purpose::STANDARD;
7use rsa::pkcs1::DecodeRsaPrivateKey;
8use rsa::pkcs8::DecodePrivateKey;
9use rsa::signature::{SignatureEncoding, Signer};
10use serde::Deserialize;
11
12use super::{Provider, ProviderError, ProviderHost};
13
14/// Oracle Cloud Infrastructure provider configuration.
15pub struct Oracle {
16    pub regions: Vec<String>,
17    pub compartment: String,
18}
19
20/// Parsed OCI API credentials.
21#[derive(Debug)]
22struct OciCredentials {
23    tenancy: String,
24    user: String,
25    fingerprint: String,
26    key_pem: String,
27    region: String,
28}
29
30/// Parse an OCI config file and return credentials.
31///
32/// Only the `[DEFAULT]` profile is read (case-sensitive). The `key_pem`
33/// field comes from the already-read key file content passed as
34/// `key_content`.
35fn parse_oci_config(content: &str, key_content: &str) -> Result<OciCredentials, ProviderError> {
36    let mut in_default = false;
37    let mut tenancy: Option<String> = None;
38    let mut user: Option<String> = None;
39    let mut fingerprint: Option<String> = None;
40    let mut region: Option<String> = None;
41
42    for raw_line in content.lines() {
43        // Strip CRLF by stripping trailing \r after lines() removes \n
44        let line = raw_line.trim_end_matches('\r');
45        let trimmed = line.trim();
46
47        if trimmed.starts_with('[') && trimmed.ends_with(']') {
48            let profile = &trimmed[1..trimmed.len() - 1];
49            in_default = profile == "DEFAULT";
50            continue;
51        }
52
53        if !in_default {
54            continue;
55        }
56
57        if trimmed.starts_with('#') || trimmed.is_empty() {
58            continue;
59        }
60
61        if let Some(eq) = trimmed.find('=') {
62            let key = trimmed[..eq].trim();
63            let val = trimmed[eq + 1..].trim().to_string();
64            match key {
65                "tenancy" => tenancy = Some(val),
66                "user" => user = Some(val),
67                "fingerprint" => fingerprint = Some(val),
68                "region" => region = Some(val),
69                _ => {}
70            }
71        }
72    }
73
74    let tenancy = tenancy
75        .ok_or_else(|| ProviderError::Http("OCI config missing 'tenancy' in [DEFAULT]".into()))?;
76    let user =
77        user.ok_or_else(|| ProviderError::Http("OCI config missing 'user' in [DEFAULT]".into()))?;
78    let fingerprint = fingerprint.ok_or_else(|| {
79        ProviderError::Http("OCI config missing 'fingerprint' in [DEFAULT]".into())
80    })?;
81    let region = region.unwrap_or_default();
82
83    Ok(OciCredentials {
84        tenancy,
85        user,
86        fingerprint,
87        key_pem: key_content.to_string(),
88        region,
89    })
90}
91
92/// Extract the `key_file` path from the `[DEFAULT]` profile of an OCI
93/// config file.
94fn extract_key_file(config_content: &str) -> Result<String, ProviderError> {
95    let mut in_default = false;
96
97    for raw_line in config_content.lines() {
98        let line = raw_line.trim_end_matches('\r');
99        let trimmed = line.trim();
100
101        if trimmed.starts_with('[') && trimmed.ends_with(']') {
102            let profile = &trimmed[1..trimmed.len() - 1];
103            in_default = profile == "DEFAULT";
104            continue;
105        }
106
107        if !in_default || trimmed.starts_with('#') || trimmed.is_empty() {
108            continue;
109        }
110
111        if let Some(eq) = trimmed.find('=') {
112            let key = trimmed[..eq].trim();
113            if key == "key_file" {
114                return Ok(trimmed[eq + 1..].trim().to_string());
115            }
116        }
117    }
118
119    Err(ProviderError::Http(
120        "OCI config missing 'key_file' in [DEFAULT]".into(),
121    ))
122}
123
124/// Validate that an OCID string has a compartment or tenancy prefix.
125fn validate_compartment(ocid: &str) -> Result<(), ProviderError> {
126    if ocid.starts_with("ocid1.compartment.oc1..") || ocid.starts_with("ocid1.tenancy.oc1..") {
127        Ok(())
128    } else {
129        Err(ProviderError::Http(format!(
130            "Invalid compartment OCID: '{}'. Must start with 'ocid1.compartment.oc1..' or 'ocid1.tenancy.oc1..'",
131            ocid
132        )))
133    }
134}
135
136// ---------------------------------------------------------------------------
137// RFC 7231 date formatting
138// ---------------------------------------------------------------------------
139
140const WEEKDAYS: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
141const MONTHS: [&str; 12] = [
142    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
143];
144
145/// Format a Unix timestamp as an RFC 7231 date string.
146///
147/// Example: `Thu, 26 Mar 2026 12:00:00 GMT`
148fn format_rfc7231(epoch_secs: u64) -> String {
149    let secs_per_day = 86400u64;
150    let epoch_days = epoch_secs / secs_per_day;
151    let mut remaining_days = epoch_days;
152    let day_secs = epoch_secs % secs_per_day;
153    let hours = day_secs / 3600;
154    let minutes = (day_secs % 3600) / 60;
155    let seconds = day_secs % 60;
156
157    // Day of week: Jan 1 1970 was a Thursday (index 0 in WEEKDAYS)
158    let weekday = WEEKDAYS[(epoch_days % 7) as usize];
159
160    let mut year = 1970u64;
161    loop {
162        let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
163        let days_in_year = if leap { 366 } else { 365 };
164        if remaining_days < days_in_year {
165            break;
166        }
167        remaining_days -= days_in_year;
168        year += 1;
169    }
170
171    let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
172    let days_per_month: [u64; 12] = [
173        31,
174        if leap { 29 } else { 28 },
175        31,
176        30,
177        31,
178        30,
179        31,
180        31,
181        30,
182        31,
183        30,
184        31,
185    ];
186    let mut month = 0usize;
187    while month < 12 && remaining_days >= days_per_month[month] {
188        remaining_days -= days_per_month[month];
189        month += 1;
190    }
191    let day = remaining_days + 1;
192
193    format!(
194        "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
195        weekday, day, MONTHS[month], year, hours, minutes, seconds,
196    )
197}
198
199// ---------------------------------------------------------------------------
200// RSA private key parsing
201// ---------------------------------------------------------------------------
202
203/// Parse a PEM-encoded RSA private key (PKCS#1 or PKCS#8).
204fn parse_private_key(pem: &str) -> Result<rsa::RsaPrivateKey, ProviderError> {
205    if pem.contains("ENCRYPTED") {
206        return Err(ProviderError::Http(
207            "OCI private key is encrypted. Please provide an unencrypted key.".into(),
208        ));
209    }
210
211    // Try PKCS#1 first, then PKCS#8
212    if let Ok(key) = rsa::RsaPrivateKey::from_pkcs1_pem(pem) {
213        return Ok(key);
214    }
215
216    rsa::RsaPrivateKey::from_pkcs8_pem(pem)
217        .map_err(|e| ProviderError::Http(format!("Failed to parse OCI private key: {}", e)))
218}
219
220// ---------------------------------------------------------------------------
221// HTTP request signing
222// ---------------------------------------------------------------------------
223
224/// Build the OCI `Authorization` header value for a GET request.
225///
226/// Signs `date`, `(request-target)` and `host` headers using RSA-SHA256.
227/// The caller must parse the RSA private key once and pass it in to avoid
228/// re-parsing on every request.
229fn sign_request(
230    creds: &OciCredentials,
231    rsa_key: &rsa::RsaPrivateKey,
232    date: &str,
233    host: &str,
234    path_and_query: &str,
235) -> Result<String, ProviderError> {
236    let signing_string = format!(
237        "date: {}\n(request-target): get {}\nhost: {}",
238        date, path_and_query, host
239    );
240
241    let signing_key = rsa::pkcs1v15::SigningKey::<sha2::Sha256>::new(rsa_key.clone());
242    let signature = signing_key.sign(signing_string.as_bytes());
243    let sig_b64 = STANDARD.encode(signature.to_bytes());
244
245    let key_id = format!("{}/{}/{}", creds.tenancy, creds.user, creds.fingerprint);
246    Ok(format!(
247        "Signature version=\"1\",keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"date (request-target) host\",signature=\"{}\"",
248        key_id, sig_b64
249    ))
250}
251
252// ---------------------------------------------------------------------------
253// JSON response models
254// ---------------------------------------------------------------------------
255
256#[derive(Deserialize)]
257struct OciCompartment {
258    id: String,
259    #[serde(rename = "lifecycleState")]
260    lifecycle_state: String,
261}
262
263#[derive(Deserialize)]
264struct OciInstance {
265    id: String,
266    #[serde(rename = "displayName")]
267    display_name: String,
268    #[serde(rename = "lifecycleState")]
269    lifecycle_state: String,
270    shape: String,
271    #[serde(rename = "imageId")]
272    image_id: Option<String>,
273    #[serde(rename = "freeformTags")]
274    freeform_tags: Option<std::collections::HashMap<String, String>>,
275}
276
277#[derive(Deserialize)]
278struct OciVnicAttachment {
279    #[serde(rename = "instanceId")]
280    instance_id: String,
281    #[serde(rename = "vnicId")]
282    vnic_id: Option<String>,
283    #[serde(rename = "lifecycleState")]
284    lifecycle_state: String,
285    #[serde(rename = "isPrimary")]
286    is_primary: Option<bool>,
287}
288
289#[derive(Deserialize)]
290struct OciVnic {
291    #[serde(rename = "publicIp")]
292    public_ip: Option<String>,
293    #[serde(rename = "privateIp")]
294    private_ip: Option<String>,
295}
296
297#[derive(Deserialize)]
298struct OciImage {
299    #[serde(rename = "displayName")]
300    display_name: Option<String>,
301}
302
303// ureq 3.x does not expose the response body on StatusCode errors, so we
304// cannot parse OCI error JSON from failed responses. Other providers in this
305// codebase handle errors the same way (status code only). Kept for future use
306// if ureq adds body-on-error support.
307#[derive(Deserialize)]
308#[allow(dead_code)]
309struct OciErrorBody {
310    code: Option<String>,
311    message: Option<String>,
312}
313
314// ---------------------------------------------------------------------------
315// IP selection, VNIC mapping and helpers
316// ---------------------------------------------------------------------------
317
318fn select_ip(vnic: &OciVnic) -> String {
319    if let Some(ip) = &vnic.public_ip {
320        if !ip.is_empty() {
321            return ip.clone();
322        }
323    }
324    if let Some(ip) = &vnic.private_ip {
325        if !ip.is_empty() {
326            return ip.clone();
327        }
328    }
329    String::new()
330}
331
332fn select_vnic_for_instance(
333    attachments: &[OciVnicAttachment],
334    instance_id: &str,
335) -> Option<String> {
336    let matching: Vec<_> = attachments
337        .iter()
338        .filter(|a| a.instance_id == instance_id && a.lifecycle_state == "ATTACHED")
339        .collect();
340    if let Some(primary) = matching.iter().find(|a| a.is_primary == Some(true)) {
341        return primary.vnic_id.clone();
342    }
343    matching.first().and_then(|a| a.vnic_id.clone())
344}
345
346fn extract_tags(freeform_tags: &Option<std::collections::HashMap<String, String>>) -> Vec<String> {
347    match freeform_tags {
348        Some(tags) => {
349            let mut result: Vec<String> = tags
350                .iter()
351                .map(|(k, v)| {
352                    if v.is_empty() {
353                        k.clone()
354                    } else {
355                        format!("{}:{}", k, v)
356                    }
357                })
358                .collect();
359            result.sort();
360            result
361        }
362        None => Vec::new(),
363    }
364}
365
366// ---------------------------------------------------------------------------
367// Region constants
368// ---------------------------------------------------------------------------
369
370pub const OCI_REGIONS: &[(&str, &str)] = &[
371    // Americas (0..12)
372    ("us-ashburn-1", "Ashburn"),
373    ("us-phoenix-1", "Phoenix"),
374    ("us-sanjose-1", "San Jose"),
375    ("us-chicago-1", "Chicago"),
376    ("ca-toronto-1", "Toronto"),
377    ("ca-montreal-1", "Montreal"),
378    ("br-saopaulo-1", "Sao Paulo"),
379    ("br-vinhedo-1", "Vinhedo"),
380    ("mx-queretaro-1", "Queretaro"),
381    ("mx-monterrey-1", "Monterrey"),
382    ("cl-santiago-1", "Santiago"),
383    ("co-bogota-1", "Bogota"),
384    // EMEA (12..29)
385    ("eu-amsterdam-1", "Amsterdam"),
386    ("eu-frankfurt-1", "Frankfurt"),
387    ("eu-zurich-1", "Zurich"),
388    ("eu-stockholm-1", "Stockholm"),
389    ("eu-marseille-1", "Marseille"),
390    ("eu-milan-1", "Milan"),
391    ("eu-paris-1", "Paris"),
392    ("eu-madrid-1", "Madrid"),
393    ("eu-jovanovac-1", "Jovanovac"),
394    ("uk-london-1", "London"),
395    ("uk-cardiff-1", "Cardiff"),
396    ("me-jeddah-1", "Jeddah"),
397    ("me-abudhabi-1", "Abu Dhabi"),
398    ("me-dubai-1", "Dubai"),
399    ("me-riyadh-1", "Riyadh"),
400    ("af-johannesburg-1", "Johannesburg"),
401    ("il-jerusalem-1", "Jerusalem"),
402    // Asia Pacific (29..38)
403    ("ap-tokyo-1", "Tokyo"),
404    ("ap-osaka-1", "Osaka"),
405    ("ap-seoul-1", "Seoul"),
406    ("ap-chuncheon-1", "Chuncheon"),
407    ("ap-singapore-1", "Singapore"),
408    ("ap-sydney-1", "Sydney"),
409    ("ap-melbourne-1", "Melbourne"),
410    ("ap-mumbai-1", "Mumbai"),
411    ("ap-hyderabad-1", "Hyderabad"),
412];
413
414pub const OCI_REGION_GROUPS: &[(&str, usize, usize)] = &[
415    ("Americas", 0, 12),
416    ("EMEA", 12, 29),
417    ("Asia Pacific", 29, 38),
418];
419
420// ---------------------------------------------------------------------------
421// Provider trait implementation
422// ---------------------------------------------------------------------------
423
424impl Provider for Oracle {
425    fn name(&self) -> &str {
426        "oracle"
427    }
428
429    fn short_label(&self) -> &str {
430        "oci"
431    }
432
433    fn fetch_hosts_cancellable(
434        &self,
435        token: &str,
436        cancel: &AtomicBool,
437    ) -> Result<Vec<ProviderHost>, ProviderError> {
438        self.fetch_hosts_with_progress(token, cancel, &|_| {})
439    }
440
441    fn fetch_hosts_with_progress(
442        &self,
443        token: &str,
444        cancel: &AtomicBool,
445        progress: &dyn Fn(&str),
446    ) -> Result<Vec<ProviderHost>, ProviderError> {
447        if self.compartment.is_empty() {
448            return Err(ProviderError::Http(
449                "No compartment configured. Run: purple provider add oracle --token ~/.oci/config --compartment <OCID>".to_string(),
450            ));
451        }
452        validate_compartment(&self.compartment)?;
453
454        let config_content = std::fs::read_to_string(token).map_err(|e| {
455            ProviderError::Http(format!("Cannot read OCI config file '{}': {}", token, e))
456        })?;
457        let key_file = extract_key_file(&config_content)?;
458        let expanded = if key_file.starts_with("~/") {
459            if let Some(home) = dirs::home_dir() {
460                format!("{}{}", home.display(), &key_file[1..])
461            } else {
462                key_file.clone()
463            }
464        } else {
465            key_file.clone()
466        };
467        let key_content = std::fs::read_to_string(&expanded).map_err(|e| {
468            ProviderError::Http(format!("Cannot read OCI private key '{}': {}", expanded, e))
469        })?;
470        let creds = parse_oci_config(&config_content, &key_content)?;
471        let rsa_key = parse_private_key(&creds.key_pem)?;
472
473        let regions: Vec<String> = if self.regions.is_empty() {
474            if creds.region.is_empty() {
475                return Err(ProviderError::Http(
476                    "No regions configured and OCI config has no default region".to_string(),
477                ));
478            }
479            vec![creds.region.clone()]
480        } else {
481            self.regions.clone()
482        };
483
484        let mut all_hosts = Vec::new();
485        let mut region_failures = 0usize;
486        let total_regions = regions.len();
487        for region in &regions {
488            if cancel.load(std::sync::atomic::Ordering::Relaxed) {
489                return Err(ProviderError::Cancelled);
490            }
491            progress(&format!("Syncing {} ...", region));
492            match self.fetch_region(&creds, &rsa_key, region, cancel, progress) {
493                Ok(mut hosts) => all_hosts.append(&mut hosts),
494                Err(ProviderError::AuthFailed) => return Err(ProviderError::AuthFailed),
495                Err(ProviderError::RateLimited) => return Err(ProviderError::RateLimited),
496                Err(ProviderError::Cancelled) => return Err(ProviderError::Cancelled),
497                Err(ProviderError::PartialResult {
498                    hosts: mut partial, ..
499                }) => {
500                    all_hosts.append(&mut partial);
501                    region_failures += 1;
502                }
503                Err(_) => {
504                    region_failures += 1;
505                }
506            }
507        }
508        if region_failures > 0 {
509            if all_hosts.is_empty() {
510                return Err(ProviderError::Http(format!(
511                    "Failed to sync all {} region(s)",
512                    total_regions
513                )));
514            }
515            return Err(ProviderError::PartialResult {
516                hosts: all_hosts,
517                failures: region_failures,
518                total: total_regions,
519            });
520        }
521        Ok(all_hosts)
522    }
523}
524
525impl Oracle {
526    /// Perform a signed GET request against the OCI API.
527    fn signed_get(
528        &self,
529        creds: &OciCredentials,
530        rsa_key: &rsa::RsaPrivateKey,
531        agent: &ureq::Agent,
532        host: &str,
533        url: &str,
534    ) -> Result<ureq::http::Response<ureq::Body>, ProviderError> {
535        let now = SystemTime::now()
536            .duration_since(SystemTime::UNIX_EPOCH)
537            .unwrap_or_default()
538            .as_secs();
539        let date = format_rfc7231(now);
540
541        // Extract path+query from URL (everything after the host part)
542        let path_and_query = if let Some(pos) = url.find(host) {
543            &url[pos + host.len()..]
544        } else {
545            // Fallback: strip scheme + host
546            url.splitn(4, '/').nth(3).map_or("/", |p| {
547                // We need the leading slash
548                &url[url.len() - p.len() - 1..]
549            })
550        };
551
552        let auth = sign_request(creds, rsa_key, &date, host, path_and_query)?;
553
554        agent
555            .get(url)
556            .header("date", &date)
557            .header("Authorization", &auth)
558            .call()
559            .map_err(|e| match e {
560                ureq::Error::StatusCode(401 | 403) => ProviderError::AuthFailed,
561                ureq::Error::StatusCode(429) => ProviderError::RateLimited,
562                ureq::Error::StatusCode(code) => ProviderError::Http(format!("HTTP {}", code)),
563                other => super::map_ureq_error(other),
564            })
565    }
566
567    /// List active sub-compartments (Identity API supports compartmentIdInSubtree).
568    fn list_compartments(
569        &self,
570        creds: &OciCredentials,
571        rsa_key: &rsa::RsaPrivateKey,
572        agent: &ureq::Agent,
573        region: &str,
574        cancel: &AtomicBool,
575    ) -> Result<Vec<String>, ProviderError> {
576        let host = format!("identity.{}.oraclecloud.com", region);
577        let compartment_encoded = urlencoding_encode(&self.compartment);
578
579        let mut compartment_ids = vec![self.compartment.clone()];
580        let mut next_page: Option<String> = None;
581        for _ in 0..500 {
582            if cancel.load(Ordering::Relaxed) {
583                return Err(ProviderError::Cancelled);
584            }
585
586            let url = match &next_page {
587                Some(page) => format!(
588                    "https://{}/20160918/compartments?compartmentId={}&compartmentIdInSubtree=true&lifecycleState=ACTIVE&limit=100&page={}",
589                    host,
590                    compartment_encoded,
591                    urlencoding_encode(page)
592                ),
593                None => format!(
594                    "https://{}/20160918/compartments?compartmentId={}&compartmentIdInSubtree=true&lifecycleState=ACTIVE&limit=100",
595                    host, compartment_encoded
596                ),
597            };
598
599            let mut resp = self.signed_get(creds, rsa_key, agent, &host, &url)?;
600
601            let opc_next = resp
602                .headers()
603                .get("opc-next-page")
604                .and_then(|v| v.to_str().ok())
605                .filter(|s| !s.is_empty())
606                .map(String::from);
607
608            let items: Vec<OciCompartment> = resp
609                .body_mut()
610                .read_json()
611                .map_err(|e| ProviderError::Parse(e.to_string()))?;
612
613            compartment_ids.extend(
614                items
615                    .into_iter()
616                    .filter(|c| c.lifecycle_state == "ACTIVE")
617                    .map(|c| c.id),
618            );
619
620            match opc_next {
621                Some(p) => next_page = Some(p),
622                None => break,
623            }
624        }
625        Ok(compartment_ids)
626    }
627
628    fn fetch_region(
629        &self,
630        creds: &OciCredentials,
631        rsa_key: &rsa::RsaPrivateKey,
632        region: &str,
633        cancel: &AtomicBool,
634        progress: &dyn Fn(&str),
635    ) -> Result<Vec<ProviderHost>, ProviderError> {
636        let agent = super::http_agent();
637        let host = format!("iaas.{}.oraclecloud.com", region);
638
639        // Step 0: Discover all compartments (root + sub-compartments)
640        progress("Listing compartments...");
641        let compartment_ids = self.list_compartments(creds, rsa_key, &agent, region, cancel)?;
642        let total_compartments = compartment_ids.len();
643
644        // Step 1: List instances across all compartments (paginated per compartment)
645        let mut instances: Vec<OciInstance> = Vec::new();
646        for (ci, comp_id) in compartment_ids.iter().enumerate() {
647            if cancel.load(Ordering::Relaxed) {
648                return Err(ProviderError::Cancelled);
649            }
650            if total_compartments > 1 {
651                progress(&format!(
652                    "Listing instances ({}/{} compartments)...",
653                    ci + 1,
654                    total_compartments
655                ));
656            } else {
657                progress("Listing instances...");
658            }
659            let compartment_encoded = urlencoding_encode(comp_id);
660            let mut next_page: Option<String> = None;
661            for _ in 0..500 {
662                if cancel.load(Ordering::Relaxed) {
663                    return Err(ProviderError::Cancelled);
664                }
665
666                let url = match &next_page {
667                    Some(page) => format!(
668                        "https://{}/20160918/instances?compartmentId={}&limit=100&page={}",
669                        host,
670                        compartment_encoded,
671                        urlencoding_encode(page)
672                    ),
673                    None => format!(
674                        "https://{}/20160918/instances?compartmentId={}&limit=100",
675                        host, compartment_encoded
676                    ),
677                };
678
679                let mut resp = self.signed_get(creds, rsa_key, &agent, &host, &url)?;
680
681                let opc_next = resp
682                    .headers()
683                    .get("opc-next-page")
684                    .and_then(|v| v.to_str().ok())
685                    .filter(|s| !s.is_empty())
686                    .map(String::from);
687
688                let page_items: Vec<OciInstance> = resp
689                    .body_mut()
690                    .read_json()
691                    .map_err(|e| ProviderError::Parse(e.to_string()))?;
692
693                instances.extend(
694                    page_items
695                        .into_iter()
696                        .filter(|i| i.lifecycle_state != "TERMINATED"),
697                );
698
699                match opc_next {
700                    Some(p) => next_page = Some(p),
701                    None => break,
702                }
703            }
704        }
705
706        // Step 2: List VNIC attachments across all compartments (paginated per compartment)
707        progress("Listing VNIC attachments...");
708        let mut attachments: Vec<OciVnicAttachment> = Vec::new();
709        for comp_id in &compartment_ids {
710            if cancel.load(Ordering::Relaxed) {
711                return Err(ProviderError::Cancelled);
712            }
713            let compartment_encoded = urlencoding_encode(comp_id);
714            let mut next_page: Option<String> = None;
715            for _ in 0..500 {
716                if cancel.load(Ordering::Relaxed) {
717                    return Err(ProviderError::Cancelled);
718                }
719
720                let url = match &next_page {
721                    Some(page) => format!(
722                        "https://{}/20160918/vnicAttachments?compartmentId={}&limit=100&page={}",
723                        host,
724                        compartment_encoded,
725                        urlencoding_encode(page)
726                    ),
727                    None => format!(
728                        "https://{}/20160918/vnicAttachments?compartmentId={}&limit=100",
729                        host, compartment_encoded
730                    ),
731                };
732
733                let mut resp = self.signed_get(creds, rsa_key, &agent, &host, &url)?;
734
735                let opc_next = resp
736                    .headers()
737                    .get("opc-next-page")
738                    .and_then(|v| v.to_str().ok())
739                    .filter(|s| !s.is_empty())
740                    .map(String::from);
741
742                let page_items: Vec<OciVnicAttachment> = resp
743                    .body_mut()
744                    .read_json()
745                    .map_err(|e| ProviderError::Parse(e.to_string()))?;
746
747                attachments.extend(page_items);
748
749                match opc_next {
750                    Some(p) => next_page = Some(p),
751                    None => break,
752                }
753            }
754        }
755
756        // Step 3: Resolve images (N+1 per unique imageId)
757        let unique_image_ids: Vec<String> = {
758            let mut ids: Vec<String> = instances
759                .iter()
760                .filter_map(|i| i.image_id.clone())
761                .collect();
762            ids.sort_unstable();
763            ids.dedup();
764            ids
765        };
766        let total_images = unique_image_ids.len();
767        let mut image_names: HashMap<String, String> = HashMap::new();
768        for (n, image_id) in unique_image_ids.iter().enumerate() {
769            if cancel.load(Ordering::Relaxed) {
770                return Err(ProviderError::Cancelled);
771            }
772            progress(&format!("Resolving images ({}/{})...", n + 1, total_images));
773
774            let url = format!("https://{}/20160918/images/{}", host, image_id);
775            match self.signed_get(creds, rsa_key, &agent, &host, &url) {
776                Ok(mut resp) => {
777                    if let Ok(img) = resp.body_mut().read_json::<OciImage>() {
778                        if let Some(name) = img.display_name {
779                            image_names.insert(image_id.clone(), name);
780                        }
781                    }
782                }
783                Err(ProviderError::AuthFailed) => return Err(ProviderError::AuthFailed),
784                Err(ProviderError::RateLimited) => return Err(ProviderError::RateLimited),
785                Err(_) => {} // Non-fatal: skip silently
786            }
787        }
788
789        // Step 4: Get VNIC + build hosts (N+1 per VNIC for RUNNING instances)
790        let total_instances = instances.len();
791        let mut hosts: Vec<ProviderHost> = Vec::new();
792        let mut fetch_failures = 0usize;
793        for (n, instance) in instances.iter().enumerate() {
794            if cancel.load(Ordering::Relaxed) {
795                return Err(ProviderError::Cancelled);
796            }
797            progress(&format!("Fetching IPs ({}/{})...", n + 1, total_instances));
798
799            let ip = if instance.lifecycle_state == "RUNNING" {
800                match select_vnic_for_instance(&attachments, &instance.id) {
801                    Some(vnic_id) => {
802                        let url = format!("https://{}/20160918/vnics/{}", host, vnic_id);
803                        match self.signed_get(creds, rsa_key, &agent, &host, &url) {
804                            Ok(mut resp) => match resp.body_mut().read_json::<OciVnic>() {
805                                Ok(vnic) => {
806                                    let raw = select_ip(&vnic);
807                                    super::strip_cidr(&raw).to_string()
808                                }
809                                Err(_) => {
810                                    fetch_failures += 1;
811                                    String::new()
812                                }
813                            },
814                            Err(ProviderError::AuthFailed) => {
815                                return Err(ProviderError::AuthFailed);
816                            }
817                            Err(ProviderError::RateLimited) => {
818                                return Err(ProviderError::RateLimited);
819                            }
820                            Err(ProviderError::Http(ref msg)) if msg == "HTTP 404" => {
821                                // 404: race condition, silent skip
822                                String::new()
823                            }
824                            Err(_) => {
825                                fetch_failures += 1;
826                                String::new()
827                            }
828                        }
829                    }
830                    None => String::new(),
831                }
832            } else {
833                String::new()
834            };
835
836            let os_name = instance
837                .image_id
838                .as_ref()
839                .and_then(|id| image_names.get(id))
840                .cloned()
841                .unwrap_or_default();
842
843            let mut metadata = Vec::new();
844            metadata.push(("region".to_string(), region.to_string()));
845            metadata.push(("shape".to_string(), instance.shape.clone()));
846            if !os_name.is_empty() {
847                metadata.push(("os".to_string(), os_name));
848            }
849            metadata.push(("status".to_string(), instance.lifecycle_state.clone()));
850
851            hosts.push(ProviderHost {
852                server_id: instance.id.clone(),
853                name: instance.display_name.clone(),
854                ip,
855                tags: extract_tags(&instance.freeform_tags),
856                metadata,
857            });
858        }
859
860        if fetch_failures > 0 {
861            if hosts.is_empty() {
862                return Err(ProviderError::Http(format!(
863                    "Failed to fetch details for all {} instances",
864                    total_instances
865                )));
866            }
867            return Err(ProviderError::PartialResult {
868                hosts,
869                failures: fetch_failures,
870                total: total_instances,
871            });
872        }
873
874        Ok(hosts)
875    }
876}
877
878/// Minimal percent-encoding for query parameter values.
879/// Encodes characters that are not unreserved per RFC 3986.
880fn urlencoding_encode(input: &str) -> String {
881    let mut out = String::with_capacity(input.len());
882    for b in input.bytes() {
883        match b {
884            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
885                out.push(b as char);
886            }
887            _ => {
888                out.push('%');
889                out.push(char::from(HEX_DIGITS[(b >> 4) as usize]));
890                out.push(char::from(HEX_DIGITS[(b & 0x0F) as usize]));
891            }
892        }
893    }
894    out
895}
896
897const HEX_DIGITS: [u8; 16] = *b"0123456789ABCDEF";
898
899// ---------------------------------------------------------------------------
900// Tests
901// ---------------------------------------------------------------------------
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906
907    // -----------------------------------------------------------------------
908    // Config parsing and compartment validation
909    // -----------------------------------------------------------------------
910
911    fn minimal_config() -> &'static str {
912        "[DEFAULT]\ntenancy=ocid1.tenancy.oc1..aaa\nuser=ocid1.user.oc1..bbb\nfingerprint=aa:bb:cc\nregion=us-ashburn-1\nkey_file=~/.oci/key.pem\n"
913    }
914
915    #[test]
916    fn test_parse_oci_config_valid() {
917        let creds = parse_oci_config(minimal_config(), "PEM_CONTENT").unwrap();
918        assert_eq!(creds.tenancy, "ocid1.tenancy.oc1..aaa");
919        assert_eq!(creds.user, "ocid1.user.oc1..bbb");
920        assert_eq!(creds.fingerprint, "aa:bb:cc");
921        assert_eq!(creds.region, "us-ashburn-1");
922        assert_eq!(creds.key_pem, "PEM_CONTENT");
923    }
924
925    #[test]
926    fn test_parse_oci_config_missing_tenancy() {
927        let cfg = "[DEFAULT]\nuser=ocid1.user.oc1..bbb\nfingerprint=aa:bb:cc\n";
928        let err = parse_oci_config(cfg, "").unwrap_err();
929        assert!(err.to_string().contains("tenancy"));
930    }
931
932    #[test]
933    fn test_parse_oci_config_missing_user() {
934        let cfg = "[DEFAULT]\ntenancy=ocid1.tenancy.oc1..aaa\nfingerprint=aa:bb:cc\n";
935        let err = parse_oci_config(cfg, "").unwrap_err();
936        assert!(err.to_string().contains("user"));
937    }
938
939    #[test]
940    fn test_parse_oci_config_missing_fingerprint() {
941        let cfg = "[DEFAULT]\ntenancy=ocid1.tenancy.oc1..aaa\nuser=ocid1.user.oc1..bbb\n";
942        let err = parse_oci_config(cfg, "").unwrap_err();
943        assert!(err.to_string().contains("fingerprint"));
944    }
945
946    #[test]
947    fn test_parse_oci_config_no_default_profile() {
948        let cfg = "[OTHER]\ntenancy=ocid1.tenancy.oc1..aaa\nuser=u\nfingerprint=f\n";
949        let err = parse_oci_config(cfg, "").unwrap_err();
950        assert!(err.to_string().contains("tenancy"));
951    }
952
953    #[test]
954    fn test_parse_oci_config_multiple_profiles_reads_default() {
955        let cfg = "[OTHER]\ntenancy=wrong\n[DEFAULT]\ntenancy=right\nuser=u\nfingerprint=f\n";
956        let creds = parse_oci_config(cfg, "").unwrap();
957        assert_eq!(creds.tenancy, "right");
958    }
959
960    #[test]
961    fn test_parse_oci_config_whitespace_trimmed() {
962        let cfg = "[DEFAULT]\n tenancy = ocid1.tenancy.oc1..aaa \n user = u \n fingerprint = f \n";
963        let creds = parse_oci_config(cfg, "").unwrap();
964        assert_eq!(creds.tenancy, "ocid1.tenancy.oc1..aaa");
965        assert_eq!(creds.user, "u");
966        assert_eq!(creds.fingerprint, "f");
967    }
968
969    #[test]
970    fn test_parse_oci_config_crlf() {
971        let cfg = "[DEFAULT]\r\ntenancy=ocid1.tenancy.oc1..aaa\r\nuser=u\r\nfingerprint=f\r\n";
972        let creds = parse_oci_config(cfg, "").unwrap();
973        assert_eq!(creds.tenancy, "ocid1.tenancy.oc1..aaa");
974    }
975
976    #[test]
977    fn test_parse_oci_config_empty_file() {
978        let err = parse_oci_config("", "").unwrap_err();
979        assert!(err.to_string().contains("tenancy"));
980    }
981
982    #[test]
983    fn test_validate_compartment_valid() {
984        assert!(validate_compartment("ocid1.compartment.oc1..aaaaaaaa1234").is_ok());
985    }
986
987    #[test]
988    fn test_validate_compartment_tenancy_accepted() {
989        assert!(validate_compartment("ocid1.tenancy.oc1..aaaaaaaa1234").is_ok());
990    }
991
992    #[test]
993    fn test_validate_compartment_invalid() {
994        assert!(validate_compartment("ocid1.instance.oc1..xxx").is_err());
995        assert!(validate_compartment("not-an-ocid").is_err());
996        assert!(validate_compartment("").is_err());
997    }
998
999    // -----------------------------------------------------------------------
1000    // RFC 7231 date formatting
1001    // -----------------------------------------------------------------------
1002
1003    #[test]
1004    fn test_format_rfc7231_known_vector() {
1005        // 1774526400 = Thu, 26 Mar 2026 12:00:00 GMT
1006        assert_eq!(
1007            format_rfc7231(1_774_526_400),
1008            "Thu, 26 Mar 2026 12:00:00 GMT"
1009        );
1010    }
1011
1012    #[test]
1013    fn test_format_rfc7231_epoch_zero() {
1014        assert_eq!(format_rfc7231(0), "Thu, 01 Jan 1970 00:00:00 GMT");
1015    }
1016
1017    #[test]
1018    fn test_format_rfc7231_leap_year() {
1019        // 1582934400 = Sat, 29 Feb 2020 00:00:00 GMT
1020        assert_eq!(
1021            format_rfc7231(1_582_934_400),
1022            "Sat, 29 Feb 2020 00:00:00 GMT"
1023        );
1024    }
1025
1026    // -----------------------------------------------------------------------
1027    // RSA signing
1028    // -----------------------------------------------------------------------
1029
1030    fn load_test_key() -> String {
1031        std::fs::read_to_string(concat!(
1032            env!("CARGO_MANIFEST_DIR"),
1033            "/tests/fixtures/test_oci_key.pem"
1034        ))
1035        .expect("test key fixture missing")
1036    }
1037
1038    fn load_test_key_pkcs1() -> String {
1039        std::fs::read_to_string(concat!(
1040            env!("CARGO_MANIFEST_DIR"),
1041            "/tests/fixtures/test_oci_key_pkcs1.pem"
1042        ))
1043        .expect("test pkcs1 key fixture missing")
1044    }
1045
1046    fn make_creds(key_pem: String) -> OciCredentials {
1047        OciCredentials {
1048            tenancy: "ocid1.tenancy.oc1..aaa".into(),
1049            user: "ocid1.user.oc1..bbb".into(),
1050            fingerprint: "aa:bb:cc:dd".into(),
1051            key_pem,
1052            region: "us-ashburn-1".into(),
1053        }
1054    }
1055
1056    #[test]
1057    fn test_sign_request_authorization_header_format() {
1058        let creds = make_creds(load_test_key());
1059        let rsa_key = parse_private_key(&creds.key_pem).unwrap();
1060        let date = "Thu, 26 Mar 2026 12:00:00 GMT";
1061        let result = sign_request(
1062            &creds,
1063            &rsa_key,
1064            date,
1065            "iaas.us-ashburn-1.oraclecloud.com",
1066            "/20160918/instances",
1067        )
1068        .unwrap();
1069        assert!(result.starts_with("Signature version=\"1\",keyId="));
1070        assert!(result.contains("algorithm=\"rsa-sha256\""));
1071        // Exact match on the headers field
1072        assert!(result.contains("headers=\"date (request-target) host\""));
1073        assert!(result.contains("signature=\""));
1074        // Verify keyId format is exactly tenancy/user/fingerprint
1075        let expected_key_id = format!(
1076            "keyId=\"{}/{}/{}\"",
1077            creds.tenancy, creds.user, creds.fingerprint
1078        );
1079        assert!(
1080            result.contains(&expected_key_id),
1081            "keyId mismatch: expected {} in {}",
1082            expected_key_id,
1083            result
1084        );
1085    }
1086
1087    #[test]
1088    fn test_sign_request_deterministic() {
1089        let key = load_test_key();
1090        let creds1 = make_creds(key.clone());
1091        let creds2 = make_creds(key);
1092        let rsa_key = parse_private_key(&creds1.key_pem).unwrap();
1093        let date = "Thu, 26 Mar 2026 12:00:00 GMT";
1094        let host = "iaas.us-ashburn-1.oraclecloud.com";
1095        let path = "/20160918/instances";
1096        let r1 = sign_request(&creds1, &rsa_key, date, host, path).unwrap();
1097        let r2 = sign_request(&creds2, &rsa_key, date, host, path).unwrap();
1098        assert_eq!(r1, r2);
1099    }
1100
1101    #[test]
1102    fn test_sign_request_different_hosts_differ() {
1103        let key = load_test_key();
1104        let creds1 = make_creds(key.clone());
1105        let creds2 = make_creds(key);
1106        let rsa_key = parse_private_key(&creds1.key_pem).unwrap();
1107        let date = "Thu, 26 Mar 2026 12:00:00 GMT";
1108        let path = "/20160918/instances";
1109        let r1 = sign_request(
1110            &creds1,
1111            &rsa_key,
1112            date,
1113            "iaas.us-ashburn-1.oraclecloud.com",
1114            path,
1115        )
1116        .unwrap();
1117        let r2 = sign_request(
1118            &creds2,
1119            &rsa_key,
1120            date,
1121            "iaas.us-phoenix-1.oraclecloud.com",
1122            path,
1123        )
1124        .unwrap();
1125        assert_ne!(r1, r2);
1126    }
1127
1128    #[test]
1129    fn test_parse_private_key_pkcs1() {
1130        let pem = load_test_key_pkcs1();
1131        assert!(parse_private_key(&pem).is_ok());
1132    }
1133
1134    #[test]
1135    fn test_parse_private_key_pkcs8() {
1136        let pem = load_test_key();
1137        assert!(parse_private_key(&pem).is_ok());
1138    }
1139
1140    #[test]
1141    fn test_parse_private_key_encrypted_detected() {
1142        let fake_encrypted = "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: ...\ndata\n-----END RSA PRIVATE KEY-----";
1143        let err = parse_private_key(fake_encrypted).unwrap_err();
1144        assert!(err.to_string().to_lowercase().contains("encrypt"));
1145    }
1146
1147    #[test]
1148    fn test_parse_private_key_proc_type_encrypted() {
1149        // Different wording but also contains ENCRYPTED
1150        let pem = "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nABC\n-----END RSA PRIVATE KEY-----";
1151        let err = parse_private_key(pem).unwrap_err();
1152        assert!(err.to_string().to_lowercase().contains("encrypt"));
1153    }
1154
1155    #[test]
1156    fn test_parse_private_key_malformed() {
1157        let err = parse_private_key("not a pem key at all").unwrap_err();
1158        assert!(err.to_string().contains("Failed to parse OCI private key"));
1159    }
1160
1161    // -----------------------------------------------------------------------
1162    // extract_key_file
1163    // -----------------------------------------------------------------------
1164
1165    #[test]
1166    fn test_extract_key_file_present() {
1167        let cfg = "[DEFAULT]\ntenancy=t\nkey_file=~/.oci/key.pem\n";
1168        assert_eq!(extract_key_file(cfg).unwrap(), "~/.oci/key.pem");
1169    }
1170
1171    #[test]
1172    fn test_extract_key_file_missing() {
1173        let cfg = "[DEFAULT]\ntenancy=t\n";
1174        assert!(extract_key_file(cfg).is_err());
1175    }
1176
1177    // -----------------------------------------------------------------------
1178    // JSON deserialization
1179    // -----------------------------------------------------------------------
1180
1181    #[test]
1182    fn test_deserialize_list_instances() {
1183        let json = r#"[
1184            {
1185                "id": "ocid1.instance.oc1..aaa",
1186                "displayName": "my-server",
1187                "lifecycleState": "RUNNING",
1188                "shape": "VM.Standard2.1",
1189                "imageId": "ocid1.image.oc1..img",
1190                "freeformTags": {"env": "prod", "team": "ops"}
1191            }
1192        ]"#;
1193        let items: Vec<OciInstance> = serde_json::from_str(json).unwrap();
1194        assert_eq!(items.len(), 1);
1195        let inst = &items[0];
1196        assert_eq!(inst.id, "ocid1.instance.oc1..aaa");
1197        assert_eq!(inst.display_name, "my-server");
1198        assert_eq!(inst.shape, "VM.Standard2.1");
1199        let tags = inst.freeform_tags.as_ref().unwrap();
1200        assert_eq!(tags.get("env").map(String::as_str), Some("prod"));
1201        assert_eq!(tags.get("team").map(String::as_str), Some("ops"));
1202    }
1203
1204    #[test]
1205    fn test_deserialize_list_instances_empty() {
1206        let json = r#"[]"#;
1207        let items: Vec<OciInstance> = serde_json::from_str(json).unwrap();
1208        assert_eq!(items.len(), 0);
1209    }
1210
1211    #[test]
1212    fn test_deserialize_list_instances_null_image_id() {
1213        let json = r#"[
1214            {
1215                "id": "ocid1.instance.oc1..bbb",
1216                "displayName": "no-image",
1217                "lifecycleState": "STOPPED",
1218                "shape": "VM.Standard2.1"
1219            }
1220        ]"#;
1221        let items: Vec<OciInstance> = serde_json::from_str(json).unwrap();
1222        assert_eq!(items.len(), 1);
1223        assert!(items[0].image_id.is_none());
1224        assert!(items[0].freeform_tags.is_none());
1225    }
1226
1227    #[test]
1228    fn test_deserialize_vnic_attachment_is_primary() {
1229        let json = r#"[
1230            {
1231                "instanceId": "ocid1.instance.oc1..aaa",
1232                "vnicId": "ocid1.vnic.oc1..vvv",
1233                "lifecycleState": "ATTACHED",
1234                "isPrimary": true
1235            }
1236        ]"#;
1237        let items: Vec<OciVnicAttachment> = serde_json::from_str(json).unwrap();
1238        assert_eq!(items.len(), 1);
1239        let att = &items[0];
1240        assert_eq!(att.instance_id, "ocid1.instance.oc1..aaa");
1241        assert_eq!(att.vnic_id.as_deref(), Some("ocid1.vnic.oc1..vvv"));
1242        assert_eq!(att.lifecycle_state, "ATTACHED");
1243        assert_eq!(att.is_primary, Some(true));
1244    }
1245
1246    #[test]
1247    fn test_deserialize_vnic_public_and_private() {
1248        let json = r#"{"publicIp": "1.2.3.4", "privateIp": "10.0.0.5"}"#;
1249        let vnic: OciVnic = serde_json::from_str(json).unwrap();
1250        assert_eq!(vnic.public_ip.as_deref(), Some("1.2.3.4"));
1251        assert_eq!(vnic.private_ip.as_deref(), Some("10.0.0.5"));
1252    }
1253
1254    #[test]
1255    fn test_deserialize_vnic_private_only() {
1256        let json = r#"{"privateIp": "10.0.0.5"}"#;
1257        let vnic: OciVnic = serde_json::from_str(json).unwrap();
1258        assert!(vnic.public_ip.is_none());
1259        assert_eq!(vnic.private_ip.as_deref(), Some("10.0.0.5"));
1260    }
1261
1262    #[test]
1263    fn test_deserialize_image() {
1264        let json = r#"{"displayName": "Oracle-Linux-8.9"}"#;
1265        let img: OciImage = serde_json::from_str(json).unwrap();
1266        assert_eq!(img.display_name.as_deref(), Some("Oracle-Linux-8.9"));
1267    }
1268
1269    #[test]
1270    fn test_deserialize_error_body() {
1271        let json = r#"{"code": "NotAuthenticated", "message": "Missing or invalid credentials."}"#;
1272        let err: OciErrorBody = serde_json::from_str(json).unwrap();
1273        assert_eq!(err.code.as_deref(), Some("NotAuthenticated"));
1274        assert_eq!(
1275            err.message.as_deref(),
1276            Some("Missing or invalid credentials.")
1277        );
1278    }
1279
1280    #[test]
1281    fn test_deserialize_error_body_missing_fields() {
1282        let json = r#"{}"#;
1283        let err: OciErrorBody = serde_json::from_str(json).unwrap();
1284        assert!(err.code.is_none());
1285        assert!(err.message.is_none());
1286    }
1287
1288    // -----------------------------------------------------------------------
1289    // IP selection, VNIC mapping, tag extraction
1290    // -----------------------------------------------------------------------
1291
1292    #[test]
1293    fn test_select_ip_public_preferred() {
1294        let vnic = OciVnic {
1295            public_ip: Some("1.2.3.4".to_string()),
1296            private_ip: Some("10.0.0.1".to_string()),
1297        };
1298        assert_eq!(select_ip(&vnic), "1.2.3.4");
1299    }
1300
1301    #[test]
1302    fn test_select_ip_private_fallback() {
1303        let vnic = OciVnic {
1304            public_ip: None,
1305            private_ip: Some("10.0.0.1".to_string()),
1306        };
1307        assert_eq!(select_ip(&vnic), "10.0.0.1");
1308    }
1309
1310    #[test]
1311    fn test_select_ip_empty() {
1312        let vnic = OciVnic {
1313            public_ip: None,
1314            private_ip: None,
1315        };
1316        assert_eq!(select_ip(&vnic), "");
1317    }
1318
1319    #[test]
1320    fn test_select_primary_vnic() {
1321        let attachments = vec![
1322            OciVnicAttachment {
1323                instance_id: "inst-1".to_string(),
1324                vnic_id: Some("vnic-secondary".to_string()),
1325                lifecycle_state: "ATTACHED".to_string(),
1326                is_primary: Some(false),
1327            },
1328            OciVnicAttachment {
1329                instance_id: "inst-1".to_string(),
1330                vnic_id: Some("vnic-primary".to_string()),
1331                lifecycle_state: "ATTACHED".to_string(),
1332                is_primary: Some(true),
1333            },
1334        ];
1335        assert_eq!(
1336            select_vnic_for_instance(&attachments, "inst-1"),
1337            Some("vnic-primary".to_string())
1338        );
1339    }
1340
1341    #[test]
1342    fn test_select_vnic_no_primary_uses_first() {
1343        let attachments = vec![
1344            OciVnicAttachment {
1345                instance_id: "inst-1".to_string(),
1346                vnic_id: Some("vnic-first".to_string()),
1347                lifecycle_state: "ATTACHED".to_string(),
1348                is_primary: None,
1349            },
1350            OciVnicAttachment {
1351                instance_id: "inst-1".to_string(),
1352                vnic_id: Some("vnic-second".to_string()),
1353                lifecycle_state: "ATTACHED".to_string(),
1354                is_primary: None,
1355            },
1356        ];
1357        assert_eq!(
1358            select_vnic_for_instance(&attachments, "inst-1"),
1359            Some("vnic-first".to_string())
1360        );
1361    }
1362
1363    #[test]
1364    fn test_select_vnic_no_attachment() {
1365        let attachments: Vec<OciVnicAttachment> = vec![];
1366        assert_eq!(select_vnic_for_instance(&attachments, "inst-1"), None);
1367    }
1368
1369    #[test]
1370    fn test_select_vnic_filters_by_instance_id() {
1371        let attachments = vec![OciVnicAttachment {
1372            instance_id: "inst-other".to_string(),
1373            vnic_id: Some("vnic-other".to_string()),
1374            lifecycle_state: "ATTACHED".to_string(),
1375            is_primary: Some(true),
1376        }];
1377        assert_eq!(select_vnic_for_instance(&attachments, "inst-1"), None);
1378    }
1379
1380    #[test]
1381    fn test_extract_freeform_tags() {
1382        let mut map = std::collections::HashMap::new();
1383        map.insert("env".to_string(), "prod".to_string());
1384        map.insert("role".to_string(), "".to_string());
1385        map.insert("team".to_string(), "ops".to_string());
1386        let tags = extract_tags(&Some(map));
1387        // sorted
1388        assert_eq!(tags, vec!["env:prod", "role", "team:ops"]);
1389    }
1390
1391    #[test]
1392    fn test_extract_freeform_tags_empty() {
1393        let tags = extract_tags(&None);
1394        assert!(tags.is_empty());
1395    }
1396
1397    // -----------------------------------------------------------------------
1398    // Region constants and Provider trait
1399    // -----------------------------------------------------------------------
1400
1401    #[test]
1402    fn test_oci_regions_count() {
1403        assert_eq!(OCI_REGIONS.len(), 38);
1404    }
1405
1406    #[test]
1407    fn test_oci_regions_no_duplicates() {
1408        let mut ids: Vec<&str> = OCI_REGIONS.iter().map(|(id, _)| *id).collect();
1409        ids.sort_unstable();
1410        let before = ids.len();
1411        ids.dedup();
1412        assert_eq!(ids.len(), before, "duplicate region IDs found");
1413    }
1414
1415    #[test]
1416    fn test_oci_region_groups_cover_all() {
1417        use std::collections::HashSet;
1418        let group_indices: HashSet<usize> = OCI_REGION_GROUPS
1419            .iter()
1420            .flat_map(|(_, s, e)| *s..*e)
1421            .collect();
1422        let all_indices: HashSet<usize> = (0..OCI_REGIONS.len()).collect();
1423        assert_eq!(
1424            group_indices, all_indices,
1425            "region groups must cover all region indices exactly"
1426        );
1427        for (_, start, end) in OCI_REGION_GROUPS {
1428            assert!(*end <= OCI_REGIONS.len());
1429            assert!(start < end);
1430        }
1431    }
1432
1433    #[test]
1434    fn test_oracle_provider_name() {
1435        let oracle = Oracle {
1436            regions: Vec::new(),
1437            compartment: String::new(),
1438        };
1439        assert_eq!(oracle.name(), "oracle");
1440        assert_eq!(oracle.short_label(), "oci");
1441    }
1442
1443    #[test]
1444    fn test_oracle_empty_compartment_error() {
1445        let oracle = Oracle {
1446            regions: Vec::new(),
1447            compartment: String::new(),
1448        };
1449        let cancel = AtomicBool::new(false);
1450        let err = oracle
1451            .fetch_hosts_with_progress("some_token", &cancel, &|_| {})
1452            .unwrap_err();
1453        assert!(err.to_string().contains("compartment"));
1454    }
1455
1456    // -----------------------------------------------------------------------
1457    // Additional coverage
1458    // -----------------------------------------------------------------------
1459
1460    #[test]
1461    fn test_malformed_json_instance_list() {
1462        let result = serde_json::from_str::<Vec<OciInstance>>("not json");
1463        assert!(result.is_err());
1464    }
1465
1466    #[test]
1467    fn test_parse_private_key_empty_string() {
1468        let err = parse_private_key("").unwrap_err();
1469        assert!(
1470            err.to_string().contains("Failed to parse OCI private key"),
1471            "got: {}",
1472            err
1473        );
1474    }
1475
1476    #[test]
1477    fn test_parse_oci_config_missing_region_defaults_empty() {
1478        let cfg = "[DEFAULT]\ntenancy=ocid1.tenancy.oc1..aaa\nuser=u\nfingerprint=f\n";
1479        let creds = parse_oci_config(cfg, "").unwrap();
1480        assert_eq!(creds.region, "");
1481    }
1482
1483    #[test]
1484    fn test_sign_request_headers_exact() {
1485        let creds = make_creds(load_test_key());
1486        let rsa_key = parse_private_key(&creds.key_pem).unwrap();
1487        let date = "Thu, 26 Mar 2026 12:00:00 GMT";
1488        let result = sign_request(
1489            &creds,
1490            &rsa_key,
1491            date,
1492            "iaas.us-ashburn-1.oraclecloud.com",
1493            "/20160918/instances",
1494        )
1495        .unwrap();
1496        // Exact match on the headers= field value
1497        assert!(
1498            result.contains("headers=\"date (request-target) host\""),
1499            "headers field mismatch in: {}",
1500            result
1501        );
1502    }
1503
1504    #[test]
1505    fn test_sign_request_key_id_format() {
1506        let creds = make_creds(load_test_key());
1507        let rsa_key = parse_private_key(&creds.key_pem).unwrap();
1508        let date = "Thu, 26 Mar 2026 12:00:00 GMT";
1509        let result = sign_request(
1510            &creds,
1511            &rsa_key,
1512            date,
1513            "iaas.us-ashburn-1.oraclecloud.com",
1514            "/20160918/instances",
1515        )
1516        .unwrap();
1517        let expected = format!(
1518            "keyId=\"{}/{}/{}\"",
1519            creds.tenancy, creds.user, creds.fingerprint
1520        );
1521        assert!(
1522            result.contains(&expected),
1523            "expected keyId {} in: {}",
1524            expected,
1525            result
1526        );
1527    }
1528
1529    #[test]
1530    fn test_deserialize_multiple_instances() {
1531        let json = r#"[
1532            {
1533                "id": "ocid1.instance.oc1..aaa",
1534                "displayName": "server-1",
1535                "lifecycleState": "RUNNING",
1536                "shape": "VM.Standard2.1"
1537            },
1538            {
1539                "id": "ocid1.instance.oc1..bbb",
1540                "displayName": "server-2",
1541                "lifecycleState": "STOPPED",
1542                "shape": "VM.Standard.E4.Flex",
1543                "imageId": "ocid1.image.oc1..img2"
1544            }
1545        ]"#;
1546        let items: Vec<OciInstance> = serde_json::from_str(json).unwrap();
1547        assert_eq!(items.len(), 2);
1548        assert_eq!(items[0].id, "ocid1.instance.oc1..aaa");
1549        assert_eq!(items[0].display_name, "server-1");
1550        assert_eq!(items[1].id, "ocid1.instance.oc1..bbb");
1551        assert_eq!(items[1].display_name, "server-2");
1552        assert_eq!(items[1].image_id.as_deref(), Some("ocid1.image.oc1..img2"));
1553    }
1554
1555    #[test]
1556    fn test_extract_freeform_tags_special_chars() {
1557        let mut map = std::collections::HashMap::new();
1558        map.insert("path".to_string(), "/usr/local/bin".to_string());
1559        map.insert("env:tier".to_string(), "prod/us-east".to_string());
1560        let tags = extract_tags(&Some(map));
1561        assert!(tags.contains(&"env:tier:prod/us-east".to_string()));
1562        assert!(tags.contains(&"path:/usr/local/bin".to_string()));
1563    }
1564
1565    // -----------------------------------------------------------------------
1566    // HTTP roundtrip tests (mockito)
1567    // -----------------------------------------------------------------------
1568
1569    #[test]
1570    fn test_http_list_instances_roundtrip() {
1571        let mut server = mockito::Server::new();
1572        let mock = server
1573            .mock("GET", "/20160918/instances")
1574            .match_query(mockito::Matcher::AllOf(vec![
1575                mockito::Matcher::UrlEncoded(
1576                    "compartmentId".into(),
1577                    "ocid1.compartment.oc1..aaa".into(),
1578                ),
1579                mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
1580            ]))
1581            .match_header("Authorization", mockito::Matcher::Any)
1582            .with_status(200)
1583            .with_header("content-type", "application/json")
1584            .with_body(
1585                r#"[
1586                    {
1587                        "id": "ocid1.instance.oc1..inst1",
1588                        "displayName": "web-prod-1",
1589                        "lifecycleState": "RUNNING",
1590                        "shape": "VM.Standard2.1",
1591                        "imageId": "ocid1.image.oc1..img1",
1592                        "freeformTags": {"env": "prod", "team": "web"}
1593                    }
1594                ]"#,
1595            )
1596            .create();
1597
1598        let agent = super::super::http_agent();
1599        let url = format!(
1600            "{}/20160918/instances?compartmentId=ocid1.compartment.oc1..aaa&limit=100",
1601            server.url()
1602        );
1603        let items: Vec<OciInstance> = agent
1604            .get(&url)
1605            .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1606            .call()
1607            .unwrap()
1608            .body_mut()
1609            .read_json()
1610            .unwrap();
1611
1612        assert_eq!(items.len(), 1);
1613        assert_eq!(items[0].id, "ocid1.instance.oc1..inst1");
1614        assert_eq!(items[0].display_name, "web-prod-1");
1615        assert_eq!(items[0].lifecycle_state, "RUNNING");
1616        assert_eq!(items[0].shape, "VM.Standard2.1");
1617        assert_eq!(items[0].image_id.as_deref(), Some("ocid1.image.oc1..img1"));
1618        let tags = items[0].freeform_tags.as_ref().unwrap();
1619        assert_eq!(tags.get("env").unwrap(), "prod");
1620        assert_eq!(tags.get("team").unwrap(), "web");
1621        mock.assert();
1622    }
1623
1624    #[test]
1625    fn test_http_list_instances_pagination() {
1626        let mut server = mockito::Server::new();
1627        let page1 = server
1628            .mock("GET", "/20160918/instances")
1629            .match_query(mockito::Matcher::AllOf(vec![
1630                mockito::Matcher::UrlEncoded("compartmentId".into(), "ocid1.compartment.oc1..aaa".into()),
1631                mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
1632            ]))
1633            .with_status(200)
1634            .with_header("content-type", "application/json")
1635            .with_header("opc-next-page", "page2-token")
1636            .with_body(
1637                r#"[{"id": "ocid1.instance.oc1..a", "displayName": "srv-1", "lifecycleState": "RUNNING", "shape": "VM.Standard2.1"}]"#,
1638            )
1639            .create();
1640        let page2 = server
1641            .mock("GET", "/20160918/instances")
1642            .match_query(mockito::Matcher::AllOf(vec![
1643                mockito::Matcher::UrlEncoded("compartmentId".into(), "ocid1.compartment.oc1..aaa".into()),
1644                mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
1645                mockito::Matcher::UrlEncoded("page".into(), "page2-token".into()),
1646            ]))
1647            .with_status(200)
1648            .with_header("content-type", "application/json")
1649            .with_body(
1650                r#"[{"id": "ocid1.instance.oc1..b", "displayName": "srv-2", "lifecycleState": "STOPPED", "shape": "VM.Standard.E4.Flex"}]"#,
1651            )
1652            .create();
1653
1654        let agent = super::super::http_agent();
1655        // Page 1
1656        let resp1 = agent
1657            .get(&format!(
1658                "{}/20160918/instances?compartmentId=ocid1.compartment.oc1..aaa&limit=100",
1659                server.url()
1660            ))
1661            .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1662            .call()
1663            .unwrap();
1664        let next_page = resp1
1665            .headers()
1666            .get("opc-next-page")
1667            .and_then(|v| v.to_str().ok())
1668            .map(String::from);
1669        let items1: Vec<OciInstance> =
1670            serde_json::from_str(&resp1.into_body().read_to_string().unwrap()).unwrap();
1671        assert_eq!(items1.len(), 1);
1672        assert_eq!(items1[0].id, "ocid1.instance.oc1..a");
1673        assert_eq!(next_page.as_deref(), Some("page2-token"));
1674
1675        // Page 2
1676        let items2: Vec<OciInstance> = agent
1677            .get(&format!(
1678                "{}/20160918/instances?compartmentId=ocid1.compartment.oc1..aaa&limit=100&page=page2-token",
1679                server.url()
1680            ))
1681            .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1682            .call()
1683            .unwrap()
1684            .body_mut()
1685            .read_json()
1686            .unwrap();
1687        assert_eq!(items2.len(), 1);
1688        assert_eq!(items2[0].id, "ocid1.instance.oc1..b");
1689
1690        page1.assert();
1691        page2.assert();
1692    }
1693
1694    #[test]
1695    fn test_http_list_instances_auth_failure() {
1696        let mut server = mockito::Server::new();
1697        let mock = server
1698            .mock("GET", "/20160918/instances")
1699            .match_query(mockito::Matcher::Any)
1700            .with_status(401)
1701            .with_body(r#"{"code": "NotAuthenticated", "message": "The required information to complete authentication was not provided."}"#)
1702            .create();
1703
1704        let agent = super::super::http_agent();
1705        let result = agent
1706            .get(&format!(
1707                "{}/20160918/instances?compartmentId=ocid1.compartment.oc1..aaa&limit=100",
1708                server.url()
1709            ))
1710            .header("Authorization", "Signature version=\"1\",keyId=\"bad\"")
1711            .call();
1712
1713        match result {
1714            Err(ureq::Error::StatusCode(401)) => {} // expected
1715            other => panic!("expected 401 error, got {:?}", other),
1716        }
1717        mock.assert();
1718    }
1719
1720    #[test]
1721    fn test_http_vnic_attachments_roundtrip() {
1722        let mut server = mockito::Server::new();
1723        let mock = server
1724            .mock("GET", "/20160918/vnicAttachments")
1725            .match_query(mockito::Matcher::AllOf(vec![
1726                mockito::Matcher::UrlEncoded(
1727                    "compartmentId".into(),
1728                    "ocid1.compartment.oc1..aaa".into(),
1729                ),
1730                mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
1731            ]))
1732            .match_header("Authorization", mockito::Matcher::Any)
1733            .with_status(200)
1734            .with_header("content-type", "application/json")
1735            .with_body(
1736                r#"[
1737                    {
1738                        "instanceId": "ocid1.instance.oc1..inst1",
1739                        "vnicId": "ocid1.vnic.oc1..vnic1",
1740                        "lifecycleState": "ATTACHED",
1741                        "isPrimary": true
1742                    },
1743                    {
1744                        "instanceId": "ocid1.instance.oc1..inst1",
1745                        "vnicId": "ocid1.vnic.oc1..vnic2",
1746                        "lifecycleState": "ATTACHED",
1747                        "isPrimary": false
1748                    }
1749                ]"#,
1750            )
1751            .create();
1752
1753        let agent = super::super::http_agent();
1754        let url = format!(
1755            "{}/20160918/vnicAttachments?compartmentId=ocid1.compartment.oc1..aaa&limit=100",
1756            server.url()
1757        );
1758        let items: Vec<OciVnicAttachment> = agent
1759            .get(&url)
1760            .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1761            .call()
1762            .unwrap()
1763            .body_mut()
1764            .read_json()
1765            .unwrap();
1766
1767        assert_eq!(items.len(), 2);
1768        assert_eq!(items[0].instance_id, "ocid1.instance.oc1..inst1");
1769        assert_eq!(items[0].vnic_id.as_deref(), Some("ocid1.vnic.oc1..vnic1"));
1770        assert_eq!(items[0].lifecycle_state, "ATTACHED");
1771        assert_eq!(items[0].is_primary, Some(true));
1772        assert_eq!(items[1].is_primary, Some(false));
1773        mock.assert();
1774    }
1775
1776    #[test]
1777    fn test_http_vnic_attachments_auth_failure() {
1778        let mut server = mockito::Server::new();
1779        let mock = server
1780            .mock("GET", "/20160918/vnicAttachments")
1781            .match_query(mockito::Matcher::Any)
1782            .with_status(401)
1783            .with_body(r#"{"code": "NotAuthenticated", "message": "Invalid credentials"}"#)
1784            .create();
1785
1786        let agent = super::super::http_agent();
1787        let result = agent
1788            .get(&format!(
1789                "{}/20160918/vnicAttachments?compartmentId=ocid1.compartment.oc1..aaa&limit=100",
1790                server.url()
1791            ))
1792            .header("Authorization", "Signature version=\"1\",keyId=\"bad\"")
1793            .call();
1794
1795        match result {
1796            Err(ureq::Error::StatusCode(401)) => {} // expected
1797            other => panic!("expected 401 error, got {:?}", other),
1798        }
1799        mock.assert();
1800    }
1801
1802    #[test]
1803    fn test_http_get_vnic_roundtrip() {
1804        let mut server = mockito::Server::new();
1805        let mock = server
1806            .mock("GET", "/20160918/vnics/ocid1.vnic.oc1..vnic1")
1807            .match_header("Authorization", mockito::Matcher::Any)
1808            .with_status(200)
1809            .with_header("content-type", "application/json")
1810            .with_body(r#"{"publicIp": "129.146.10.1", "privateIp": "10.0.0.5"}"#)
1811            .create();
1812
1813        let agent = super::super::http_agent();
1814        let url = format!("{}/20160918/vnics/ocid1.vnic.oc1..vnic1", server.url());
1815        let vnic: OciVnic = agent
1816            .get(&url)
1817            .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1818            .call()
1819            .unwrap()
1820            .body_mut()
1821            .read_json()
1822            .unwrap();
1823
1824        assert_eq!(vnic.public_ip.as_deref(), Some("129.146.10.1"));
1825        assert_eq!(vnic.private_ip.as_deref(), Some("10.0.0.5"));
1826        mock.assert();
1827    }
1828
1829    #[test]
1830    fn test_http_get_vnic_auth_failure() {
1831        let mut server = mockito::Server::new();
1832        let mock = server
1833            .mock("GET", "/20160918/vnics/ocid1.vnic.oc1..vnic1")
1834            .match_query(mockito::Matcher::Any)
1835            .with_status(401)
1836            .with_body(r#"{"code": "NotAuthenticated", "message": "Invalid credentials"}"#)
1837            .create();
1838
1839        let agent = super::super::http_agent();
1840        let result = agent
1841            .get(&format!(
1842                "{}/20160918/vnics/ocid1.vnic.oc1..vnic1",
1843                server.url()
1844            ))
1845            .header("Authorization", "Signature version=\"1\",keyId=\"bad\"")
1846            .call();
1847
1848        match result {
1849            Err(ureq::Error::StatusCode(401)) => {} // expected
1850            other => panic!("expected 401 error, got {:?}", other),
1851        }
1852        mock.assert();
1853    }
1854
1855    #[test]
1856    fn test_http_get_image_roundtrip() {
1857        let mut server = mockito::Server::new();
1858        let mock = server
1859            .mock("GET", "/20160918/images/ocid1.image.oc1..img1")
1860            .match_header("Authorization", mockito::Matcher::Any)
1861            .with_status(200)
1862            .with_header("content-type", "application/json")
1863            .with_body(r#"{"displayName": "Oracle-Linux-8.8-2024.01.26-0"}"#)
1864            .create();
1865
1866        let agent = super::super::http_agent();
1867        let url = format!("{}/20160918/images/ocid1.image.oc1..img1", server.url());
1868        let image: OciImage = agent
1869            .get(&url)
1870            .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1871            .call()
1872            .unwrap()
1873            .body_mut()
1874            .read_json()
1875            .unwrap();
1876
1877        assert_eq!(
1878            image.display_name.as_deref(),
1879            Some("Oracle-Linux-8.8-2024.01.26-0")
1880        );
1881        mock.assert();
1882    }
1883
1884    #[test]
1885    fn test_http_get_image_auth_failure() {
1886        let mut server = mockito::Server::new();
1887        let mock = server
1888            .mock("GET", "/20160918/images/ocid1.image.oc1..img1")
1889            .match_query(mockito::Matcher::Any)
1890            .with_status(401)
1891            .with_body(r#"{"code": "NotAuthenticated", "message": "Invalid credentials"}"#)
1892            .create();
1893
1894        let agent = super::super::http_agent();
1895        let result = agent
1896            .get(&format!(
1897                "{}/20160918/images/ocid1.image.oc1..img1",
1898                server.url()
1899            ))
1900            .header("Authorization", "Signature version=\"1\",keyId=\"bad\"")
1901            .call();
1902
1903        match result {
1904            Err(ureq::Error::StatusCode(401)) => {} // expected
1905            other => panic!("expected 401 error, got {:?}", other),
1906        }
1907        mock.assert();
1908    }
1909
1910    // ── ListCompartments HTTP tests ─────────────────────────────────
1911
1912    #[test]
1913    fn test_deserialize_compartment() {
1914        let json = r#"[
1915            {"id": "ocid1.compartment.oc1..child1", "lifecycleState": "ACTIVE"},
1916            {"id": "ocid1.compartment.oc1..child2", "lifecycleState": "DELETED"}
1917        ]"#;
1918        let items: Vec<OciCompartment> = serde_json::from_str(json).unwrap();
1919        assert_eq!(items.len(), 2);
1920        assert_eq!(items[0].id, "ocid1.compartment.oc1..child1");
1921        assert_eq!(items[0].lifecycle_state, "ACTIVE");
1922        assert_eq!(items[1].lifecycle_state, "DELETED");
1923    }
1924
1925    #[test]
1926    fn test_deserialize_compartment_empty() {
1927        let json = r#"[]"#;
1928        let items: Vec<OciCompartment> = serde_json::from_str(json).unwrap();
1929        assert_eq!(items.len(), 0);
1930    }
1931
1932    #[test]
1933    fn test_http_list_compartments_roundtrip() {
1934        let mut server = mockito::Server::new();
1935        let mock = server
1936            .mock("GET", "/20160918/compartments")
1937            .match_query(mockito::Matcher::AllOf(vec![
1938                mockito::Matcher::UrlEncoded(
1939                    "compartmentId".into(),
1940                    "ocid1.tenancy.oc1..root".into(),
1941                ),
1942                mockito::Matcher::UrlEncoded("compartmentIdInSubtree".into(), "true".into()),
1943                mockito::Matcher::UrlEncoded("lifecycleState".into(), "ACTIVE".into()),
1944                mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
1945            ]))
1946            .with_status(200)
1947            .with_header("content-type", "application/json")
1948            .with_body(
1949                r#"[
1950                    {"id": "ocid1.compartment.oc1..prod", "lifecycleState": "ACTIVE"},
1951                    {"id": "ocid1.compartment.oc1..staging", "lifecycleState": "ACTIVE"},
1952                    {"id": "ocid1.compartment.oc1..old", "lifecycleState": "DELETED"}
1953                ]"#,
1954            )
1955            .create();
1956
1957        let agent = super::super::http_agent();
1958        let url = format!(
1959            "{}/20160918/compartments?compartmentId={}&compartmentIdInSubtree=true&lifecycleState=ACTIVE&limit=100",
1960            server.url(),
1961            "ocid1.tenancy.oc1..root"
1962        );
1963        let items: Vec<OciCompartment> = agent
1964            .get(&url)
1965            .header("date", "Thu, 27 Mar 2026 12:00:00 GMT")
1966            .header("Authorization", "Signature version=\"1\",keyId=\"test\"")
1967            .call()
1968            .unwrap()
1969            .body_mut()
1970            .read_json()
1971            .unwrap();
1972
1973        // Only ACTIVE compartments should be kept by caller
1974        let active: Vec<_> = items
1975            .iter()
1976            .filter(|c| c.lifecycle_state == "ACTIVE")
1977            .collect();
1978        assert_eq!(active.len(), 2);
1979        assert_eq!(active[0].id, "ocid1.compartment.oc1..prod");
1980        assert_eq!(active[1].id, "ocid1.compartment.oc1..staging");
1981        mock.assert();
1982    }
1983
1984    #[test]
1985    fn test_http_list_compartments_auth_failure() {
1986        let mut server = mockito::Server::new();
1987        let mock = server
1988            .mock("GET", "/20160918/compartments")
1989            .match_query(mockito::Matcher::Any)
1990            .with_status(401)
1991            .with_body(r#"{"code": "NotAuthenticated", "message": "Not authenticated"}"#)
1992            .create();
1993
1994        let agent = super::super::http_agent();
1995        let result = agent
1996            .get(&format!(
1997                "{}/20160918/compartments?compartmentId=x&compartmentIdInSubtree=true&lifecycleState=ACTIVE&limit=100",
1998                server.url()
1999            ))
2000            .header("date", "Thu, 27 Mar 2026 12:00:00 GMT")
2001            .header("Authorization", "Signature version=\"1\",keyId=\"bad\"")
2002            .call();
2003
2004        match result {
2005            Err(ureq::Error::StatusCode(401)) => {} // expected
2006            other => panic!("expected 401 error, got {:?}", other),
2007        }
2008        mock.assert();
2009    }
2010}