Skip to main content

kibana_sync/client/
kibana.rs

1//! Kibana client module
2//!
3//! Provides `KibanaClient` for making API requests to Kibana.
4//! The client can be scoped to a specific space via `.space(id)`.
5
6use super::Auth;
7use crate::{Error, Result};
8use base64::Engine;
9use reqwest::{Client, Method, multipart};
10use semver::Version;
11use serde_json::Value;
12use std::collections::HashMap;
13use std::sync::Arc;
14use tokio::sync::{RwLock, Semaphore};
15use tracing::{debug, trace};
16use url::Url;
17
18/// Semantic version of a Kibana server.
19pub type KibanaVersion = Version;
20
21/// Parse Kibana version strings using semver with small compatibility fixes:
22/// - optional leading `v` prefix
23/// - missing patch in `major.minor` strings (normalized to `.0`)
24pub fn parse_kibana_version(version: &str) -> Result<KibanaVersion> {
25    let trimmed = version.trim().trim_start_matches('v');
26
27    if let Ok(parsed) = KibanaVersion::parse(trimmed) {
28        return Ok(parsed);
29    }
30
31    let dot_count = trimmed.matches('.').count();
32    let normalized = match dot_count {
33        0 => format!("{trimmed}.0.0"),
34        1 => format!("{trimmed}.0"),
35        _ => trimmed.to_string(),
36    };
37
38    KibanaVersion::parse(&normalized).map_err(Error::from)
39}
40
41/// Resolved Kibana version details from `/api/status`.
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct KibanaVersionInfo {
44    pub raw: String,
45    pub parsed: KibanaVersion,
46}
47
48/// Caller-provided registry of known Kibana spaces.
49pub type SpaceRegistry = HashMap<String, String>;
50
51/// API families that are gated by minimum Kibana versions.
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub enum ApiCapability {
54    Spaces,
55    SavedObjects,
56    Agents,
57    Tools,
58    Skills,
59    Workflows,
60}
61
62impl ApiCapability {
63    pub fn name(self) -> &'static str {
64        match self {
65            Self::Spaces => "spaces",
66            Self::SavedObjects => "saved_objects",
67            Self::Agents => "agents",
68            Self::Tools => "tools",
69            Self::Skills => "skills",
70            Self::Workflows => "workflows",
71        }
72    }
73
74    pub fn minimum_version(self) -> KibanaVersion {
75        match self {
76            Self::Spaces | Self::SavedObjects => KibanaVersion::new(8, 0, 0),
77            Self::Agents | Self::Tools => KibanaVersion::new(9, 2, 0),
78            Self::Skills => KibanaVersion::new(9, 4, 0),
79            Self::Workflows => KibanaVersion::new(9, 3, 0),
80        }
81    }
82
83    pub fn maturity_note(self) -> Option<&'static str> {
84        match self {
85            Self::Agents | Self::Tools => Some("Tech preview in 9.2, GA in 9.3"),
86            Self::Skills => Some("Experimental as of 9.4"),
87            Self::Workflows => Some("Tech preview in 9.3"),
88            _ => None,
89        }
90    }
91}
92
93/// Kibana client for making API requests.
94///
95/// The client can operate in two modes:
96/// - **Root mode** (`space: None`): For global operations like managing spaces
97/// - **Space mode** (`space: Some(id)`): For space-scoped operations
98///
99/// Use `.space(id)` to create a space-scoped client from a root client.
100///
101/// # Example
102/// ```no_run
103/// use kibana_sync::{Auth, KibanaClient};
104/// use url::Url;
105///
106/// # async fn example() -> kibana_sync::Result<()> {
107/// let url = Url::parse("http://localhost:5601")?;
108/// let client = KibanaClient::builder(url)
109///     .auth(Auth::None)
110///     .spaces([("marketing".to_string(), "Marketing".to_string())])
111///     .build()?;
112///
113/// // Root client for global operations (e.g., managing spaces)
114/// let response = client.get("/api/spaces/space").await?;
115///
116/// // Space-scoped client for space-specific operations
117/// let marketing = client.space("marketing")?;
118/// let objects = marketing.get("/api/saved_objects/_find").await?;
119///
120/// // Get the current space ID
121/// assert_eq!(marketing.space_id(), "marketing");
122/// # Ok(())
123/// # }
124/// ```
125#[derive(Clone, Debug)]
126pub struct KibanaClient {
127    client: Client,
128    url: Url,
129    spaces: HashMap<String, String>, // id -> name (for validation)
130    space: Option<String>,           // Current space context (None = root/default)
131    semaphore: Arc<Semaphore>,       // Global concurrency limit
132    version_info: Arc<RwLock<Option<KibanaVersionInfo>>>,
133}
134
135/// Builder for [`KibanaClient`].
136#[derive(Clone, Debug)]
137pub struct KibanaClientBuilder {
138    url: Url,
139    auth: Auth,
140    max_concurrency: usize,
141    spaces: SpaceRegistry,
142}
143
144impl KibanaClientBuilder {
145    fn new(url: Url) -> Self {
146        Self {
147            url,
148            auth: Auth::None,
149            max_concurrency: 8,
150            spaces: default_spaces(),
151        }
152    }
153
154    /// Set authentication for requests.
155    pub fn auth(mut self, auth: Auth) -> Self {
156        self.auth = auth;
157        self
158    }
159
160    /// Set the maximum number of concurrent requests shared by all cloned clients.
161    pub fn max_concurrency(mut self, max_concurrency: usize) -> Self {
162        self.max_concurrency = max_concurrency;
163        self
164    }
165
166    /// Replace the space registry with caller-provided spaces.
167    pub fn spaces(mut self, spaces: impl IntoIterator<Item = (String, String)>) -> Self {
168        self.spaces = spaces.into_iter().collect();
169        if self.spaces.is_empty() {
170            self.spaces = default_spaces();
171        }
172        self
173    }
174
175    /// Build the root Kibana client.
176    pub fn build(self) -> Result<KibanaClient> {
177        if self.max_concurrency == 0 {
178            return Err(Error::InvalidConfiguration(
179                "max_concurrency must be greater than zero".to_string(),
180            ));
181        }
182
183        let mut headers = reqwest::header::HeaderMap::new();
184        headers.insert("kbn-xsrf", "true".parse()?);
185        match self.auth {
186            Auth::Basic(username, password) => {
187                let credentials = base64::engine::general_purpose::STANDARD
188                    .encode(format!("{}:{}", username, password));
189                headers.append(
190                    reqwest::header::AUTHORIZATION,
191                    format!("Basic {}", credentials).parse()?,
192                );
193            }
194            Auth::Apikey(apikey) => {
195                headers.append(
196                    reqwest::header::AUTHORIZATION,
197                    format!("ApiKey {}", apikey).parse()?,
198                );
199            }
200            Auth::None => {
201                // No authentication header.
202            }
203        }
204        let client = Client::builder().default_headers(headers).build()?;
205        let semaphore = Arc::new(Semaphore::new(self.max_concurrency));
206
207        Ok(KibanaClient {
208            client,
209            url: self.url,
210            spaces: self.spaces,
211            space: None, // Root mode
212            semaphore,
213            version_info: Arc::new(RwLock::new(None)),
214        })
215    }
216}
217
218fn default_spaces() -> SpaceRegistry {
219    let mut spaces = HashMap::new();
220    spaces.insert("default".to_string(), "Default".to_string());
221    spaces
222}
223
224impl KibanaClient {
225    /// Start configuring a root Kibana client from explicit values.
226    pub fn builder(url: Url) -> KibanaClientBuilder {
227        KibanaClientBuilder::new(url)
228    }
229
230    /// Create a root client with default options and only the default space.
231    pub fn new(url: Url, auth: Auth) -> Result<Self> {
232        Self::builder(url).auth(auth).build()
233    }
234
235    /// Create a space-scoped client for the given space ID.
236    ///
237    /// Returns a new client that will automatically scope all API requests
238    /// to the specified space (prefixing paths with `/s/{space}/`).
239    ///
240    /// # Arguments
241    /// * `id` - Space ID to scope to
242    ///
243    /// # Returns
244    /// A new KibanaClient scoped to the specified space
245    ///
246    /// # Errors
247    /// Returns an error if the space ID is not in the loaded manifest
248    ///
249    /// # Example
250    /// ```no_run
251    /// # use kibana_sync::{Auth, KibanaClient};
252    /// # use url::Url;
253    /// # fn example() -> kibana_sync::Result<()> {
254    /// # let url = Url::parse("http://localhost:5601")?;
255    /// # let client = KibanaClient::builder(url)
256    /// #     .spaces([("marketing".to_string(), "Marketing".to_string())])
257    /// #     .build()?;
258    /// let marketing = client.space("marketing")?;
259    /// assert_eq!(marketing.space_id(), "marketing");
260    /// # Ok(())
261    /// # }
262    /// ```
263    pub fn space(&self, id: &str) -> Result<KibanaClient> {
264        if !self.spaces.contains_key(id) {
265            let mut available = self.spaces.keys().cloned().collect::<Vec<_>>();
266            available.sort();
267            return Err(Error::InvalidSpace {
268                id: id.to_string(),
269                available,
270            });
271        }
272
273        let space = if id == "default" {
274            None
275        } else {
276            Some(id.to_string())
277        };
278
279        Ok(KibanaClient {
280            client: self.client.clone(),
281            url: self.url.clone(),
282            spaces: self.spaces.clone(),
283            space,
284            semaphore: self.semaphore.clone(),
285            version_info: self.version_info.clone(),
286        })
287    }
288
289    /// Resolve and cache Kibana server version info from `/api/status`.
290    pub async fn server_version_info(&self) -> Result<KibanaVersionInfo> {
291        if let Some(cached) = self.version_info.read().await.clone() {
292            return Ok(cached);
293        }
294
295        let response = self
296            .request_raw(Method::GET, &HashMap::new(), "/api/status", None)
297            .await?;
298
299        if !response.status().is_success() {
300            let status = response.status();
301            let body = response.text().await.unwrap_or_default();
302            return Err(Error::api_response(status, body));
303        }
304
305        let status: Value = response.json().await?;
306        let raw = status
307            .get("version")
308            .and_then(|v| v.get("number"))
309            .and_then(|v| v.as_str())
310            .ok_or(Error::RedactedStatusUnauthenticated)?
311            .to_string();
312        let parsed = parse_kibana_version(&raw)?;
313        let info = KibanaVersionInfo { raw, parsed };
314
315        *self.version_info.write().await = Some(info.clone());
316        Ok(info)
317    }
318
319    /// Resolve the normalized Kibana server version.
320    pub async fn server_version(&self) -> Result<KibanaVersion> {
321        Ok(self.server_version_info().await?.parsed)
322    }
323
324    /// Check if a capability is supported on a specific Kibana version.
325    pub fn supports_capability(version: &KibanaVersion, capability: ApiCapability) -> bool {
326        version >= &capability.minimum_version()
327    }
328
329    /// Build a user-facing unsupported message for a capability/version pair.
330    pub fn unsupported_capability_reason(
331        version: &KibanaVersion,
332        capability: ApiCapability,
333    ) -> String {
334        let minimum = capability.minimum_version();
335        match capability.maturity_note() {
336            Some(note) => format!(
337                "API '{}' requires Kibana {}+ (detected {}, {})",
338                capability.name(),
339                minimum,
340                version,
341                note
342            ),
343            None => format!(
344                "API '{}' requires Kibana {}+ (detected {})",
345                capability.name(),
346                minimum,
347                version
348            ),
349        }
350    }
351
352    /// Get the current space ID.
353    ///
354    /// Returns "default" for root mode or the default space.
355    pub fn space_id(&self) -> &str {
356        self.space.as_deref().unwrap_or("default")
357    }
358
359    /// Check if this client is in root mode (no space scoping).
360    pub fn is_root(&self) -> bool {
361        self.space.is_none()
362    }
363
364    /// Get all available space IDs from the manifest.
365    pub fn space_ids(&self) -> Vec<&str> {
366        self.spaces.keys().map(|s| s.as_str()).collect()
367    }
368
369    /// Get the name of a space by ID.
370    ///
371    /// # Returns
372    /// The space name if found, None otherwise
373    pub fn space_name(&self, id: &str) -> Option<&str> {
374        self.spaces.get(id).map(|s| s.as_str())
375    }
376
377    /// Check if a space exists in the manifest.
378    pub fn has_space(&self, id: &str) -> bool {
379        self.spaces.contains_key(id)
380    }
381
382    /// Get the base URL.
383    pub fn url(&self) -> &Url {
384        &self.url
385    }
386
387    /// Verify the connection and authentication to Kibana.
388    ///
389    /// Makes a GET request to /api/status to verify connectivity.
390    pub async fn test_connection(&self) -> Result<reqwest::Response> {
391        // Always use root path for status check
392        self.request_raw(Method::GET, &HashMap::new(), "/api/status", None)
393            .await
394    }
395
396    /// Send a request to a given path.
397    ///
398    /// If this client is scoped to a space, the path will be prefixed with `/s/{space}/`.
399    /// For root mode, the path is used as-is.
400    ///
401    /// # Arguments
402    /// * `method` - HTTP method
403    /// * `headers` - Additional headers
404    /// * `path` - API path
405    /// * `body` - Optional request body
406    pub async fn request(
407        &self,
408        method: Method,
409        headers: &HashMap<String, String>,
410        path: &str,
411        body: Option<&[u8]>,
412    ) -> Result<reqwest::Response> {
413        // Strip leading slash from path if present, to avoid double slashes
414        let path_stripped = path.strip_prefix('/').unwrap_or(path);
415
416        // Build final path with space prefix if scoped to a space
417        let final_path = match &self.space {
418            Some(space) => format!("/s/{}/{}", space, path_stripped),
419            None => format!("/{}", path_stripped),
420        };
421
422        debug!(method = %method, path = %final_path, "sending Kibana request");
423
424        self.request_raw(method, headers, &final_path, body).await
425    }
426
427    #[cfg(test)]
428    pub(crate) fn prefixed_path_for_test(&self, path: &str) -> String {
429        let path_stripped = path.strip_prefix('/').unwrap_or(path);
430        match &self.space {
431            Some(space) => format!("/s/{}/{}", space, path_stripped),
432            None => format!("/{}", path_stripped),
433        }
434    }
435
436    /// Send a request without space path prefixing (internal use).
437    async fn request_raw(
438        &self,
439        method: Method,
440        headers: &HashMap<String, String>,
441        path: &str,
442        body: Option<&[u8]>,
443    ) -> Result<reqwest::Response> {
444        let _permit = self
445            .semaphore
446            .acquire()
447            .await
448            .map_err(|_| Error::SemaphoreClosed)?;
449
450        let mut header_map = reqwest::header::HeaderMap::new();
451        for (key, value) in headers {
452            header_map.insert(
453                key.parse::<reqwest::header::HeaderName>()?,
454                value.parse::<reqwest::header::HeaderValue>()?,
455            );
456        }
457        let use_form_data = match headers.get("Content-Type") {
458            Some(content_type) => {
459                trace!("Content-Type: {}", content_type);
460                content_type.starts_with("multipart/form-data")
461            }
462            None => false,
463        };
464
465        if use_form_data {
466            header_map.remove("Content-Type");
467        }
468
469        let client = match path.split_once('?') {
470            Some((p, query)) => {
471                let query: Vec<_> = query.split('&').filter_map(|s| s.split_once('=')).collect();
472                self.client
473                    .request(method, self.url.join(p)?)
474                    .query(&query)
475                    .headers(header_map)
476            }
477            None => self
478                .client
479                .request(method, self.url.join(path)?)
480                .headers(header_map),
481        };
482
483        let response = match body {
484            Some(body) if use_form_data => {
485                trace!("sending Kibana request with form-data");
486                let part = multipart::Part::bytes(body.to_vec())
487                    .file_name("dashboards.ndjson")
488                    .mime_str("application/x-ndjson")?;
489                let form = multipart::Form::new().part("file", part);
490                client.multipart(form).send().await
491            }
492            Some(body) => {
493                trace!("sending Kibana request with body");
494                client.body(body.to_vec()).send().await
495            }
496            None => client.send().await,
497        };
498        response.map_err(Error::from)
499    }
500
501    /// Helper for GET requests.
502    pub async fn get(&self, path: &str) -> Result<reqwest::Response> {
503        self.request(Method::GET, &HashMap::new(), path, None).await
504    }
505
506    /// Helper for GET requests for internal Kibana APIs.
507    /// Adds X-Elastic-Internal-Origin header required by some APIs (e.g., workflows).
508    pub async fn get_internal(&self, path: &str) -> Result<reqwest::Response> {
509        let mut headers = HashMap::new();
510        headers.insert(
511            "X-Elastic-Internal-Origin".to_string(),
512            "Kibana".to_string(),
513        );
514        self.request(Method::GET, &headers, path, None).await
515    }
516
517    /// Helper for HEAD requests.
518    pub async fn head(&self, path: &str) -> Result<reqwest::Response> {
519        self.request(Method::HEAD, &HashMap::new(), path, None)
520            .await
521    }
522
523    /// Helper for HEAD requests for internal Kibana APIs.
524    /// Adds X-Elastic-Internal-Origin header required by some APIs.
525    pub async fn head_internal(&self, path: &str) -> Result<reqwest::Response> {
526        let mut headers = HashMap::new();
527        headers.insert(
528            "X-Elastic-Internal-Origin".to_string(),
529            "Kibana".to_string(),
530        );
531        self.request(Method::HEAD, &headers, path, None).await
532    }
533
534    /// Helper for POST requests with JSON body.
535    pub async fn post_json(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
536        let mut headers = HashMap::new();
537        headers.insert("Content-Type".to_string(), "application/json".to_string());
538        self.request(Method::POST, &headers, path, Some(body)).await
539    }
540
541    /// Helper for POST requests with JSON value.
542    pub async fn post_json_value(
543        &self,
544        path: &str,
545        value: &serde_json::Value,
546    ) -> Result<reqwest::Response> {
547        let body = serde_json::to_vec(value)?;
548        self.post_json(path, &body).await
549    }
550
551    /// Helper for POST requests with JSON value for internal Kibana APIs.
552    /// Adds X-Elastic-Internal-Origin header required by some APIs (e.g., workflows).
553    pub async fn post_json_value_internal(
554        &self,
555        path: &str,
556        value: &serde_json::Value,
557    ) -> Result<reqwest::Response> {
558        let body = serde_json::to_vec(value)?;
559        let mut headers = HashMap::new();
560        headers.insert("Content-Type".to_string(), "application/json".to_string());
561        headers.insert(
562            "X-Elastic-Internal-Origin".to_string(),
563            "Kibana".to_string(),
564        );
565        self.request(Method::POST, &headers, path, Some(&body))
566            .await
567    }
568
569    /// Helper for PUT requests with JSON value.
570    pub async fn put_json_value(
571        &self,
572        path: &str,
573        value: &serde_json::Value,
574    ) -> Result<reqwest::Response> {
575        let body = serde_json::to_vec(value)?;
576        let mut headers = HashMap::new();
577        headers.insert("Content-Type".to_string(), "application/json".to_string());
578        self.request(Method::PUT, &headers, path, Some(&body)).await
579    }
580
581    /// Helper for PUT requests with JSON value for internal Kibana APIs.
582    /// Adds X-Elastic-Internal-Origin header required by some APIs (e.g., workflows).
583    pub async fn put_json_value_internal(
584        &self,
585        path: &str,
586        value: &serde_json::Value,
587    ) -> Result<reqwest::Response> {
588        let body = serde_json::to_vec(value)?;
589        let mut headers = HashMap::new();
590        headers.insert("Content-Type".to_string(), "application/json".to_string());
591        headers.insert(
592            "X-Elastic-Internal-Origin".to_string(),
593            "Kibana".to_string(),
594        );
595        self.request(Method::PUT, &headers, path, Some(&body)).await
596    }
597
598    /// Helper for POST requests with multipart form data.
599    pub async fn post_form(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
600        let mut headers = HashMap::new();
601        headers.insert(
602            "Content-Type".to_string(),
603            "multipart/form-data".to_string(),
604        );
605        self.request(Method::POST, &headers, path, Some(body)).await
606    }
607}
608
609impl std::fmt::Display for KibanaClient {
610    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
611        match &self.space {
612            Some(space) => write!(f, "{} (space: {})", self.url, space),
613            None => write!(f, "{}", self.url),
614        }
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::test_support::{MockResponse, TestServer};
622    use serde_json::json;
623
624    #[test]
625    fn test_kibana_client_defaults_to_default_space_without_filesystem() {
626        let url = Url::parse("http://localhost:5601").unwrap();
627        let client = KibanaClient::builder(url).build().unwrap();
628
629        assert_eq!(client.space_ids().len(), 1);
630        assert!(client.has_space("default"));
631        assert_eq!(client.space_name("default"), Some("Default"));
632        assert!(client.is_root());
633        assert_eq!(client.space_id(), "default");
634    }
635
636    #[test]
637    fn test_kibana_client_with_explicit_space_registry() {
638        let url = Url::parse("http://localhost:5601").unwrap();
639        let client = KibanaClient::builder(url)
640            .spaces([
641                ("default".to_string(), "Default".to_string()),
642                ("marketing".to_string(), "Marketing".to_string()),
643            ])
644            .build()
645            .unwrap();
646
647        assert_eq!(client.space_ids().len(), 2);
648        assert!(client.has_space("default"));
649        assert!(client.has_space("marketing"));
650        assert_eq!(client.space_name("marketing"), Some("Marketing"));
651    }
652
653    #[test]
654    fn test_space_scoped_client() {
655        let url = Url::parse("http://localhost:5601").unwrap();
656        let client = KibanaClient::builder(url)
657            .spaces([
658                ("default".to_string(), "Default".to_string()),
659                ("marketing".to_string(), "Marketing".to_string()),
660            ])
661            .build()
662            .unwrap();
663
664        // Root client
665        assert!(client.is_root());
666        assert_eq!(client.space_id(), "default");
667
668        // Default space should have None internally (no /s/ prefix)
669        let default = client.space("default").unwrap();
670        assert_eq!(default.space_id(), "default");
671        assert!(default.space.is_none());
672        assert_eq!(
673            default.prefixed_path_for_test("/api/saved_objects"),
674            "/api/saved_objects"
675        );
676
677        // Non-default space should have Some
678        let marketing = client.space("marketing").unwrap();
679        assert_eq!(marketing.space_id(), "marketing");
680        assert!(!marketing.is_root());
681        assert!(marketing.space.is_some());
682        assert_eq!(
683            marketing.prefixed_path_for_test("/api/saved_objects"),
684            "/s/marketing/api/saved_objects"
685        );
686
687        // Space-scoped client should still have access to spaces map
688        assert!(marketing.has_space("default"));
689        assert!(marketing.has_space("marketing"));
690    }
691
692    #[test]
693    fn test_invalid_space() {
694        let url = Url::parse("http://localhost:5601").unwrap();
695        let client = KibanaClient::builder(url).build().unwrap();
696
697        let result = client.space("nonexistent");
698        assert!(result.is_err());
699        assert!(matches!(result.unwrap_err(), Error::InvalidSpace { .. }));
700    }
701
702    #[test]
703    fn test_parse_kibana_version() {
704        let parsed = parse_kibana_version("9.3.2").unwrap();
705        assert_eq!(parsed, KibanaVersion::new(9, 3, 2));
706
707        let snapshot = parse_kibana_version("9.4.0-SNAPSHOT").unwrap();
708        assert_eq!(snapshot, KibanaVersion::parse("9.4.0-SNAPSHOT").unwrap());
709
710        let prefixed = parse_kibana_version("v9.5.1").unwrap();
711        assert_eq!(prefixed, KibanaVersion::new(9, 5, 1));
712
713        let missing_patch = parse_kibana_version("9.6").unwrap();
714        assert_eq!(missing_patch, KibanaVersion::new(9, 6, 0));
715    }
716
717    #[test]
718    fn test_capability_thresholds() {
719        let v92 = parse_kibana_version("9.2.1").unwrap();
720        let v91 = parse_kibana_version("9.1.9").unwrap();
721        let v93 = parse_kibana_version("9.3.0").unwrap();
722
723        assert!(KibanaClient::supports_capability(
724            &v92,
725            ApiCapability::Agents
726        ));
727        assert!(KibanaClient::supports_capability(
728            &v92,
729            ApiCapability::Tools
730        ));
731        assert!(!KibanaClient::supports_capability(
732            &v91,
733            ApiCapability::Agents
734        ));
735        assert!(!KibanaClient::supports_capability(
736            &v91,
737            ApiCapability::Tools
738        ));
739        assert!(KibanaClient::supports_capability(
740            &v93,
741            ApiCapability::Workflows
742        ));
743        assert!(!KibanaClient::supports_capability(
744            &v92,
745            ApiCapability::Workflows
746        ));
747
748        let v94 = parse_kibana_version("9.4.0").unwrap();
749        assert!(KibanaClient::supports_capability(
750            &v94,
751            ApiCapability::Skills
752        ));
753        assert!(!KibanaClient::supports_capability(
754            &v93,
755            ApiCapability::Skills
756        ));
757    }
758
759    #[tokio::test]
760    async fn server_version_info_parses_authenticated_status_response() {
761        let server = TestServer::new(vec![MockResponse {
762            method: "GET",
763            path: "/api/status",
764            status: 200,
765            body: json!({
766                "version": {
767                    "number": "9.4.1"
768                }
769            }),
770        }]);
771        let client = server.client().unwrap();
772
773        let info = client.server_version_info().await.unwrap();
774
775        assert_eq!(info.raw, "9.4.1");
776        assert_eq!(info.parsed, KibanaVersion::new(9, 4, 1));
777    }
778
779    #[tokio::test]
780    async fn server_version_info_reports_redacted_status_as_authentication_failure() {
781        let server = TestServer::new(vec![MockResponse {
782            method: "GET",
783            path: "/api/status",
784            status: 200,
785            body: json!({
786                "status": {
787                    "overall": {
788                        "level": "available"
789                    }
790                }
791            }),
792        }]);
793        let client = server.client().unwrap();
794
795        let err = client.server_version_info().await.unwrap_err();
796
797        assert!(matches!(err, Error::RedactedStatusUnauthenticated));
798        assert!(err.to_string().contains("Check KIBANA_APIKEY"));
799    }
800}