1#[cfg(feature = "mock")]
2pub mod mock;
3
4pub(crate) mod paths;
5
6pub mod access_presets;
7pub mod api_tokens;
8pub mod audit_logs;
9pub mod auth;
10pub mod autodiscovery;
11pub mod batch_progress_stream;
12pub mod discovery_allowlist;
13pub mod enrollment_tokens;
14pub mod error;
15pub mod events_stream;
16pub mod health;
17pub mod host_tags;
18pub mod hosts;
19pub mod notifications;
20pub mod oidc_auth;
21pub mod oidc_providers;
22pub mod permissions;
23pub mod pki;
24pub mod plugin_configs;
25pub mod plugin_type_settings;
26pub mod roles;
27pub mod scheduler;
28pub mod services;
29pub mod settings;
30pub mod settings_nats;
31pub mod settings_provider_github;
32pub mod software_items;
33pub mod sse;
34pub mod surfaces;
35pub mod system_alerts;
36pub mod system_enrollment_tokens;
37pub mod system_services;
38pub mod update_batches;
39pub mod update_history;
40pub mod update_output_stream;
41pub mod users;
42
43pub use error::{ClientError, Result};
44
45pub use uptrakit_shared_types::DeviceAuthStatus;
46pub use uptrakit_web_api_types as types;
47
48pub(crate) mod types_impl {
49 pub(crate) use uptrakit_web_api_types::*;
50}
51
52#[cfg(test)]
53pub(crate) mod shared_types_impl {
54 pub(crate) use uptrakit_shared_types::*;
55}
56
57pub use uuid::Uuid;
60
61pub use reqwest::Error as ReqwestError;
64
65pub use reqwest::StatusCode;
68
69use rootcause::prelude::*;
70use serde::Serialize;
71use serde::de::DeserializeOwned;
72use std::time::Duration;
73
74fn serialize_status_code<S: serde::Serializer>(
76 status: &reqwest::StatusCode,
77 serializer: S,
78) -> std::result::Result<S::Ok, S::Error> {
79 serializer.serialize_u16(status.as_u16())
80}
81
82#[derive(Debug, Serialize)]
84pub struct RawResponse {
85 #[serde(serialize_with = "serialize_status_code")]
86 pub status: reqwest::StatusCode,
87 pub body: serde_json::Value,
88}
89
90#[derive(Debug, Clone)]
104pub struct RetryConfig {
105 pub max_retries: u32,
108 pub initial_delay: Duration,
111 pub max_delay: Duration,
114}
115
116impl Default for RetryConfig {
117 fn default() -> Self {
118 Self {
119 max_retries: 3,
120 initial_delay: Duration::from_secs(1),
121 max_delay: Duration::from_secs(30),
122 }
123 }
124}
125
126pub struct UptrakitClient {
131 http: reqwest::Client,
132 base_url: String,
133 token: Option<String>,
134 retry: Option<RetryConfig>,
135}
136
137impl UptrakitClient {
138 const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
140
141 const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
143
144 pub fn new(
159 base_url: &str,
160 token: Option<&str>,
161 insecure: bool,
162 ca_pem: Option<&str>,
163 request_timeout: Option<Duration>,
164 ) -> Result<Self> {
165 let timeout = request_timeout.unwrap_or(Self::DEFAULT_REQUEST_TIMEOUT);
166 let mut builder = reqwest::Client::builder()
167 .connect_timeout(Self::DEFAULT_CONNECT_TIMEOUT)
168 .timeout(timeout);
169 if insecure {
170 builder = builder.tls_danger_accept_invalid_certs(true);
171 } else if let Some(pem) = ca_pem {
172 let cert = reqwest::Certificate::from_pem(pem.as_bytes()).context_to()?;
173 builder = builder.tls_certs_only(std::iter::once(cert));
174 }
175 let http = builder.build().context_to()?;
176
177 Ok(Self {
178 http,
179 base_url: base_url.trim_end_matches('/').to_string(),
180 token: token.map(|t| t.to_string()),
181 retry: None,
182 })
183 }
184
185 pub fn with_token(
190 base_url: &str,
191 token: &str,
192 insecure: bool,
193 ca_pem: Option<&str>,
194 ) -> Result<Self> {
195 Self::new(base_url, Some(token), insecure, ca_pem, None)
196 }
197
198 pub fn with_retry(mut self, config: RetryConfig) -> Self {
204 self.retry = Some(config);
205 self
206 }
207
208 pub async fn raw_request(
210 &self,
211 method: &str,
212 path: &str,
213 body: Option<serde_json::Value>,
214 ) -> Result<RawResponse> {
215 let url = format!("{}{}", self.base_url, path);
216 let method = method.to_uppercase();
217 let req_method = method
218 .parse::<reqwest::Method>()
219 .map_err(|e| report!(ClientError::InvalidMethod(e.to_string())))?;
220
221 let mut req = self.http.request(req_method, &url);
222 if let Some(token) = &self.token {
223 req = req.bearer_auth(token);
224 }
225 if let Some(body) = body {
226 req = req.json(&body);
227 }
228
229 let resp = req.send().await.context_to()?;
230 let status = resp.status();
231 let text = resp.text().await.context_to()?;
232
233 let body = if text.is_empty() {
234 serde_json::Value::Null
235 } else {
236 serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text))
237 };
238
239 Ok(RawResponse { status, body })
240 }
241
242 fn token_or_err(&self) -> Result<&str> {
245 self.token
246 .as_deref()
247 .ok_or_else(|| report!(ClientError::NotAuthenticated))
248 }
249
250 async fn send_with_retry(&self, req: reqwest::RequestBuilder) -> Result<reqwest::Response> {
256 let Some(retry) = &self.retry else {
257 return req.send().await.context_to();
258 };
259
260 let retry_builders: Vec<reqwest::RequestBuilder> = (0..retry.max_retries)
264 .map_while(|_| req.try_clone())
265 .collect();
266
267 let mut resp = req.send().await.context_to()?;
268
269 for (attempt, retry_req) in retry_builders.into_iter().enumerate() {
270 let status = resp.status();
271 let delay = if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
272 parse_retry_after(&resp)
274 .map(Duration::from_secs)
275 .unwrap_or(retry.initial_delay)
276 .min(retry.max_delay)
277 } else if status.is_server_error() {
278 let factor = 1u32.checked_shl(attempt as u32).unwrap_or(u32::MAX);
280 retry
281 .initial_delay
282 .saturating_mul(factor)
283 .min(retry.max_delay)
284 } else {
285 return Ok(resp);
287 };
288
289 tokio::time::sleep(delay).await;
290 resp = retry_req.send().await.context_to()?;
291 }
292
293 Ok(resp)
294 }
295
296 pub(crate) async fn fetch_all_pages<T: DeserializeOwned + Send>(
304 &self,
305 path: &str,
306 base_query: &impl Serialize,
307 ) -> Result<Vec<T>> {
308 use crate::types_impl::pagination::{MAX_PER_PAGE, PaginatedResponse};
309
310 let base_value = serde_json::to_value(base_query).context_to()?;
311 let mut all: Vec<T> = Vec::new();
312 let mut page: u64 = 1;
313 loop {
314 let mut query = base_value.clone();
315 if let Some(obj) = query.as_object_mut() {
316 obj.insert("page".to_string(), serde_json::json!(page));
317 obj.insert("per_page".to_string(), serde_json::json!(MAX_PER_PAGE));
318 }
319 let resp: PaginatedResponse<T> = self.get_with_query(path, &query).await?;
320 let total_pages = resp.total_pages;
321 all.extend(resp.items);
322 if page >= total_pages || total_pages == 0 {
323 break;
324 }
325 page += 1;
326 }
327 Ok(all)
328 }
329
330 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
331 let url = format!("{}{}", self.base_url, path);
332 let req = self.http.get(&url).bearer_auth(self.token_or_err()?);
333 let resp = self.send_with_retry(req).await?;
334 self.handle_response(resp).await
335 }
336
337 async fn get_with_query<T: DeserializeOwned>(
338 &self,
339 path: &str,
340 query: &impl Serialize,
341 ) -> Result<T> {
342 let url = format!("{}{}", self.base_url, path);
343 let req = self
344 .http
345 .get(&url)
346 .bearer_auth(self.token_or_err()?)
347 .query(query);
348 let resp = self.send_with_retry(req).await?;
349 self.handle_response(resp).await
350 }
351
352 async fn post_json<T: DeserializeOwned>(&self, path: &str, body: &impl Serialize) -> Result<T> {
353 let url = format!("{}{}", self.base_url, path);
354 let req = self
355 .http
356 .post(&url)
357 .bearer_auth(self.token_or_err()?)
358 .json(body);
359 let resp = self.send_with_retry(req).await?;
360 self.handle_response(resp).await
361 }
362
363 async fn post_empty<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
364 let url = format!("{}{}", self.base_url, path);
365 let req = self.http.post(&url).bearer_auth(self.token_or_err()?);
366 let resp = self.send_with_retry(req).await?;
367 self.handle_response(resp).await
368 }
369
370 async fn post_json_unauth<T: DeserializeOwned>(
372 &self,
373 path: &str,
374 body: &impl Serialize,
375 ) -> Result<T> {
376 let url = format!("{}{}", self.base_url, path);
377 let req = self.http.post(&url).json(body);
378 let resp = self.send_with_retry(req).await?;
379 self.handle_response(resp).await
380 }
381
382 async fn post_form_unauth<T: DeserializeOwned, F: Serialize + ?Sized>(
392 &self,
393 path: &str,
394 form: &F,
395 ) -> Result<T> {
396 let url = format!("{}{}", self.base_url, path);
397 let req = self.http.post(&url).form(form);
398 let resp = self.send_with_retry(req).await?;
399
400 let status = resp.status();
401 let retry_after = parse_retry_after(&resp);
404 let bytes = resp.bytes().await.context_to()?;
405
406 if status == reqwest::StatusCode::OK {
407 return serde_json::from_slice::<T>(&bytes).context_to();
408 }
409 if status == reqwest::StatusCode::BAD_REQUEST
410 && let Ok(err_resp) =
411 serde_json::from_slice::<crate::types_impl::oauth::OAuthErrorResponse>(&bytes)
412 {
413 bail!(ClientError::OAuthError(err_resp));
414 }
415 self.handle_response_bytes(status, bytes.to_vec(), retry_after)
420 .await
421 }
422
423 async fn delete(&self, path: &str) -> Result<()> {
424 let url = format!("{}{}", self.base_url, path);
425 let req = self.http.delete(&url).bearer_auth(self.token_or_err()?);
426 let resp = self.send_with_retry(req).await?;
427 self.handle_empty_response(resp).await
428 }
429
430 async fn delete_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
431 let url = format!("{}{}", self.base_url, path);
432 let req = self.http.delete(&url).bearer_auth(self.token_or_err()?);
433 let resp = self.send_with_retry(req).await?;
434 self.handle_response(resp).await
435 }
436
437 async fn delete_with_query(&self, path: &str, query: &impl Serialize) -> Result<()> {
438 let url = format!("{}{}", self.base_url, path);
439 let req = self
440 .http
441 .delete(&url)
442 .bearer_auth(self.token_or_err()?)
443 .query(query);
444 let resp = self.send_with_retry(req).await?;
445 self.handle_empty_response(resp).await
446 }
447
448 #[expect(
449 dead_code,
450 reason = "HTTP helper — not yet called by any route but retained for API completeness"
451 )]
452 async fn delete_with_query_json<T: DeserializeOwned>(
453 &self,
454 path: &str,
455 query: &impl Serialize,
456 ) -> Result<T> {
457 let url = format!("{}{}", self.base_url, path);
458 let req = self
459 .http
460 .delete(&url)
461 .bearer_auth(self.token_or_err()?)
462 .query(query);
463 let resp = self.send_with_retry(req).await?;
464 self.handle_response(resp).await
465 }
466
467 async fn put_json<T: DeserializeOwned>(&self, path: &str, body: &impl Serialize) -> Result<T> {
468 let url = format!("{}{}", self.base_url, path);
469 let req = self
470 .http
471 .put(&url)
472 .bearer_auth(self.token_or_err()?)
473 .json(body);
474 let resp = self.send_with_retry(req).await?;
475 self.handle_response(resp).await
476 }
477
478 async fn get_with_etag<T: DeserializeOwned>(&self, path: &str) -> Result<(T, String)> {
479 let url = format!("{}{}", self.base_url, path);
480 let req = self.http.get(&url).bearer_auth(self.token_or_err()?);
481 let resp = self.send_with_retry(req).await?;
482 let etag = resp
483 .headers()
484 .get(reqwest::header::ETAG)
485 .and_then(|v| v.to_str().ok())
486 .unwrap_or_default()
487 .to_string();
488 let body: T = self.handle_response(resp).await?;
489 Ok((body, etag))
490 }
491
492 async fn put_json_with_etag<T: DeserializeOwned>(
493 &self,
494 path: &str,
495 body: &impl Serialize,
496 etag: &str,
497 ) -> Result<(T, String)> {
498 let url = format!("{}{}", self.base_url, path);
499 let req = self
500 .http
501 .put(&url)
502 .bearer_auth(self.token_or_err()?)
503 .header(reqwest::header::IF_MATCH, etag)
504 .json(body);
505 let resp = self.send_with_retry(req).await?;
506 let new_etag = resp
507 .headers()
508 .get(reqwest::header::ETAG)
509 .and_then(|v| v.to_str().ok())
510 .unwrap_or_default()
511 .to_string();
512 let body: T = self.handle_response(resp).await?;
513 Ok((body, new_etag))
514 }
515
516 async fn post_json_no_content(&self, path: &str, body: &impl Serialize) -> Result<()> {
518 let url = format!("{}{}", self.base_url, path);
519 let req = self
520 .http
521 .post(&url)
522 .bearer_auth(self.token_or_err()?)
523 .json(body);
524 let resp = self.send_with_retry(req).await?;
525 self.handle_empty_response(resp).await
526 }
527
528 async fn get_unauth<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
530 let url = format!("{}{}", self.base_url, path);
531 let req = self.http.get(&url);
532 let resp = self.send_with_retry(req).await?;
533 self.handle_response(resp).await
534 }
535
536 async fn get_text_unauth(&self, path: &str) -> Result<String> {
538 let url = format!("{}{}", self.base_url, path);
539 let req = self.http.get(&url);
540 let resp = self.send_with_retry(req).await?;
541 self.handle_text_response(resp).await
542 }
543
544 async fn handle_response<T: DeserializeOwned>(&self, resp: reqwest::Response) -> Result<T> {
545 let status = resp.status();
546 let retry_after = parse_retry_after(&resp);
547 let bytes = resp.bytes().await.context_to()?.to_vec();
548 self.handle_response_bytes(status, bytes, retry_after).await
549 }
550
551 async fn handle_response_bytes<T: DeserializeOwned>(
552 &self,
553 status: reqwest::StatusCode,
554 bytes: Vec<u8>,
555 retry_after: Option<u64>,
556 ) -> Result<T> {
557 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
558 bail!(ClientError::RateLimited {
559 retry_after_seconds: retry_after,
560 });
561 }
562 if status == reqwest::StatusCode::UNAUTHORIZED {
563 bail!(ClientError::NotAuthenticated);
564 }
565 let text = String::from_utf8_lossy(&bytes).into_owned();
566 if status == reqwest::StatusCode::NOT_FOUND {
567 let message = extract_error_message(&text);
568 bail!(ClientError::NotFound(message));
569 }
570 if status.is_client_error() || status.is_server_error() {
571 let message = extract_error_message(&text);
572 bail!(ClientError::Api { status, message });
573 }
574 serde_json::from_slice::<T>(&bytes).context_to()
575 }
576
577 async fn handle_empty_response(&self, resp: reqwest::Response) -> Result<()> {
578 let status = resp.status();
579 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
580 let retry_after = parse_retry_after(&resp);
581 bail!(ClientError::RateLimited {
582 retry_after_seconds: retry_after,
583 });
584 }
585 if status == reqwest::StatusCode::UNAUTHORIZED {
586 bail!(ClientError::NotAuthenticated);
587 }
588 if status == reqwest::StatusCode::NOT_FOUND {
589 let text = resp.text().await.context_to()?;
590 let message = extract_error_message(&text);
591 bail!(ClientError::NotFound(message));
592 }
593 if status.is_client_error() || status.is_server_error() {
594 let text = resp.text().await.context_to()?;
595 let message = extract_error_message(&text);
596 bail!(ClientError::Api { status, message });
597 }
598 Ok(())
599 }
600
601 async fn handle_text_response(&self, resp: reqwest::Response) -> Result<String> {
602 let status = resp.status();
603 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
604 let retry_after = parse_retry_after(&resp);
605 bail!(ClientError::RateLimited {
606 retry_after_seconds: retry_after,
607 });
608 }
609 if status == reqwest::StatusCode::UNAUTHORIZED {
610 bail!(ClientError::NotAuthenticated);
611 }
612 let text = resp.text().await.context_to()?;
613 if status == reqwest::StatusCode::NOT_FOUND {
614 let message = extract_error_message(&text);
615 bail!(ClientError::NotFound(message));
616 }
617 if status.is_client_error() || status.is_server_error() {
618 let message = extract_error_message(&text);
619 bail!(ClientError::Api { status, message });
620 }
621 Ok(text)
622 }
623}
624
625fn parse_retry_after(resp: &reqwest::Response) -> Option<u64> {
630 resp.headers()
631 .get(reqwest::header::RETRY_AFTER)?
632 .to_str()
633 .ok()?
634 .parse::<u64>()
635 .ok()
636}
637
638#[expect(
641 clippy::indexing_slicing,
642 reason = "serde_json::Value index operator returns Value::Null for missing keys rather than panicking; this is safe"
643)]
644pub(crate) fn extract_error_message(text: &str) -> String {
645 serde_json::from_str::<serde_json::Value>(text)
646 .ok()
647 .and_then(|v| v["error"].as_str().map(|s| s.to_string()))
648 .unwrap_or_else(|| {
649 if text.is_empty() {
650 "Request failed".to_string()
651 } else {
652 text.to_string()
653 }
654 })
655}
656
657#[cfg(test)]
658mod tests {
659 #![expect(
660 clippy::assertions_on_result_states,
661 reason = "test assertions — assert!(result.is_err()) is idiomatic in tests"
662 )]
663
664 use super::*;
665
666 #[test]
667 fn extract_error_message_from_json() {
668 let text = r#"{"error":"Not found"}"#;
669 assert_eq!(extract_error_message(text), "Not found");
670 }
671
672 #[test]
673 fn extract_error_message_from_json_without_error_field() {
674 let text = r#"{"message":"something"}"#;
675 assert_eq!(extract_error_message(text), text);
676 }
677
678 #[test]
679 fn extract_error_message_from_plain_text() {
680 let text = "Internal Server Error";
681 assert_eq!(extract_error_message(text), "Internal Server Error");
682 }
683
684 #[test]
685 fn extract_error_message_from_empty() {
686 assert_eq!(extract_error_message(""), "Request failed");
687 }
688
689 #[test]
690 fn base_url_trailing_slash_is_trimmed() {
691 let client = UptrakitClient::new("https://example.com/", None, false, None, None)
692 .expect("client creation");
693 assert_eq!(client.base_url, "https://example.com");
694 }
695
696 #[test]
697 fn base_url_without_trailing_slash_is_unchanged() {
698 let client = UptrakitClient::new("https://example.com", None, false, None, None)
699 .expect("client creation");
700 assert_eq!(client.base_url, "https://example.com");
701 }
702
703 #[test]
704 fn with_token_stores_token() {
705 let client = UptrakitClient::with_token("https://example.com", "tok-123", false, None)
706 .expect("client creation");
707 assert_eq!(client.token.as_deref(), Some("tok-123"));
708 }
709
710 #[test]
711 fn new_without_token_stores_none() {
712 let client = UptrakitClient::new("https://example.com", None, false, None, None)
713 .expect("client creation");
714 assert!(client.token.is_none());
715 }
716
717 #[test]
718 fn token_or_err_returns_token_when_present() {
719 let client = UptrakitClient::with_token("https://example.com", "tok", false, None)
720 .expect("client creation");
721 assert_eq!(client.token_or_err().expect("token"), "tok");
722 }
723
724 #[test]
725 fn token_or_err_returns_error_when_absent() {
726 let client = UptrakitClient::new("https://example.com", None, false, None, None)
727 .expect("client creation");
728 let err = client.token_or_err().unwrap_err();
729 assert!(
730 matches!(err.current_context(), ClientError::NotAuthenticated),
731 "expected NotAuthenticated, got: {err}"
732 );
733 }
734
735 #[test]
736 fn parse_retry_after_valid_seconds() {
737 let resp = http::Response::builder()
738 .status(http::StatusCode::TOO_MANY_REQUESTS)
739 .header("Retry-After", "60")
740 .body("")
741 .unwrap();
742 let reqwest_resp = reqwest::Response::from(resp);
743 assert_eq!(parse_retry_after(&reqwest_resp), Some(60));
744 }
745
746 #[test]
747 fn parse_retry_after_missing_header() {
748 let resp = http::Response::builder()
749 .status(http::StatusCode::TOO_MANY_REQUESTS)
750 .body("")
751 .unwrap();
752 let reqwest_resp = reqwest::Response::from(resp);
753 assert_eq!(parse_retry_after(&reqwest_resp), None);
754 }
755
756 #[test]
757 fn parse_retry_after_non_numeric() {
758 let resp = http::Response::builder()
759 .status(http::StatusCode::TOO_MANY_REQUESTS)
760 .header("Retry-After", "Wed, 21 Oct 2025 07:28:00 GMT")
761 .body("")
762 .unwrap();
763 let reqwest_resp = reqwest::Response::from(resp);
764 assert_eq!(parse_retry_after(&reqwest_resp), None);
765 }
766
767 #[test]
768 fn raw_response_serialization() {
769 let resp = RawResponse {
770 status: reqwest::StatusCode::OK,
771 body: serde_json::json!({"key": "value"}),
772 };
773 let json = serde_json::to_string(&resp).expect("serialize");
774 let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse");
775 assert_eq!(parsed["status"], 200);
776 assert_eq!(parsed["body"]["key"], "value");
777 }
778
779 #[test]
780 fn default_client_has_no_retry() {
781 let client =
782 UptrakitClient::new("https://example.com", None, false, None, None).expect("client");
783 assert!(client.retry.is_none());
784 }
785
786 #[test]
787 fn with_retry_sets_config() {
788 let client = UptrakitClient::new("https://example.com", None, false, None, None)
789 .expect("client")
790 .with_retry(RetryConfig::default());
791 assert!(client.retry.is_some());
792 }
793
794 #[test]
795 fn retry_config_default_values() {
796 let config = RetryConfig::default();
797 assert_eq!(config.max_retries, 3);
798 assert_eq!(config.initial_delay, Duration::from_secs(1));
799 assert_eq!(config.max_delay, Duration::from_secs(30));
800 }
801
802 #[cfg(test)]
804 fn retrying_client(base_url: &str) -> UptrakitClient {
805 UptrakitClient::with_token(base_url, "test-token", false, None)
806 .expect("client")
807 .with_retry(RetryConfig {
808 max_retries: 2,
809 initial_delay: Duration::from_millis(1),
810 max_delay: Duration::from_millis(10),
811 })
812 }
813
814 #[tokio::test]
817 async fn retry_exhausted_on_repeated_503() {
818 use crate::types_impl::pagination::PaginationParams;
819 use httpmock::prelude::*;
820
821 let server = MockServer::start_async().await;
822 let mock = server.mock(|when, then| {
823 when.method(GET).path("/api/v1/hosts");
824 then.status(503).body(r#"{"error":"down"}"#);
825 });
826
827 let params = PaginationParams {
828 page: None,
829 per_page: None,
830 };
831 let client = retrying_client(&server.base_url());
832 let result = client.list_hosts(¶ms).await;
833
834 assert!(result.is_err());
835 mock.assert_calls(3);
837 }
838
839 #[tokio::test]
840 async fn no_retry_on_400() {
841 use crate::types_impl::pagination::PaginationParams;
842 use httpmock::prelude::*;
843
844 let server = MockServer::start_async().await;
845 let mock = server.mock(|when, then| {
846 when.method(GET).path("/api/v1/hosts");
847 then.status(400).body(r#"{"error":"bad request"}"#);
848 });
849
850 let params = PaginationParams {
851 page: None,
852 per_page: None,
853 };
854 let client = retrying_client(&server.base_url());
855 let result = client.list_hosts(¶ms).await;
856
857 assert!(result.is_err());
858 mock.assert_calls(1); }
860
861 #[tokio::test]
862 async fn no_retry_on_401() {
863 use crate::types_impl::pagination::PaginationParams;
864 use httpmock::prelude::*;
865
866 let server = MockServer::start_async().await;
867 let mock = server.mock(|when, then| {
868 when.method(GET).path("/api/v1/hosts");
869 then.status(401).body(r#"{"error":"unauthorized"}"#);
870 });
871
872 let params = PaginationParams {
873 page: None,
874 per_page: None,
875 };
876 let client = retrying_client(&server.base_url());
877 let result = client.list_hosts(¶ms).await;
878
879 assert!(result.is_err());
880 mock.assert_calls(1); }
882
883 #[tokio::test]
884 async fn retry_exhausted_on_repeated_429() {
885 use crate::types_impl::pagination::PaginationParams;
886 use httpmock::prelude::*;
887
888 let server = MockServer::start_async().await;
889 let mock = server.mock(|when, then| {
890 when.method(GET).path("/api/v1/hosts");
891 then.status(429)
892 .header("Retry-After", "1")
893 .body(r#"{"error":"rate limited"}"#);
894 });
895
896 let params = PaginationParams {
897 page: None,
898 per_page: None,
899 };
900 let client = retrying_client(&server.base_url());
901 let result = client.list_hosts(¶ms).await;
902
903 assert!(result.is_err());
904 mock.assert_calls(3);
906 }
907
908 fn host_json(id: &str) -> serde_json::Value {
912 serde_json::json!({
913 "id": id,
914 "machine_id": format!("machine-{id}"),
915 "hostname": format!("host-{id}"),
916 "friendly_name": format!("Host {id}"),
917 "os_type": null,
918 "os_version": null,
919 "architecture": null,
920 "ip_address": null,
921 "last_seen_at": null,
922 "created_at": "2024-01-01T00:00:00Z",
923 "updated_at": "2024-01-01T00:00:00Z",
924 "agents": [],
925 "tags": []
926 })
927 }
928
929 fn paginated_hosts_json(
930 items: Vec<serde_json::Value>,
931 total: u64,
932 page: u64,
933 total_pages: u64,
934 ) -> serde_json::Value {
935 serde_json::json!({
936 "items": items,
937 "total": total,
938 "page": page,
939 "per_page": 1000,
940 "total_pages": total_pages
941 })
942 }
943
944 #[tokio::test]
945 async fn list_all_hosts_multi_page() {
946 use httpmock::prelude::*;
947
948 let server = MockServer::start_async().await;
949
950 let h1 = host_json("550e8400-e29b-41d4-a716-446655440001");
951 let h2 = host_json("550e8400-e29b-41d4-a716-446655440002");
952 let h3 = host_json("550e8400-e29b-41d4-a716-446655440003");
953
954 server.mock(|when, then| {
955 when.method(GET)
956 .path("/api/v1/hosts")
957 .query_param("page", "1")
958 .query_param("per_page", "1000");
959 then.status(200)
960 .header("Content-Type", "application/json")
961 .json_body(paginated_hosts_json(vec![h1.clone()], 3, 1, 3));
962 });
963 server.mock(|when, then| {
964 when.method(GET)
965 .path("/api/v1/hosts")
966 .query_param("page", "2")
967 .query_param("per_page", "1000");
968 then.status(200)
969 .header("Content-Type", "application/json")
970 .json_body(paginated_hosts_json(vec![h2.clone()], 3, 2, 3));
971 });
972 server.mock(|when, then| {
973 when.method(GET)
974 .path("/api/v1/hosts")
975 .query_param("page", "3")
976 .query_param("per_page", "1000");
977 then.status(200)
978 .header("Content-Type", "application/json")
979 .json_body(paginated_hosts_json(vec![h3.clone()], 3, 3, 3));
980 });
981
982 let client =
983 UptrakitClient::with_token(&server.base_url(), "tok", false, None).expect("client");
984 let all = client.list_all_hosts().await.expect("list_all_hosts");
985 assert_eq!(all.len(), 3);
986 assert_eq!(
987 all[0].machine_id,
988 "machine-550e8400-e29b-41d4-a716-446655440001"
989 );
990 assert_eq!(
991 all[2].machine_id,
992 "machine-550e8400-e29b-41d4-a716-446655440003"
993 );
994 }
995
996 #[tokio::test]
997 async fn list_all_hosts_single_page() {
998 use httpmock::prelude::*;
999
1000 let server = MockServer::start_async().await;
1001 let h1 = host_json("550e8400-e29b-41d4-a716-000000000001");
1002 let h2 = host_json("550e8400-e29b-41d4-a716-000000000002");
1003
1004 server.mock(|when, then| {
1005 when.method(GET).path("/api/v1/hosts");
1006 then.status(200)
1007 .header("Content-Type", "application/json")
1008 .json_body(paginated_hosts_json(vec![h1, h2], 2, 1, 1));
1009 });
1010
1011 let client =
1012 UptrakitClient::with_token(&server.base_url(), "tok", false, None).expect("client");
1013 let all = client.list_all_hosts().await.expect("list_all_hosts");
1014 assert_eq!(all.len(), 2);
1015 }
1016
1017 #[tokio::test]
1018 async fn list_all_hosts_empty() {
1019 use httpmock::prelude::*;
1020
1021 let server = MockServer::start_async().await;
1022
1023 server.mock(|when, then| {
1024 when.method(GET).path("/api/v1/hosts");
1025 then.status(200)
1026 .header("Content-Type", "application/json")
1027 .json_body(paginated_hosts_json(vec![], 0, 1, 0));
1028 });
1029
1030 let client =
1031 UptrakitClient::with_token(&server.base_url(), "tok", false, None).expect("client");
1032 let all = client.list_all_hosts().await.expect("list_all_hosts");
1033 assert!(all.is_empty());
1034 }
1035
1036 #[tokio::test]
1037 async fn list_all_hosts_forwards_page_params() {
1038 use crate::types_impl::pagination::MAX_PER_PAGE;
1039 use httpmock::prelude::*;
1040
1041 let server = MockServer::start_async().await;
1042
1043 let page_param_mock = server.mock(|when, then| {
1045 when.method(GET)
1046 .path("/api/v1/hosts")
1047 .query_param("page", "1")
1048 .query_param("per_page", MAX_PER_PAGE.to_string());
1049 then.status(200)
1050 .header("Content-Type", "application/json")
1051 .json_body(paginated_hosts_json(vec![], 0, 1, 0));
1052 });
1053
1054 let client =
1055 UptrakitClient::with_token(&server.base_url(), "tok", false, None).expect("client");
1056 client.list_all_hosts().await.expect("list_all_hosts");
1057
1058 page_param_mock.assert_calls(1);
1059 }
1060
1061 #[test]
1062 fn new_with_ca_pem_none_succeeds() {
1063 let client =
1064 UptrakitClient::new("https://example.com", None, false, None, None).expect("client");
1065 assert!(client.token.is_none());
1066 }
1067
1068 #[test]
1069 fn new_with_insecure_ignores_invalid_ca_pem() {
1070 let client =
1072 UptrakitClient::new("https://example.com", None, true, Some("not-a-pem"), None);
1073 assert!(client.is_ok());
1074 }
1075}