Skip to main content

uptrakit_web_api_types/
hosts.rs

1use crate::host_tags::HostTagSummary;
2use crate::services::ServiceStatus;
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7pub use super::agents::MessageResponse as HostMessageResponse;
8
9#[derive(Debug, Serialize, Deserialize)]
10#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
11pub struct HostResponse {
12    pub id: Uuid,
13    pub machine_id: String,
14    pub hostname: String,
15    pub friendly_name: String,
16    pub os_type: Option<String>,
17    pub os_version: Option<String>,
18    pub architecture: Option<String>,
19    pub ip_address: Option<String>,
20    #[serde(with = "time::serde::rfc3339::option")]
21    #[cfg_attr(
22        feature = "openapi",
23        schema(value_type = Option<String>, format = DateTime)
24    )]
25    pub last_seen_at: Option<OffsetDateTime>,
26    #[serde(with = "time::serde::rfc3339")]
27    #[cfg_attr(
28        feature = "openapi",
29        schema(value_type = String, format = DateTime)
30    )]
31    pub created_at: OffsetDateTime,
32    #[serde(with = "time::serde::rfc3339")]
33    #[cfg_attr(
34        feature = "openapi",
35        schema(value_type = String, format = DateTime)
36    )]
37    pub updated_at: OffsetDateTime,
38    pub agents: Vec<HostAgentSummary>,
39    /// Tags assigned to this host.
40    #[serde(default)]
41    pub tags: Vec<HostTagSummary>,
42    /// Agent-reported host features. Empty if not reported (legacy agent).
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub features: Vec<String>,
45    /// Aggregate software status for host-list rendering.
46    #[serde(default)]
47    pub software_status: HostSoftwareStatusSummary,
48}
49
50#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
51#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
52pub struct HostSoftwareStatusSummary {
53    pub known: bool,
54    pub update_count: u32,
55    pub error_count: u32,
56}
57
58#[derive(Debug, Serialize, Deserialize)]
59#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
60pub struct HostAgentSummary {
61    pub id: Uuid,
62    pub friendly_name: String,
63    pub status: ServiceStatus,
64}
65
66#[derive(Serialize, Deserialize)]
67#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
68pub struct UpdateHostRequest {
69    pub friendly_name: Option<String>,
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use time::macros::datetime;
76
77    fn sample_uuid() -> Uuid {
78        Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6")
79            .expect("hard-coded UUID should be valid")
80    }
81
82    fn sample_agent_uuid() -> Uuid {
83        Uuid::parse_str("b1b2b3b4-c1c2-d1d2-e1e2-f1f2f3f4f5f6")
84            .expect("hard-coded UUID should be valid")
85    }
86
87    fn sample_software_status() -> HostSoftwareStatusSummary {
88        HostSoftwareStatusSummary {
89            known: true,
90            update_count: 2,
91            error_count: 1,
92        }
93    }
94
95    // ── HostAgentSummary ─────────────────────────────────────────────
96
97    #[test]
98    fn host_agent_summary_round_trip() {
99        let summary = HostAgentSummary {
100            id: sample_agent_uuid(),
101            friendly_name: "agent-1".to_string(),
102            status: ServiceStatus::Approved,
103        };
104        let json = serde_json::to_string(&summary).expect("serialization should succeed");
105        let deserialized: HostAgentSummary =
106            serde_json::from_str(&json).expect("deserialization should succeed");
107        assert_eq!(deserialized.id, sample_agent_uuid());
108        assert_eq!(deserialized.friendly_name, "agent-1");
109        assert_eq!(deserialized.status, ServiceStatus::Approved);
110    }
111
112    #[test]
113    fn host_agent_summary_pending_status() {
114        let summary = HostAgentSummary {
115            id: sample_agent_uuid(),
116            friendly_name: "new-agent".to_string(),
117            status: ServiceStatus::Pending,
118        };
119        let json_value =
120            serde_json::to_value(&summary).expect("serialization to Value should succeed");
121        assert_eq!(
122            json_value.get("status").and_then(|v| v.as_str()),
123            Some("pending")
124        );
125    }
126
127    // ── HostResponse ─────────────────────────────────────────────────
128
129    #[test]
130    fn host_response_round_trip_all_fields() {
131        let resp = HostResponse {
132            id: sample_uuid(),
133            machine_id: "machine-001".to_string(),
134            hostname: "server-1.local".to_string(),
135            friendly_name: "Production Server".to_string(),
136            os_type: Some("linux".to_string()),
137            os_version: Some("Ubuntu 22.04".to_string()),
138            architecture: Some("x86_64".to_string()),
139            ip_address: Some("192.168.1.100".to_string()),
140            last_seen_at: Some(datetime!(2025-06-01 12:00:00 UTC)),
141            created_at: datetime!(2025-01-01 0:00:00 UTC),
142            updated_at: datetime!(2025-06-01 12:00:00 UTC),
143            agents: vec![HostAgentSummary {
144                id: sample_agent_uuid(),
145                friendly_name: "agent-1".to_string(),
146                status: ServiceStatus::Approved,
147            }],
148            tags: vec![],
149            features: vec![],
150            software_status: sample_software_status(),
151        };
152        let json = serde_json::to_string(&resp).expect("serialization should succeed");
153        let deserialized: HostResponse =
154            serde_json::from_str(&json).expect("deserialization should succeed");
155        assert_eq!(deserialized.id, sample_uuid());
156        assert_eq!(deserialized.machine_id, "machine-001");
157        assert_eq!(deserialized.hostname, "server-1.local");
158        assert_eq!(deserialized.friendly_name, "Production Server");
159        assert_eq!(deserialized.os_type.as_deref(), Some("linux"));
160        assert_eq!(deserialized.os_version.as_deref(), Some("Ubuntu 22.04"));
161        assert_eq!(deserialized.architecture.as_deref(), Some("x86_64"));
162        assert_eq!(deserialized.ip_address.as_deref(), Some("192.168.1.100"));
163        assert!(deserialized.last_seen_at.is_some());
164        assert_eq!(deserialized.agents.len(), 1);
165        assert_eq!(deserialized.agents[0].status, ServiceStatus::Approved);
166        assert_eq!(deserialized.software_status, sample_software_status());
167    }
168
169    #[test]
170    fn host_response_round_trip_none_fields() {
171        let resp = HostResponse {
172            id: sample_uuid(),
173            machine_id: "machine-002".to_string(),
174            hostname: "unknown-host".to_string(),
175            friendly_name: "New Host".to_string(),
176            os_type: None,
177            os_version: None,
178            architecture: None,
179            ip_address: None,
180            last_seen_at: None,
181            created_at: datetime!(2025-01-01 0:00:00 UTC),
182            updated_at: datetime!(2025-01-01 0:00:00 UTC),
183            agents: vec![],
184            tags: vec![],
185            features: vec![],
186            software_status: sample_software_status(),
187        };
188        let json = serde_json::to_string(&resp).expect("serialization should succeed");
189        let deserialized: HostResponse =
190            serde_json::from_str(&json).expect("deserialization should succeed");
191        assert!(deserialized.os_type.is_none());
192        assert!(deserialized.os_version.is_none());
193        assert!(deserialized.architecture.is_none());
194        assert!(deserialized.ip_address.is_none());
195        assert!(deserialized.last_seen_at.is_none());
196        assert!(deserialized.agents.is_empty());
197        assert_eq!(deserialized.software_status, sample_software_status());
198    }
199
200    #[test]
201    fn host_response_none_fields_serialize_as_null() {
202        let resp = HostResponse {
203            id: sample_uuid(),
204            machine_id: "m".to_string(),
205            hostname: "h".to_string(),
206            friendly_name: "f".to_string(),
207            os_type: None,
208            os_version: None,
209            architecture: None,
210            ip_address: None,
211            last_seen_at: None,
212            created_at: datetime!(2025-01-01 0:00:00 UTC),
213            updated_at: datetime!(2025-01-01 0:00:00 UTC),
214            agents: vec![],
215            tags: vec![],
216            features: vec![],
217            software_status: sample_software_status(),
218        };
219        let json_value =
220            serde_json::to_value(&resp).expect("serialization to Value should succeed");
221        let obj = json_value
222            .as_object()
223            .expect("top-level value should be an object");
224        for field in [
225            "os_type",
226            "os_version",
227            "architecture",
228            "ip_address",
229            "last_seen_at",
230        ] {
231            assert!(
232                obj.get(field).expect("field should be present").is_null(),
233                "{field} should serialize as null when None"
234            );
235        }
236    }
237
238    #[test]
239    fn host_response_multiple_agents_different_statuses() {
240        let resp = HostResponse {
241            id: sample_uuid(),
242            machine_id: "m".to_string(),
243            hostname: "h".to_string(),
244            friendly_name: "f".to_string(),
245            os_type: None,
246            os_version: None,
247            architecture: None,
248            ip_address: None,
249            last_seen_at: None,
250            created_at: datetime!(2025-01-01 0:00:00 UTC),
251            updated_at: datetime!(2025-01-01 0:00:00 UTC),
252            agents: vec![
253                HostAgentSummary {
254                    id: sample_agent_uuid(),
255                    friendly_name: "approved-agent".to_string(),
256                    status: ServiceStatus::Approved,
257                },
258                HostAgentSummary {
259                    id: sample_uuid(),
260                    friendly_name: "pending-agent".to_string(),
261                    status: ServiceStatus::Pending,
262                },
263            ],
264            tags: vec![],
265            features: vec![],
266            software_status: sample_software_status(),
267        };
268        let json = serde_json::to_string(&resp).expect("serialization should succeed");
269        let deserialized: HostResponse =
270            serde_json::from_str(&json).expect("deserialization should succeed");
271        assert_eq!(deserialized.agents.len(), 2);
272        assert_eq!(deserialized.agents[0].status, ServiceStatus::Approved);
273        assert_eq!(deserialized.agents[1].status, ServiceStatus::Pending);
274    }
275
276    #[test]
277    fn host_response_deserializes_missing_software_status_to_default() {
278        let json = serde_json::json!({
279            "id": sample_uuid(),
280            "machine_id": "machine-001",
281            "hostname": "server-1.local",
282            "friendly_name": "Production Server",
283            "os_type": null,
284            "os_version": null,
285            "architecture": null,
286            "ip_address": null,
287            "last_seen_at": null,
288            "created_at": "2025-01-01T00:00:00Z",
289            "updated_at": "2025-01-01T00:00:00Z",
290            "agents": [],
291            "tags": [],
292            "features": []
293        });
294
295        let deserialized: HostResponse =
296            serde_json::from_value(json).expect("deserialization should succeed");
297        assert_eq!(
298            deserialized.software_status,
299            HostSoftwareStatusSummary::default()
300        );
301    }
302
303    // ── UpdateHostRequest ────────────────────────────────────────────
304
305    #[test]
306    fn update_host_request_round_trip_with_name() {
307        let req = UpdateHostRequest {
308            friendly_name: Some("New Name".to_string()),
309        };
310        let json = serde_json::to_string(&req).expect("serialization should succeed");
311        let deserialized: UpdateHostRequest =
312            serde_json::from_str(&json).expect("deserialization should succeed");
313        assert_eq!(deserialized.friendly_name.as_deref(), Some("New Name"));
314    }
315
316    #[test]
317    fn update_host_request_round_trip_none() {
318        let req = UpdateHostRequest {
319            friendly_name: None,
320        };
321        let json = serde_json::to_string(&req).expect("serialization should succeed");
322        let deserialized: UpdateHostRequest =
323            serde_json::from_str(&json).expect("deserialization should succeed");
324        assert!(deserialized.friendly_name.is_none());
325    }
326
327    #[test]
328    fn update_host_request_from_empty_json_object() {
329        let json = r#"{}"#;
330        let req: UpdateHostRequest =
331            serde_json::from_str(json).expect("deserialization should succeed");
332        assert!(req.friendly_name.is_none());
333    }
334}