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 #[serde(default)]
42 pub tags: Vec<HostTagSummary>,
43 #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 pub features: Vec<String>,
46 #[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 #[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 #[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 #[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}