Skip to main content

uptrakit_web_api_types/
hosts.rs

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