1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5use crate::validation::{Validate, ValidationError};
6
7pub use uptrakit_shared_types::{ParseServiceStatusError, ServiceStatus};
9
10#[derive(Debug, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13pub struct ServiceResponse {
14 pub id: Uuid,
15 pub capabilities: Vec<String>,
16 pub service_label: String,
17 pub hostname: String,
18 pub friendly_name: String,
19 pub is_embedded: bool,
20 pub ip_address: Option<String>,
21 pub status: ServiceStatus,
22 pub client_version: Option<String>,
23 #[serde(with = "time::serde::rfc3339::option")]
24 #[cfg_attr(
25 feature = "openapi",
26 schema(value_type = Option<String>, format = DateTime)
27 )]
28 pub last_seen_at: Option<OffsetDateTime>,
29 #[serde(with = "time::serde::rfc3339")]
30 #[cfg_attr(
31 feature = "openapi",
32 schema(value_type = String, format = DateTime)
33 )]
34 pub created_at: OffsetDateTime,
35 #[serde(with = "time::serde::rfc3339")]
36 #[cfg_attr(
37 feature = "openapi",
38 schema(value_type = String, format = DateTime)
39 )]
40 pub updated_at: OffsetDateTime,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub ping_interval_seconds: Option<u32>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub cert_lifetime_hours: Option<u32>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub yielded_to: Option<Vec<Uuid>>,
52}
53
54#[derive(Serialize, Deserialize)]
56#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))]
57pub struct ListServicesQuery {
58 pub capability: Option<String>,
60 pub status: Option<ServiceStatus>,
62 pub page: Option<u64>,
64 pub per_page: Option<u64>,
66}
67
68impl ListServicesQuery {
69 pub fn pagination(&self) -> crate::pagination::PaginationParams {
70 crate::pagination::PaginationParams {
71 page: self.page,
72 per_page: self.per_page,
73 }
74 }
75}
76
77#[derive(Serialize, Deserialize)]
79#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
80pub struct UpdateServiceRequest {
81 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub ping_interval_seconds: Option<u32>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub cert_lifetime_hours: Option<u32>,
92}
93
94impl Validate for UpdateServiceRequest {
95 fn validate(&self) -> Result<(), ValidationError> {
96 if let Some(interval) = self.ping_interval_seconds
99 && interval != 0
100 && interval < 5
101 {
102 return Err(ValidationError {
103 field: "ping_interval_seconds",
104 message: "ping_interval_seconds must be 0 (to clear) or at least 5".to_string(),
105 });
106 }
107 if let Some(hours) = self.cert_lifetime_hours
108 && hours != 0
109 && !(1..=17_520u32).contains(&hours)
110 {
111 return Err(ValidationError {
112 field: "cert_lifetime_hours",
113 message: "cert_lifetime_hours must be 0 (to clear) or between 1 and 17520"
114 .to_string(),
115 });
116 }
117 Ok(())
118 }
119}
120
121#[derive(Serialize, Deserialize)]
123#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
124pub struct SetUpdateFreezeRequest {
125 pub enabled: bool,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub reason: Option<String>,
130}
131
132impl Validate for SetUpdateFreezeRequest {
133 fn validate(&self) -> Result<(), ValidationError> {
134 if let Some(ref reason) = self.reason
135 && reason.len() > 1024
136 {
137 return Err(ValidationError {
138 field: "reason",
139 message: "reason must be at most 1024 characters".to_string(),
140 });
141 }
142 Ok(())
143 }
144}
145
146pub use super::agents::{MergeAgentRequest, MessageResponse};
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use time::macros::datetime;
153
154 fn sample_uuid() -> Uuid {
155 Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6")
156 .expect("hard-coded UUID should be valid")
157 }
158
159 #[test]
162 fn service_response_round_trip_all_fields() {
163 let resp = ServiceResponse {
164 id: sample_uuid(),
165 capabilities: vec![
166 "software_discovery".into(),
167 "update_hooks".into(),
168 "graceful_shutdown".into(),
169 ],
170 service_label: "Agent".into(),
171 hostname: "host-1.local".to_string(),
172 friendly_name: "My Agent".to_string(),
173 is_embedded: false,
174 ip_address: Some("10.0.0.1".to_string()),
175 status: ServiceStatus::Approved,
176 client_version: Some("1.2.3".to_string()),
177 last_seen_at: Some(datetime!(2025-06-01 12:00:00 UTC)),
178 created_at: datetime!(2025-01-01 0:00:00 UTC),
179 updated_at: datetime!(2025-06-01 12:00:00 UTC),
180 ping_interval_seconds: Some(60),
181 cert_lifetime_hours: None,
182 yielded_to: None,
183 };
184 let json = serde_json::to_string(&resp).expect("serialization should succeed");
185 let deserialized: ServiceResponse =
186 serde_json::from_str(&json).expect("deserialization should succeed");
187 assert_eq!(deserialized.id, sample_uuid());
188 assert_eq!(
189 deserialized.capabilities,
190 vec!["software_discovery", "update_hooks", "graceful_shutdown"]
191 );
192 assert_eq!(deserialized.service_label, "Agent");
193 assert_eq!(deserialized.hostname, "host-1.local");
194 assert_eq!(deserialized.friendly_name, "My Agent");
195 assert!(!deserialized.is_embedded);
196 assert_eq!(deserialized.ip_address.as_deref(), Some("10.0.0.1"));
197 assert_eq!(deserialized.status, ServiceStatus::Approved);
198 assert_eq!(deserialized.client_version.as_deref(), Some("1.2.3"));
199 assert!(deserialized.last_seen_at.is_some());
200 assert_eq!(deserialized.ping_interval_seconds, Some(60));
201 }
202
203 #[test]
204 fn service_response_round_trip_none_fields() {
205 let resp = ServiceResponse {
206 id: sample_uuid(),
207 capabilities: vec!["update_tracking".into(), "graceful_shutdown".into()],
208 service_label: "Update Tracker".into(),
209 hostname: "mqtt-broker".to_string(),
210 friendly_name: "MQTT Service".to_string(),
211 is_embedded: false,
212 ip_address: None,
213 status: ServiceStatus::Pending,
214 client_version: None,
215 last_seen_at: None,
216 created_at: datetime!(2025-01-01 0:00:00 UTC),
217 updated_at: datetime!(2025-01-01 0:00:00 UTC),
218 ping_interval_seconds: None,
219 cert_lifetime_hours: None,
220 yielded_to: None,
221 };
222 let json = serde_json::to_string(&resp).expect("serialization should succeed");
223 let deserialized: ServiceResponse =
224 serde_json::from_str(&json).expect("deserialization should succeed");
225 assert!(deserialized.ip_address.is_none());
226 assert!(deserialized.client_version.is_none());
227 assert!(deserialized.last_seen_at.is_none());
228 assert_eq!(deserialized.status, ServiceStatus::Pending);
229 assert!(deserialized.ping_interval_seconds.is_none());
230 }
231
232 #[test]
233 fn service_response_ssh_agent_type() {
234 let resp = ServiceResponse {
235 id: sample_uuid(),
236 capabilities: vec![
237 "ssh_remote".into(),
238 "software_discovery".into(),
239 "update_hooks".into(),
240 "graceful_shutdown".into(),
241 ],
242 service_label: "SSH Agent".into(),
243 hostname: "ssh-host".to_string(),
244 friendly_name: "SSH Agent".to_string(),
245 is_embedded: false,
246 ip_address: None,
247 status: ServiceStatus::Deactivated,
248 client_version: 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 ping_interval_seconds: None,
253 cert_lifetime_hours: None,
254 yielded_to: None,
255 };
256 let json_value =
257 serde_json::to_value(&resp).expect("serialization to Value should succeed");
258 assert!(json_value.get("capabilities").is_some());
259 assert_eq!(
260 json_value.get("service_label").and_then(|v| v.as_str()),
261 Some("SSH Agent")
262 );
263 assert_eq!(
264 json_value.get("status").and_then(|v| v.as_str()),
265 Some("deactivated")
266 );
267 }
268
269 #[test]
272 fn list_services_query_round_trip_all_fields() {
273 let query = ListServicesQuery {
274 capability: Some("software_discovery".into()),
275 status: Some(ServiceStatus::Approved),
276 page: Some(2),
277 per_page: Some(50),
278 };
279 let json = serde_json::to_string(&query).expect("serialization should succeed");
280 let deserialized: ListServicesQuery =
281 serde_json::from_str(&json).expect("deserialization should succeed");
282 assert_eq!(
283 deserialized.capability.as_deref(),
284 Some("software_discovery")
285 );
286 assert_eq!(deserialized.status, Some(ServiceStatus::Approved));
287 assert_eq!(deserialized.page, Some(2));
288 assert_eq!(deserialized.per_page, Some(50));
289 }
290
291 #[test]
292 fn list_services_query_round_trip_none_fields() {
293 let query = ListServicesQuery {
294 capability: None,
295 status: None,
296 page: None,
297 per_page: None,
298 };
299 let json = serde_json::to_string(&query).expect("serialization should succeed");
300 let deserialized: ListServicesQuery =
301 serde_json::from_str(&json).expect("deserialization should succeed");
302 assert!(deserialized.capability.is_none());
303 assert!(deserialized.status.is_none());
304 assert!(deserialized.page.is_none());
305 assert!(deserialized.per_page.is_none());
306 }
307
308 #[test]
311 fn pagination_returns_page_and_per_page() {
312 let query = ListServicesQuery {
313 capability: None,
314 status: None,
315 page: Some(3),
316 per_page: Some(25),
317 };
318 let params = query.pagination();
319 assert_eq!(params.page, Some(3));
320 assert_eq!(params.per_page, Some(25));
321 }
322
323 #[test]
324 fn pagination_returns_none_when_not_set() {
325 let query = ListServicesQuery {
326 capability: None,
327 status: None,
328 page: None,
329 per_page: None,
330 };
331 let params = query.pagination();
332 assert!(params.page.is_none());
333 assert!(params.per_page.is_none());
334 }
335
336 #[test]
337 fn pagination_resolve_applies_defaults() {
338 let query = ListServicesQuery {
339 capability: None,
340 status: None,
341 page: None,
342 per_page: None,
343 };
344 let resolved = query.pagination().resolve();
345 assert_eq!(resolved.page, 1);
346 assert_eq!(resolved.per_page, crate::pagination::DEFAULT_PER_PAGE);
347 }
348
349 #[test]
352 fn update_service_request_with_ping_interval() {
353 let req = UpdateServiceRequest {
354 ping_interval_seconds: Some(60),
355 cert_lifetime_hours: None,
356 };
357 let json = serde_json::to_string(&req).expect("serialization should succeed");
358 assert!(json.contains(r#""ping_interval_seconds":60"#));
359 let parsed: UpdateServiceRequest =
360 serde_json::from_str(&json).expect("deserialization should succeed");
361 assert_eq!(parsed.ping_interval_seconds, Some(60));
362 }
363
364 #[test]
365 fn update_service_request_without_ping_interval() {
366 let req = UpdateServiceRequest {
367 ping_interval_seconds: None,
368 cert_lifetime_hours: None,
369 };
370 let json = serde_json::to_string(&req).expect("serialization should succeed");
371 assert!(!json.contains("ping_interval_seconds"));
372 }
373
374 #[test]
375 fn update_service_request_clear_with_zero() {
376 let json = r#"{"ping_interval_seconds":0}"#;
377 let parsed: UpdateServiceRequest =
378 serde_json::from_str(json).expect("deserialization should succeed");
379 assert_eq!(parsed.ping_interval_seconds, Some(0));
380 }
381
382 #[test]
385 fn validate_accepts_none_interval() {
386 let req = UpdateServiceRequest {
387 ping_interval_seconds: None,
388 cert_lifetime_hours: None,
389 };
390 assert!(req.validate().is_ok());
391 }
392
393 #[test]
394 fn validate_accepts_zero_interval_as_clear_sentinel() {
395 let req = UpdateServiceRequest {
396 ping_interval_seconds: Some(0),
397 cert_lifetime_hours: None,
398 };
399 assert!(req.validate().is_ok());
400 }
401
402 #[test]
403 fn validate_accepts_interval_of_five_or_more() {
404 for v in [5u32, 10, 60, 3600] {
405 let req = UpdateServiceRequest {
406 ping_interval_seconds: Some(v),
407 cert_lifetime_hours: None,
408 };
409 assert!(req.validate().is_ok(), "expected ok for {v}");
410 }
411 }
412
413 #[test]
414 fn validate_rejects_interval_below_five() {
415 for v in [1u32, 2, 3, 4] {
416 let req = UpdateServiceRequest {
417 ping_interval_seconds: Some(v),
418 cert_lifetime_hours: None,
419 };
420 let err = req.validate().unwrap_err();
421 assert_eq!(err.field, "ping_interval_seconds", "field mismatch for {v}");
422 }
423 }
424
425 #[test]
428 fn service_response_includes_cert_lifetime_hours() {
429 let resp = ServiceResponse {
430 id: sample_uuid(),
431 capabilities: vec!["graceful_shutdown".into()],
432 service_label: "Agent".into(),
433 hostname: "host".to_string(),
434 friendly_name: "H".to_string(),
435 is_embedded: true,
436 ip_address: None,
437 status: ServiceStatus::Approved,
438 client_version: None,
439 last_seen_at: None,
440 created_at: datetime!(2025-01-01 0:00:00 UTC),
441 updated_at: datetime!(2025-01-01 0:00:00 UTC),
442 ping_interval_seconds: None,
443 cert_lifetime_hours: Some(48),
444 yielded_to: Some(vec![sample_uuid()]),
445 };
446 let json = serde_json::to_string(&resp).expect("serialization should succeed");
447 assert!(json.contains(r#""cert_lifetime_hours":48"#));
448 let de: ServiceResponse =
449 serde_json::from_str(&json).expect("deserialization should succeed");
450 assert!(de.is_embedded);
451 assert_eq!(de.cert_lifetime_hours, Some(48));
452 assert_eq!(de.yielded_to, Some(vec![sample_uuid()]));
453 }
454
455 #[test]
456 fn service_response_omits_cert_lifetime_hours_when_none() {
457 let resp = ServiceResponse {
458 id: sample_uuid(),
459 capabilities: vec!["graceful_shutdown".into()],
460 service_label: "Agent".into(),
461 hostname: "host".to_string(),
462 friendly_name: "H".to_string(),
463 is_embedded: false,
464 ip_address: None,
465 status: ServiceStatus::Approved,
466 client_version: None,
467 last_seen_at: None,
468 created_at: datetime!(2025-01-01 0:00:00 UTC),
469 updated_at: datetime!(2025-01-01 0:00:00 UTC),
470 ping_interval_seconds: None,
471 cert_lifetime_hours: None,
472 yielded_to: None,
473 };
474 let json = serde_json::to_string(&resp).expect("serialization should succeed");
475 assert!(!json.contains("cert_lifetime_hours"));
476 assert!(!json.contains("yielded_to"));
477 }
478
479 #[test]
480 fn update_service_request_with_cert_lifetime_hours() {
481 let req = UpdateServiceRequest {
482 ping_interval_seconds: None,
483 cert_lifetime_hours: Some(48),
484 };
485 let json = serde_json::to_string(&req).expect("serialization should succeed");
486 assert!(json.contains(r#""cert_lifetime_hours":48"#));
487 let parsed: UpdateServiceRequest =
488 serde_json::from_str(&json).expect("deserialization should succeed");
489 assert_eq!(parsed.cert_lifetime_hours, Some(48));
490 }
491
492 #[test]
493 fn update_service_request_clear_cert_lifetime_with_zero() {
494 let json = r#"{"cert_lifetime_hours":0}"#;
495 let parsed: UpdateServiceRequest =
496 serde_json::from_str(json).expect("deserialization should succeed");
497 assert_eq!(parsed.cert_lifetime_hours, Some(0));
498 assert!(parsed.validate().is_ok());
499 }
500
501 #[test]
502 fn validate_accepts_cert_lifetime_hours_in_range() {
503 for v in [1u32, 12, 48, 168, 17_520] {
504 let req = UpdateServiceRequest {
505 ping_interval_seconds: None,
506 cert_lifetime_hours: Some(v),
507 };
508 assert!(req.validate().is_ok(), "expected ok for {v}");
509 }
510 }
511
512 #[test]
513 fn validate_rejects_cert_lifetime_hours_above_max() {
514 let req = UpdateServiceRequest {
515 ping_interval_seconds: None,
516 cert_lifetime_hours: Some(17_521),
517 };
518 let err = req.validate().unwrap_err();
519 assert_eq!(err.field, "cert_lifetime_hours");
520 }
521}