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