1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::compliance::ComplianceSummary;
6use crate::errors::{CfgdError, Result};
7use crate::output::Printer;
8use crate::providers::SystemDrift;
9
10pub struct ServerClient {
12 base_url: String,
13 api_key: Option<String>,
14 device_id: String,
15}
16
17#[derive(Debug, Serialize)]
18#[serde(rename_all = "camelCase")]
19struct CheckinRequest {
20 device_id: String,
21 hostname: String,
22 os: String,
23 arch: String,
24 config_hash: String,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 compliance_summary: Option<ComplianceSummary>,
27}
28
29#[derive(Debug, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct CheckinResponse {
32 pub status: String,
33 pub config_changed: bool,
34 #[serde(default)]
35 pub desired_config: Option<serde_json::Value>,
36}
37
38#[derive(Debug, Serialize)]
39#[serde(rename_all = "camelCase")]
40struct DriftReport {
41 details: Vec<DriftDetail>,
42}
43
44#[derive(Debug, Serialize)]
45#[serde(rename_all = "camelCase")]
46struct DriftDetail {
47 field: String,
48 expected: String,
49 actual: String,
50}
51
52#[derive(Debug, Serialize)]
53#[serde(rename_all = "camelCase")]
54struct EnrollRequest {
55 token: String,
56 device_id: String,
57 hostname: String,
58 os: String,
59 arch: String,
60}
61
62#[derive(Debug, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct EnrollResponse {
65 pub status: String,
66 pub device_id: String,
67 pub api_key: String,
68 pub username: String,
69 #[serde(default)]
70 pub team: Option<String>,
71 #[serde(default)]
72 pub desired_config: Option<serde_json::Value>,
73}
74
75#[derive(Debug, Serialize)]
78#[serde(rename_all = "camelCase")]
79struct ChallengeRequest {
80 username: String,
81 device_id: String,
82 hostname: String,
83 os: String,
84 arch: String,
85}
86
87#[derive(Debug, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct ChallengeResponse {
90 pub challenge_id: String,
91 pub nonce: String,
92 pub expires_at: String,
93}
94
95#[derive(Debug, Serialize)]
96#[serde(rename_all = "camelCase")]
97struct VerifyRequest {
98 challenge_id: String,
99 signature: String,
100 key_type: String,
101}
102
103#[derive(Debug, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct EnrollInfoResponse {
106 pub method: String,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct DeviceCredential {
113 pub server_url: String,
114 pub device_id: String,
115 pub api_key: String,
116 pub username: String,
117 #[serde(default)]
118 pub team: Option<String>,
119 pub enrolled_at: String,
120}
121
122const MAX_RETRIES: u32 = 3;
123const INITIAL_BACKOFF_MS: u64 = 500;
124const API_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
126
127impl ServerClient {
128 pub fn new(base_url: &str, api_key: Option<&str>, device_id: &str) -> Self {
129 Self {
130 base_url: base_url.trim_end_matches('/').to_string(),
131 api_key: api_key.map(String::from),
132 device_id: device_id.to_string(),
133 }
134 }
135
136 fn agent(&self) -> ureq::Agent {
137 ureq::AgentBuilder::new().timeout(API_TIMEOUT).build()
138 }
139
140 fn build_request(&self, method: &str, path: &str) -> ureq::Request {
141 let url = format!("{}{}", self.base_url, path);
142 let agent = self.agent();
143 let mut req = match method {
144 "POST" => agent.post(&url),
145 _ => agent.get(&url),
146 };
147
148 if let Some(ref key) = self.api_key {
149 req = req.set("Authorization", &format!("Bearer {}", key));
150 }
151
152 req
153 }
154
155 fn post_with_retry(&self, path: &str, body_json: &str) -> std::result::Result<String, String> {
157 let mut last_err = String::new();
158 for attempt in 0..MAX_RETRIES {
159 if attempt > 0 {
160 let backoff =
161 std::time::Duration::from_millis(INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1));
162 std::thread::sleep(backoff);
163 }
164
165 match self
166 .build_request("POST", path)
167 .set("Content-Type", "application/json")
168 .send_string(body_json)
169 {
170 Ok(resp) => match resp.into_string() {
171 Ok(body) => return Ok(body),
172 Err(e) => {
173 last_err = format!("failed to read response: {}", e);
174 }
175 },
176 Err(ureq::Error::Transport(e)) => {
177 last_err = format!("network error: {}", e);
178 tracing::debug!(
179 attempt = attempt + 1,
180 max = MAX_RETRIES,
181 error = %e,
182 "Request failed, retrying"
183 );
184 }
185 Err(ureq::Error::Status(code, _)) if code >= 500 => {
186 last_err = format!("server error (HTTP {})", code);
187 tracing::debug!(
188 attempt = attempt + 1,
189 max = MAX_RETRIES,
190 code,
191 "Server error, retrying"
192 );
193 }
194 Err(e) => {
195 return Err(format!("request error: {}", e));
196 }
197 }
198 }
199 Err(format!(
200 "failed after {} attempts: {}",
201 MAX_RETRIES, last_err
202 ))
203 }
204
205 pub fn checkin(
207 &self,
208 config_hash: &str,
209 compliance_summary: Option<ComplianceSummary>,
210 printer: &Printer,
211 ) -> Result<CheckinResponse> {
212 let hostname = crate::hostname_string();
213
214 let body = CheckinRequest {
215 device_id: self.device_id.clone(),
216 hostname,
217 os: std::env::consts::OS.to_string(),
218 arch: std::env::consts::ARCH.to_string(),
219 config_hash: config_hash.to_string(),
220 compliance_summary,
221 };
222
223 let body_json = serde_json::to_string(&body).map_err(|e| {
224 CfgdError::Io(std::io::Error::other(format!(
225 "failed to serialize checkin request: {}",
226 e
227 )))
228 })?;
229
230 printer.info("Checking in with device gateway");
231
232 let response_body = self
233 .post_with_retry("/api/v1/checkin", &body_json)
234 .map_err(|e| {
235 CfgdError::Io(std::io::Error::new(
236 std::io::ErrorKind::ConnectionRefused,
237 format!("device gateway checkin failed: {}", e),
238 ))
239 })?;
240
241 serde_json::from_str(&response_body).map_err(|e| {
242 CfgdError::Io(std::io::Error::new(
243 std::io::ErrorKind::InvalidData,
244 format!("invalid checkin response: {}", e),
245 ))
246 })
247 }
248
249 pub fn report_drift(&self, drifts: &[SystemDrift], printer: &Printer) -> Result<()> {
251 if drifts.is_empty() {
252 return Ok(());
253 }
254
255 let details: Vec<DriftDetail> = drifts
256 .iter()
257 .map(|d| DriftDetail {
258 field: d.key.clone(),
259 expected: d.expected.clone(),
260 actual: d.actual.clone(),
261 })
262 .collect();
263
264 let body = DriftReport { details };
265 let body_json = serde_json::to_string(&body).map_err(|e| {
266 CfgdError::Io(std::io::Error::other(format!(
267 "failed to serialize drift report: {}",
268 e
269 )))
270 })?;
271
272 let path = format!("/api/v1/devices/{}/drift", self.device_id);
273 printer.info(&format!(
274 "Reporting {} drift events to device gateway",
275 drifts.len()
276 ));
277
278 self.post_with_retry(&path, &body_json).map_err(|e| {
279 CfgdError::Io(std::io::Error::new(
280 std::io::ErrorKind::ConnectionRefused,
281 format!("device gateway drift report failed: {}", e),
282 ))
283 })?;
284
285 Ok(())
286 }
287
288 pub fn enroll(&self, bootstrap_token: &str, printer: &Printer) -> Result<EnrollResponse> {
291 let hostname = crate::hostname_string();
292
293 let body = EnrollRequest {
294 token: bootstrap_token.to_string(),
295 device_id: self.device_id.clone(),
296 hostname,
297 os: std::env::consts::OS.to_string(),
298 arch: std::env::consts::ARCH.to_string(),
299 };
300
301 let body_json = serde_json::to_string(&body).map_err(|e| {
302 CfgdError::Io(std::io::Error::other(format!(
303 "failed to serialize enrollment request: {}",
304 e
305 )))
306 })?;
307
308 printer.info("Enrolling device with device gateway");
309
310 let response_body = self
311 .post_with_retry("/api/v1/enroll", &body_json)
312 .map_err(|e| {
313 CfgdError::Io(std::io::Error::new(
314 std::io::ErrorKind::ConnectionRefused,
315 format!("device gateway enrollment failed: {}", e),
316 ))
317 })?;
318
319 serde_json::from_str(&response_body).map_err(|e| {
320 CfgdError::Io(std::io::Error::new(
321 std::io::ErrorKind::InvalidData,
322 format!("invalid enrollment response: {}", e),
323 ))
324 })
325 }
326
327 pub fn enroll_info(&self) -> Result<EnrollInfoResponse> {
329 let url = format!("{}/api/v1/enroll/info", self.base_url);
330 let resp = ureq::get(&url).call().map_err(|e| {
331 CfgdError::Io(std::io::Error::new(
332 std::io::ErrorKind::ConnectionRefused,
333 format!("failed to query enrollment info: {}", e),
334 ))
335 })?;
336 let body = resp.into_string().map_err(|e| {
337 CfgdError::Io(std::io::Error::other(format!(
338 "failed to read enrollment info response: {}",
339 e
340 )))
341 })?;
342 serde_json::from_str(&body).map_err(|e| {
343 CfgdError::Io(std::io::Error::new(
344 std::io::ErrorKind::InvalidData,
345 format!("invalid enrollment info response: {}", e),
346 ))
347 })
348 }
349
350 pub fn request_challenge(
352 &self,
353 username: &str,
354 printer: &Printer,
355 ) -> Result<ChallengeResponse> {
356 let hostname = crate::hostname_string();
357
358 let body = ChallengeRequest {
359 username: username.to_string(),
360 device_id: self.device_id.clone(),
361 hostname,
362 os: std::env::consts::OS.to_string(),
363 arch: std::env::consts::ARCH.to_string(),
364 };
365
366 let body_json = serde_json::to_string(&body).map_err(|e| {
367 CfgdError::Io(std::io::Error::other(format!(
368 "failed to serialize challenge request: {}",
369 e
370 )))
371 })?;
372
373 printer.info("Requesting enrollment challenge");
374
375 let response_body = self
376 .post_with_retry("/api/v1/enroll/challenge", &body_json)
377 .map_err(|e| {
378 CfgdError::Io(std::io::Error::new(
379 std::io::ErrorKind::ConnectionRefused,
380 format!("enrollment challenge request failed: {}", e),
381 ))
382 })?;
383
384 serde_json::from_str(&response_body).map_err(|e| {
385 CfgdError::Io(std::io::Error::new(
386 std::io::ErrorKind::InvalidData,
387 format!("invalid challenge response: {}", e),
388 ))
389 })
390 }
391
392 pub fn submit_verification(
394 &self,
395 challenge_id: &str,
396 signature: &str,
397 key_type: &str,
398 printer: &Printer,
399 ) -> Result<EnrollResponse> {
400 let body = VerifyRequest {
401 challenge_id: challenge_id.to_string(),
402 signature: signature.to_string(),
403 key_type: key_type.to_string(),
404 };
405
406 let body_json = serde_json::to_string(&body).map_err(|e| {
407 CfgdError::Io(std::io::Error::other(format!(
408 "failed to serialize verification request: {}",
409 e
410 )))
411 })?;
412
413 printer.info("Submitting signed challenge for verification");
414
415 let response_body = self
416 .post_with_retry("/api/v1/enroll/verify", &body_json)
417 .map_err(|e| {
418 CfgdError::Io(std::io::Error::new(
419 std::io::ErrorKind::ConnectionRefused,
420 format!("enrollment verification failed: {}", e),
421 ))
422 })?;
423
424 serde_json::from_str(&response_body).map_err(|e| {
425 CfgdError::Io(std::io::Error::new(
426 std::io::ErrorKind::InvalidData,
427 format!("invalid verification response: {}", e),
428 ))
429 })
430 }
431
432 pub fn from_credential(cred: &DeviceCredential) -> Self {
434 Self {
435 base_url: cred.server_url.trim_end_matches('/').to_string(),
436 api_key: Some(cred.api_key.clone()),
437 device_id: cred.device_id.clone(),
438 }
439 }
440}
441
442pub fn credential_path() -> Result<PathBuf> {
446 let dir = crate::state::default_state_dir()?;
447 Ok(dir.join("device-credential.json"))
448}
449
450pub fn save_credential(cred: &DeviceCredential) -> Result<PathBuf> {
452 let path = credential_path()?;
453 if let Some(parent) = path.parent() {
454 std::fs::create_dir_all(parent).map_err(|e| {
455 CfgdError::Io(std::io::Error::new(
456 e.kind(),
457 format!("failed to create credential directory: {}", e),
458 ))
459 })?;
460 crate::set_file_permissions(parent, 0o700)?;
464 }
465 let json = serde_json::to_string_pretty(cred).map_err(|e| {
466 CfgdError::Io(std::io::Error::other(format!(
467 "failed to serialize credential: {}",
468 e
469 )))
470 })?;
471 crate::atomic_write_str(&path, &json)?;
472
473 crate::set_file_permissions(&path, 0o600)?;
475
476 Ok(path)
477}
478
479pub fn load_credential() -> Result<Option<DeviceCredential>> {
481 load_credential_from(&credential_path()?)
482}
483
484pub fn load_credential_from(path: &Path) -> Result<Option<DeviceCredential>> {
486 if !path.exists() {
487 return Ok(None);
488 }
489 let contents = std::fs::read_to_string(path)?;
490 let cred: DeviceCredential = serde_json::from_str(&contents).map_err(|e| {
491 CfgdError::Io(std::io::Error::new(
492 std::io::ErrorKind::InvalidData,
493 format!("invalid device credential file: {}", e),
494 ))
495 })?;
496 Ok(Some(cred))
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use crate::test_helpers::test_printer;
503
504 #[test]
505 fn server_client_strips_trailing_slash() {
506 let client = ServerClient::new("http://localhost:8080/", None, "node-1");
507 assert_eq!(client.base_url, "http://localhost:8080");
508 }
509
510 #[test]
511 fn from_credential() {
512 let cred = DeviceCredential {
513 server_url: "https://cfgd.example.com/".to_string(),
514 device_id: "dev-1".to_string(),
515 api_key: "cfgd_dev_abc123".to_string(),
516 username: "jdoe".to_string(),
517 team: Some("acme".to_string()),
518 enrolled_at: "2026-03-10T12:00:00Z".to_string(),
519 };
520 let client = ServerClient::from_credential(&cred);
521 assert_eq!(client.base_url, "https://cfgd.example.com");
522 assert_eq!(client.api_key.as_deref(), Some("cfgd_dev_abc123"));
523 assert_eq!(client.device_id, "dev-1");
524 }
525
526 #[test]
527 fn checkin_request_without_compliance_summary() {
528 let req = CheckinRequest {
529 device_id: "dev-1".into(),
530 hostname: "ws-1".into(),
531 os: "linux".into(),
532 arch: "x86_64".into(),
533 config_hash: "abc123".into(),
534 compliance_summary: None,
535 };
536 let json = serde_json::to_string(&req).unwrap();
537 assert!(!json.contains("complianceSummary"));
538 }
539
540 #[test]
541 fn credential_save_and_load() {
542 let dir = tempfile::tempdir().unwrap();
543 let path = dir.path().join("credential.json");
544
545 let cred = DeviceCredential {
546 server_url: "https://cfgd.example.com".to_string(),
547 device_id: "test-device".to_string(),
548 api_key: "cfgd_dev_test123".to_string(),
549 username: "testuser".to_string(),
550 team: None,
551 enrolled_at: "2026-03-10T12:00:00Z".to_string(),
552 };
553
554 let json = serde_json::to_string_pretty(&cred).unwrap();
555 std::fs::write(&path, &json).unwrap();
556
557 let loaded = load_credential_from(&path).unwrap().unwrap();
558 assert_eq!(loaded.device_id, "test-device");
559 assert_eq!(loaded.api_key, "cfgd_dev_test123");
560 assert_eq!(loaded.username, "testuser");
561 }
562
563 #[test]
564 fn credential_load_missing_returns_none() {
565 let dir = tempfile::tempdir().unwrap();
566 let path = dir.path().join("nonexistent.json");
567 let loaded = load_credential_from(&path).unwrap();
568 assert!(loaded.is_none());
569 }
570
571 #[test]
572 fn load_credential_from_malformed_json() {
573 let dir = tempfile::tempdir().unwrap();
574 let path = dir.path().join("bad-credential.json");
575 std::fs::write(&path, "{ this is not valid json!!!").unwrap();
576
577 let result = load_credential_from(&path);
578 assert!(result.is_err());
579 let err_msg = format!("{}", result.unwrap_err());
580 assert!(
581 err_msg.contains("invalid device credential file"),
582 "unexpected error message: {}",
583 err_msg
584 );
585 }
586
587 #[test]
588 fn credential_file_permissions() {
589 let dir = tempfile::tempdir().unwrap();
590 let path = dir.path().join("cred.json");
591
592 let cred = DeviceCredential {
593 server_url: "https://example.com".into(),
594 device_id: "d1".into(),
595 api_key: "key".into(),
596 username: "user".into(),
597 team: Some("acme".into()),
598 enrolled_at: "2026-01-01T00:00:00Z".into(),
599 };
600
601 let json = serde_json::to_string_pretty(&cred).unwrap();
602 std::fs::write(&path, &json).unwrap();
603
604 #[cfg(unix)]
605 {
606 use std::os::unix::fs::PermissionsExt;
607 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
608 let meta = std::fs::metadata(&path).unwrap();
609 assert_eq!(meta.permissions().mode() & 0o777, 0o600);
610 }
611 }
612
613 #[test]
616 fn checkin_sends_correct_payload_and_parses_response() {
617 let mut server = mockito::Server::new();
618 let mock = server
619 .mock("POST", "/api/v1/checkin")
620 .match_header("content-type", "application/json")
621 .match_header("authorization", "Bearer test-key")
622 .with_status(200)
623 .with_body(r#"{"status":"ok","configChanged":false}"#)
624 .create();
625
626 let client = ServerClient::new(&server.url(), Some("test-key"), "dev-1");
627 let printer = test_printer();
628 let result = client.checkin("hash123", None, &printer);
629
630 assert!(result.is_ok());
631 let resp = result.unwrap();
632 assert_eq!(resp.status, "ok");
633 assert!(!resp.config_changed);
634 mock.assert();
635 }
636
637 #[test]
638 fn checkin_with_compliance_summary() {
639 let mut server = mockito::Server::new();
640 let mock = server
641 .mock("POST", "/api/v1/checkin")
642 .with_status(200)
643 .with_body(r#"{"status":"ok","configChanged":true}"#)
644 .create();
645
646 let client = ServerClient::new(&server.url(), Some("key"), "dev-1");
647 let printer = test_printer();
648 let summary = ComplianceSummary {
649 compliant: 5,
650 warning: 1,
651 violation: 0,
652 };
653 let result = client.checkin("hash", Some(summary), &printer);
654 assert!(result.is_ok());
655 assert!(result.unwrap().config_changed);
656 mock.assert();
657 }
658
659 #[test]
660 fn report_drift_sends_drift_details() {
661 let mut server = mockito::Server::new();
662 let mock = server
663 .mock("POST", "/api/v1/devices/dev-1/drift")
664 .match_header("authorization", "Bearer key-1")
665 .with_status(200)
666 .with_body("{}")
667 .create();
668
669 let client = ServerClient::new(&server.url(), Some("key-1"), "dev-1");
670 let printer = test_printer();
671 let drift = SystemDrift {
672 key: "some.key".into(),
673 expected: "foo".into(),
674 actual: "bar".into(),
675 };
676 let result = client.report_drift(&[drift], &printer);
677 assert!(result.is_ok());
678 mock.assert();
679 }
680
681 #[test]
682 fn enroll_sends_bootstrap_token() {
683 let mut server = mockito::Server::new();
684 let mock = server
685 .mock("POST", "/api/v1/enroll")
686 .with_status(200)
687 .with_body(
688 r#"{"status":"enrolled","deviceId":"new-dev","apiKey":"new-key","username":"user1"}"#,
689 )
690 .create();
691
692 let client = ServerClient::new(&server.url(), None, "dev-1");
693 let printer = test_printer();
694 let result = client.enroll("bootstrap-token-123", &printer);
695 assert!(result.is_ok());
696 let resp = result.unwrap();
697 assert_eq!(resp.device_id, "new-dev");
698 assert_eq!(resp.api_key, "new-key");
699 assert_eq!(resp.username, "user1");
700 mock.assert();
701 }
702
703 #[test]
704 fn request_challenge_sends_username() {
705 let mut server = mockito::Server::new();
706 let mock = server
707 .mock("POST", "/api/v1/enroll/challenge")
708 .with_status(200)
709 .with_body(
710 r#"{"challengeId":"ch-1","nonce":"sign-this","expiresAt":"2026-01-01T00:00:00Z"}"#,
711 )
712 .create();
713
714 let client = ServerClient::new(&server.url(), None, "dev-1");
715 let printer = test_printer();
716 let result = client.request_challenge("testuser", &printer);
717 assert!(result.is_ok());
718 let resp = result.unwrap();
719 assert_eq!(resp.challenge_id, "ch-1");
720 assert_eq!(resp.nonce, "sign-this");
721 mock.assert();
722 }
723
724 #[test]
725 fn submit_verification_returns_enroll_response() {
726 let mut server = mockito::Server::new();
727 let mock = server
728 .mock("POST", "/api/v1/enroll/verify")
729 .with_status(200)
730 .with_body(
731 r#"{"status":"verified","deviceId":"verified-dev","apiKey":"verified-key","username":"user1"}"#,
732 )
733 .create();
734
735 let client = ServerClient::new(&server.url(), None, "dev-1");
736 let printer = test_printer();
737 let result = client.submit_verification("ch-1", "sig-data", "ssh-ed25519", &printer);
738 assert!(result.is_ok());
739 let resp = result.unwrap();
740 assert_eq!(resp.device_id, "verified-dev");
741 assert_eq!(resp.api_key, "verified-key");
742 mock.assert();
743 }
744
745 #[test]
746 fn enroll_info_returns_enrollment_details() {
747 let mut server = mockito::Server::new();
748 let mock = server
749 .mock("GET", "/api/v1/enroll/info")
750 .with_status(200)
751 .with_body(r#"{"method":"key"}"#)
752 .create();
753
754 let client = ServerClient::new(&server.url(), None, "dev-1");
755 let result = client.enroll_info();
756 assert!(result.is_ok());
757 let resp = result.unwrap();
758 assert_eq!(resp.method, "key");
759 mock.assert();
760 }
761
762 #[test]
763 fn checkin_server_error_returns_error() {
764 let mut server = mockito::Server::new();
765 let mock = server
766 .mock("POST", "/api/v1/checkin")
767 .with_status(500)
768 .with_body("internal error")
769 .expect_at_least(2)
770 .create();
771
772 let client = ServerClient::new(&server.url(), Some("key"), "dev-1");
773 let printer = test_printer();
774 let result = client.checkin("hash", None, &printer);
775 assert!(result.is_err());
776 mock.assert();
777 }
778
779 #[test]
780 fn checkin_client_error_does_not_retry() {
781 let mut server = mockito::Server::new();
782 let mock = server
783 .mock("POST", "/api/v1/checkin")
784 .with_status(401)
785 .with_body("unauthorized")
786 .expect(1)
787 .create();
788
789 let client = ServerClient::new(&server.url(), Some("bad-key"), "dev-1");
790 let printer = test_printer();
791 let result = client.checkin("hash", None, &printer);
792 assert!(result.is_err());
793 mock.assert();
794 }
795
796 #[test]
797 fn report_drift_empty_drifts_is_noop() {
798 let client = ServerClient::new("http://127.0.0.1:1", None, "dev-1");
801 let printer = test_printer();
802 let result = client.report_drift(&[], &printer);
803 assert!(result.is_ok(), "empty drifts should short-circuit to Ok");
804 }
805
806 #[test]
807 fn report_drift_multiple_drifts() {
808 let mut server = mockito::Server::new();
809 let mock = server
810 .mock("POST", "/api/v1/devices/dev-1/drift")
811 .with_status(200)
812 .with_body("{}")
813 .create();
814
815 let client = ServerClient::new(&server.url(), Some("key"), "dev-1");
816 let printer = test_printer();
817 let drifts = vec![
818 SystemDrift {
819 key: "file.zshrc".into(),
820 expected: "abc".into(),
821 actual: "xyz".into(),
822 },
823 SystemDrift {
824 key: "pkg.curl".into(),
825 expected: "installed".into(),
826 actual: "missing".into(),
827 },
828 ];
829 let result = client.report_drift(&drifts, &printer);
830 assert!(result.is_ok());
831 mock.assert();
832 }
833
834 #[test]
835 fn enroll_info_connection_refused() {
836 let client = ServerClient::new("http://127.0.0.1:1", None, "dev-1");
838 let result = client.enroll_info();
839 assert!(result.is_err());
840 let err_msg = format!("{}", result.unwrap_err());
841 assert!(
842 err_msg.contains("failed to query enrollment info"),
843 "unexpected error: {}",
844 err_msg
845 );
846 }
847
848 #[test]
849 fn enroll_info_invalid_json_response() {
850 let mut server = mockito::Server::new();
851 let mock = server
852 .mock("GET", "/api/v1/enroll/info")
853 .with_status(200)
854 .with_body("not valid json")
855 .create();
856
857 let client = ServerClient::new(&server.url(), None, "dev-1");
858 let result = client.enroll_info();
859 assert!(result.is_err());
860 let err_msg = format!("{}", result.unwrap_err());
861 assert!(
862 err_msg.contains("invalid enrollment info response"),
863 "unexpected error: {}",
864 err_msg
865 );
866 mock.assert();
867 }
868
869 #[test]
870 fn checkin_invalid_json_response() {
871 let mut server = mockito::Server::new();
872 let mock = server
873 .mock("POST", "/api/v1/checkin")
874 .with_status(200)
875 .with_body("not json at all")
876 .create();
877
878 let client = ServerClient::new(&server.url(), Some("key"), "dev-1");
879 let printer = test_printer();
880 let result = client.checkin("hash", None, &printer);
881 assert!(result.is_err());
882 let err_msg = format!("{}", result.unwrap_err());
883 assert!(
884 err_msg.contains("invalid checkin response"),
885 "unexpected error: {}",
886 err_msg
887 );
888 mock.assert();
889 }
890
891 #[test]
892 fn enroll_invalid_json_response() {
893 let mut server = mockito::Server::new();
894 let mock = server
895 .mock("POST", "/api/v1/enroll")
896 .with_status(200)
897 .with_body("bad json")
898 .create();
899
900 let client = ServerClient::new(&server.url(), None, "dev-1");
901 let printer = test_printer();
902 let result = client.enroll("token", &printer);
903 assert!(result.is_err());
904 let err_msg = format!("{}", result.unwrap_err());
905 assert!(
906 err_msg.contains("invalid enrollment response"),
907 "unexpected error: {}",
908 err_msg
909 );
910 mock.assert();
911 }
912
913 #[test]
914 fn request_challenge_invalid_json_response() {
915 let mut server = mockito::Server::new();
916 let mock = server
917 .mock("POST", "/api/v1/enroll/challenge")
918 .with_status(200)
919 .with_body("bad json")
920 .create();
921
922 let client = ServerClient::new(&server.url(), None, "dev-1");
923 let printer = test_printer();
924 let result = client.request_challenge("user", &printer);
925 assert!(result.is_err());
926 let err_msg = format!("{}", result.unwrap_err());
927 assert!(
928 err_msg.contains("invalid challenge response"),
929 "unexpected error: {}",
930 err_msg
931 );
932 mock.assert();
933 }
934
935 #[test]
936 fn submit_verification_invalid_json_response() {
937 let mut server = mockito::Server::new();
938 let mock = server
939 .mock("POST", "/api/v1/enroll/verify")
940 .with_status(200)
941 .with_body("not json")
942 .create();
943
944 let client = ServerClient::new(&server.url(), None, "dev-1");
945 let printer = test_printer();
946 let result = client.submit_verification("ch-1", "sig", "ssh-ed25519", &printer);
947 assert!(result.is_err());
948 let err_msg = format!("{}", result.unwrap_err());
949 assert!(
950 err_msg.contains("invalid verification response"),
951 "unexpected error: {}",
952 err_msg
953 );
954 mock.assert();
955 }
956
957 #[test]
958 fn report_drift_connection_refused() {
959 let client = ServerClient::new("http://127.0.0.1:1", Some("key"), "dev-1");
960 let printer = test_printer();
961 let drifts = vec![SystemDrift {
962 key: "test.key".into(),
963 expected: "a".into(),
964 actual: "b".into(),
965 }];
966 let result = client.report_drift(&drifts, &printer);
967 assert!(result.is_err());
968 let err_msg = format!("{}", result.unwrap_err());
969 assert!(
970 err_msg.contains("drift report failed"),
971 "unexpected error: {}",
972 err_msg
973 );
974 }
975
976 #[test]
977 fn checkin_with_desired_config_in_response() {
978 let mut server = mockito::Server::new();
979 let mock = server
980 .mock("POST", "/api/v1/checkin")
981 .with_status(200)
982 .with_body(
983 r#"{"status":"ok","configChanged":true,"desiredConfig":{"packages":["git"]}}"#,
984 )
985 .create();
986
987 let client = ServerClient::new(&server.url(), Some("key"), "dev-1");
988 let printer = test_printer();
989 let result = client.checkin("hash", None, &printer).unwrap();
990 assert!(result.config_changed);
991 assert!(result.desired_config.is_some());
992 mock.assert();
993 }
994
995 #[test]
996 fn credential_team_field_optional() {
997 let cred = DeviceCredential {
998 server_url: "https://example.com".into(),
999 device_id: "d1".into(),
1000 api_key: "key".into(),
1001 username: "user".into(),
1002 team: None,
1003 enrolled_at: "2026-01-01T00:00:00Z".into(),
1004 };
1005 let json = serde_json::to_string(&cred).unwrap();
1006 let loaded: DeviceCredential = serde_json::from_str(&json).unwrap();
1007 assert!(loaded.team.is_none());
1008 }
1009
1010 #[test]
1011 fn credential_round_trip_with_team() {
1012 let cred = DeviceCredential {
1013 server_url: "https://example.com".into(),
1014 device_id: "d1".into(),
1015 api_key: "key".into(),
1016 username: "user".into(),
1017 team: Some("engineering".into()),
1018 enrolled_at: "2026-04-01T00:00:00Z".into(),
1019 };
1020 let json = serde_json::to_string_pretty(&cred).unwrap();
1021 let loaded: DeviceCredential = serde_json::from_str(&json).unwrap();
1022 assert_eq!(loaded.team.as_deref(), Some("engineering"));
1023 assert_eq!(loaded.enrolled_at, "2026-04-01T00:00:00Z");
1024 }
1025
1026 #[test]
1027 fn server_client_new_without_api_key() {
1028 let client = ServerClient::new("http://localhost:8080", None, "node-1");
1029 assert!(client.api_key.is_none());
1030 assert_eq!(client.device_id, "node-1");
1031 }
1032
1033 #[test]
1034 fn server_client_new_with_api_key() {
1035 let client = ServerClient::new("http://localhost:8080", Some("secret"), "node-2");
1036 assert_eq!(client.api_key.as_deref(), Some("secret"));
1037 assert_eq!(client.device_id, "node-2");
1038 }
1039
1040 #[test]
1041 fn enroll_response_optional_fields() {
1042 let json = r#"{"status":"enrolled","deviceId":"dev-1","apiKey":"key-1","username":"user1","team":"ops","desiredConfig":{"foo":"bar"}}"#;
1043 let resp: EnrollResponse = serde_json::from_str(json).unwrap();
1044 assert_eq!(resp.team.as_deref(), Some("ops"));
1045 assert!(resp.desired_config.is_some());
1046 }
1047
1048 #[test]
1049 fn enroll_response_minimal() {
1050 let json =
1051 r#"{"status":"enrolled","deviceId":"dev-1","apiKey":"key-1","username":"user1"}"#;
1052 let resp: EnrollResponse = serde_json::from_str(json).unwrap();
1053 assert!(resp.team.is_none());
1054 assert!(resp.desired_config.is_none());
1055 }
1056
1057 #[test]
1058 fn checkin_no_api_key_omits_auth_header() {
1059 let mut server = mockito::Server::new();
1060 let mock = server
1061 .mock("POST", "/api/v1/checkin")
1062 .match_header("authorization", mockito::Matcher::Missing)
1063 .with_status(200)
1064 .with_body(r#"{"status":"ok","configChanged":false}"#)
1065 .create();
1066
1067 let client = ServerClient::new(&server.url(), None, "dev-1");
1068 let printer = test_printer();
1069 let result = client.checkin("hash", None, &printer);
1070 assert!(result.is_ok());
1071 mock.assert();
1072 }
1073}