Skip to main content

cfgd_core/
server_client.rs

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
10/// Client for communicating with the device gateway.
11pub 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// --- Key-based enrollment types ---
76
77#[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/// Stored device credential — saved locally after enrollment.
110#[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;
124/// Timeout for API calls (checkin, drift reports, enrollment) — short because these are small JSON payloads.
125const 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    /// Send a POST request with exponential backoff on network failures.
156    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    /// Check in with the device gateway, reporting current config hash and optional compliance summary.
206    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    /// Report drift events to the device gateway.
250    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    /// Enroll this device with the device gateway using a bootstrap token.
289    /// Returns the enrollment response including the permanent device API key.
290    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    /// Query the server's enrollment method (token or key).
328    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    /// Request an enrollment challenge from the server (key-based enrollment).
351    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    /// Submit a signed challenge for verification (key-based enrollment).
393    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    /// Create a client from a stored device credential.
433    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
442// --- Credential Storage ---
443
444/// Path to the device credential file: `~/.local/share/cfgd/device-credential.json`
445pub fn credential_path() -> Result<PathBuf> {
446    let dir = crate::state::default_state_dir()?;
447    Ok(dir.join("device-credential.json"))
448}
449
450/// Save a device credential to disk after enrollment.
451pub 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        // Restrict parent directory to owner-only access — even if the credential file
461        // briefly has permissive permissions during atomic_write, the directory ACL
462        // prevents other users from accessing it.
463        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    // Restrict file permissions (no-op on Windows)
474    crate::set_file_permissions(&path, 0o600)?;
475
476    Ok(path)
477}
478
479/// Load a previously stored device credential.
480pub fn load_credential() -> Result<Option<DeviceCredential>> {
481    load_credential_from(&credential_path()?)
482}
483
484/// Load a device credential from a specific path.
485pub 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    // --- mockito-based HTTP integration tests ---
614
615    #[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        // With no mock server set up, an empty drifts list should return Ok(())
799        // immediately without making any HTTP request
800        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        // Connect to a port that isn't listening
837        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}