1use serde::{Deserialize, Serialize};
24
25use crate::error::VtaError;
26
27pub fn validate_service_url(url: &str) -> Result<url::Url, VtaError> {
46 let trimmed = url.trim();
47 if trimmed.is_empty() {
48 return Err(VtaError::Validation("service URL is empty".into()));
49 }
50
51 let parsed = url::Url::parse(trimmed)
52 .map_err(|e| VtaError::Validation(format!("service URL is unparseable: {e}")))?;
53
54 if parsed.scheme() != "https" {
55 return Err(VtaError::Validation(format!(
56 "service URL must use https:// (got {})",
57 parsed.scheme()
58 )));
59 }
60
61 if parsed.fragment().is_some() {
62 return Err(VtaError::Validation(
63 "service URL must not contain a `#fragment`".into(),
64 ));
65 }
66
67 if !parsed.username().is_empty() || parsed.password().is_some() {
72 return Err(VtaError::Validation(
73 "service URL must not contain userinfo (user:password@)".into(),
74 ));
75 }
76
77 if parsed.host().is_none() {
78 return Err(VtaError::Validation("service URL must have a host".into()));
79 }
80
81 Ok(parsed)
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
94#[must_use]
95pub struct EnableRestRequest {
96 pub url: String,
97}
98
99impl EnableRestRequest {
100 pub fn new(url: impl Into<String>) -> Self {
101 Self { url: url.into() }
102 }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
111#[must_use]
112pub struct UpdateRestRequest {
113 pub url: String,
114}
115
116impl UpdateRestRequest {
117 pub fn new(url: impl Into<String>) -> Self {
118 Self { url: url.into() }
119 }
120}
121
122#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
133#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
134#[must_use]
135pub struct DisableRestRequest {}
136
137#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
146#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
147#[must_use]
148pub struct RollbackRestRequest {}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
168#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
169#[must_use]
170pub struct EnableTspRequest {
171 pub mediator_did: String,
172}
173
174impl EnableTspRequest {
175 pub fn new(mediator_did: impl Into<String>) -> Self {
176 Self {
177 mediator_did: mediator_did.into(),
178 }
179 }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
187#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
188#[must_use]
189pub struct UpdateTspRequest {
190 pub mediator_did: String,
191}
192
193impl UpdateTspRequest {
194 pub fn new(mediator_did: impl Into<String>) -> Self {
195 Self {
196 mediator_did: mediator_did.into(),
197 }
198 }
199}
200
201#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
210#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
211#[must_use]
212pub struct DisableTspRequest {}
213
214#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
223#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
224#[must_use]
225pub struct RollbackTspRequest {}
226
227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
240#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
241#[must_use]
242pub struct EnableWebauthnRequest {
243 pub url: String,
244}
245
246impl EnableWebauthnRequest {
247 pub fn new(url: impl Into<String>) -> Self {
248 Self { url: url.into() }
249 }
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
258#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
259#[must_use]
260pub struct UpdateWebauthnRequest {
261 pub url: String,
262}
263
264impl UpdateWebauthnRequest {
265 pub fn new(url: impl Into<String>) -> Self {
266 Self { url: url.into() }
267 }
268}
269
270#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
284#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
285#[must_use]
286pub struct DisableWebauthnRequest {}
287
288#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
291#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
292#[must_use]
293pub struct RollbackWebauthnRequest {}
294
295#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
301#[must_use]
302pub struct RollbackDidcommRequest {
303 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub drain_ttl_secs: Option<u64>,
307}
308
309impl RollbackDidcommRequest {
310 pub fn new() -> Self {
311 Self::default()
312 }
313
314 pub fn drain_ttl_secs(mut self, secs: u64) -> Self {
315 self.drain_ttl_secs = Some(secs);
316 self
317 }
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
323#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
324pub struct DrainListResponse {
325 pub entries: Vec<DrainEntry>,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
329#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
330pub struct DrainEntry {
331 pub mediator_did: String,
332 pub endpoint: String,
333 pub drains_until: String,
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
345#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
346pub struct RollbackResponse {
347 pub log_entry_version_id: String,
348 pub effective_at: String,
349 pub kind: String,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub drain_until: Option<String>,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub draining_mediator: Option<String>,
359 #[serde(default, skip_serializing_if = "String::is_empty")]
367 pub vta_did: String,
368 #[serde(default)]
375 pub serverless: bool,
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
387#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
388pub struct ServicesListResponse {
389 pub services: Vec<ServiceState>,
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
398#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
399#[serde(tag = "kind", rename_all = "lowercase")]
400pub enum ServiceState {
401 Tsp {
402 enabled: bool,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
408 mediator_did: Option<String>,
409 },
410 Rest {
411 enabled: bool,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
415 url: Option<String>,
416 },
417 Didcomm {
418 enabled: bool,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
422 mediator_did: Option<String>,
423 #[serde(default, skip_serializing_if = "Vec::is_empty")]
428 routing_keys: Vec<String>,
429 },
430 Webauthn {
431 enabled: bool,
432 #[serde(default, skip_serializing_if = "Option::is_none")]
436 url: Option<String>,
437 },
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
451#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
452pub struct ServiceMutationResponse {
453 pub log_entry_version_id: String,
456 pub effective_at: String,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub drain_until: Option<String>,
463 #[serde(default, skip_serializing_if = "String::is_empty")]
470 pub vta_did: String,
471 #[serde(default)]
478 pub serverless: bool,
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 #[test]
489 fn rest_request_types_round_trip_through_json() {
490 let req = EnableRestRequest::new("https://vta.example.com");
491 let json = serde_json::to_string(&req).unwrap();
492 let restored: EnableRestRequest = serde_json::from_str(&json).unwrap();
493 assert_eq!(restored, req);
494
495 let req = UpdateRestRequest::new("https://vta-new.example.com");
496 let json = serde_json::to_string(&req).unwrap();
497 let restored: UpdateRestRequest = serde_json::from_str(&json).unwrap();
498 assert_eq!(restored, req);
499
500 let req = DisableRestRequest::default();
501 let json = serde_json::to_string(&req).unwrap();
502 let restored: DisableRestRequest = serde_json::from_str(&json).unwrap();
503 assert_eq!(restored, req);
504
505 let req = RollbackRestRequest::default();
506 let json = serde_json::to_string(&req).unwrap();
507 let restored: RollbackRestRequest = serde_json::from_str(&json).unwrap();
508 assert_eq!(restored, req);
509 }
510
511 #[test]
514 fn tsp_request_types_round_trip_through_json() {
515 let req = EnableTspRequest::new("did:peer:2.Mediator");
516 let json = serde_json::to_string(&req).unwrap();
517 let restored: EnableTspRequest = serde_json::from_str(&json).unwrap();
518 assert_eq!(restored, req);
519
520 let req = UpdateTspRequest::new("did:peer:2.NewMediator");
521 let json = serde_json::to_string(&req).unwrap();
522 let restored: UpdateTspRequest = serde_json::from_str(&json).unwrap();
523 assert_eq!(restored, req);
524
525 let req = DisableTspRequest::default();
526 let json = serde_json::to_string(&req).unwrap();
527 let restored: DisableTspRequest = serde_json::from_str(&json).unwrap();
528 assert_eq!(restored, req);
529
530 let req = RollbackTspRequest::default();
531 let json = serde_json::to_string(&req).unwrap();
532 let restored: RollbackTspRequest = serde_json::from_str(&json).unwrap();
533 assert_eq!(restored, req);
534 }
535
536 #[test]
539 fn tsp_mediator_did_field_is_literal_on_wire() {
540 let json = serde_json::to_value(EnableTspRequest::new("did:peer:2.M")).unwrap();
541 assert_eq!(json["mediator_did"], "did:peer:2.M");
542 assert!(json.get("url").is_none(), "TSP must not carry a url field");
543
544 let json = serde_json::to_value(UpdateTspRequest::new("did:peer:2.N")).unwrap();
545 assert_eq!(json["mediator_did"], "did:peer:2.N");
546 }
547
548 #[test]
550 fn tsp_empty_request_bodies_serialize_as_empty_object() {
551 assert_eq!(
552 serde_json::to_string(&DisableTspRequest::default()).unwrap(),
553 "{}"
554 );
555 assert_eq!(
556 serde_json::to_string(&RollbackTspRequest::default()).unwrap(),
557 "{}"
558 );
559 let _: DisableTspRequest = serde_json::from_str("{}").unwrap();
560 let _: RollbackTspRequest = serde_json::from_str("{}").unwrap();
561 }
562
563 #[test]
567 fn empty_request_bodies_serialize_as_empty_object() {
568 assert_eq!(
569 serde_json::to_string(&DisableRestRequest::default()).unwrap(),
570 "{}"
571 );
572 assert_eq!(
573 serde_json::to_string(&RollbackRestRequest::default()).unwrap(),
574 "{}"
575 );
576 }
577
578 #[test]
582 fn empty_request_bodies_accept_empty_object() {
583 let _: DisableRestRequest = serde_json::from_str("{}").unwrap();
584 let _: RollbackRestRequest = serde_json::from_str("{}").unwrap();
585 }
586
587 #[test]
592 fn rest_url_field_is_literal_url_on_wire() {
593 let json = serde_json::to_value(EnableRestRequest::new("https://x.example")).unwrap();
594 assert_eq!(json["url"], "https://x.example");
595 assert!(json.get("uri").is_none(), "must not rename url to uri");
596
597 let json = serde_json::to_value(UpdateRestRequest::new("https://y.example")).unwrap();
598 assert_eq!(json["url"], "https://y.example");
599 }
600
601 #[test]
606 fn service_mutation_response_round_trips_both_drain_states() {
607 let rest_response = ServiceMutationResponse {
608 log_entry_version_id: "1-zQm...A".into(),
609 effective_at: "2026-05-06T13:00:00Z".into(),
610 drain_until: None,
611 vta_did: "did:webvh:scid:host:vta".into(),
612 serverless: false,
613 };
614 let json = serde_json::to_value(&rest_response).unwrap();
615 assert_eq!(json["log_entry_version_id"], "1-zQm...A");
616 assert_eq!(json["effective_at"], "2026-05-06T13:00:00Z");
617 assert!(
618 json.get("drain_until").is_none(),
619 "drain_until must be omitted when None — wire bandwidth + reader convention",
620 );
621 let restored: ServiceMutationResponse = serde_json::from_value(json).unwrap();
622 assert_eq!(restored, rest_response);
623
624 let didcomm_response = ServiceMutationResponse {
625 log_entry_version_id: "2-zQm...B".into(),
626 effective_at: "2026-05-06T13:00:00Z".into(),
627 drain_until: Some("2026-05-07T13:00:00Z".into()),
628 vta_did: "did:webvh:scid:host:vta".into(),
629 serverless: true,
630 };
631 let json = serde_json::to_value(&didcomm_response).unwrap();
632 assert_eq!(json["drain_until"], "2026-05-07T13:00:00Z");
633 assert_eq!(json["vta_did"], "did:webvh:scid:host:vta");
634 assert_eq!(json["serverless"], true);
635 let restored: ServiceMutationResponse = serde_json::from_value(json).unwrap();
636 assert_eq!(restored, didcomm_response);
637 }
638
639 #[test]
643 fn service_mutation_response_decodes_legacy_payload() {
644 let legacy = r#"{
645 "log_entry_version_id": "1-zQm...A",
646 "effective_at": "2026-05-06T13:00:00Z"
647 }"#;
648 let r: ServiceMutationResponse = serde_json::from_str(legacy).unwrap();
649 assert_eq!(r.vta_did, "");
650 assert!(!r.serverless);
651 }
652
653 #[test]
656 fn validate_service_url_accepts_https() {
657 assert!(validate_service_url("https://vta.example.com").is_ok());
658 assert!(validate_service_url("https://vta.example.com/").is_ok());
659 assert!(validate_service_url("https://vta.example.com:8443").is_ok());
660 assert!(validate_service_url("https://vta.example.com/path/sub").is_ok());
661 }
662
663 #[test]
664 fn validate_service_url_accepts_localhost_and_ip_literals() {
665 assert!(validate_service_url("https://localhost:8443").is_ok());
669 assert!(validate_service_url("https://127.0.0.1:8443").is_ok());
670 assert!(validate_service_url("https://[::1]:8443").is_ok());
671 }
672
673 #[test]
674 fn validate_service_url_rejects_http() {
675 let err = validate_service_url("http://vta.example.com").unwrap_err();
676 match err {
677 VtaError::Validation(msg) => assert!(
678 msg.contains("https"),
679 "expected scheme-related rejection, got: {msg}",
680 ),
681 other => panic!("expected Validation, got {other:?}"),
682 }
683 }
684
685 #[test]
686 fn validate_service_url_rejects_other_schemes() {
687 for bad in [
688 "ws://vta.example.com",
689 "ftp://x.example",
690 "file:///etc/passwd",
691 ] {
692 assert!(
693 matches!(validate_service_url(bad), Err(VtaError::Validation(_))),
694 "expected rejection for {bad}",
695 );
696 }
697 }
698
699 #[test]
700 fn validate_service_url_rejects_fragment() {
701 let err = validate_service_url("https://vta.example.com/api#section").unwrap_err();
702 match err {
703 VtaError::Validation(msg) => {
704 assert!(
705 msg.contains("fragment"),
706 "expected fragment rejection, got: {msg}"
707 )
708 }
709 other => panic!("expected Validation, got {other:?}"),
710 }
711 }
712
713 #[test]
714 fn validate_service_url_rejects_userinfo() {
715 for bad in [
716 "https://user@vta.example.com",
717 "https://user:pass@vta.example.com",
718 "https://:pass@vta.example.com",
719 ] {
720 let err = validate_service_url(bad).unwrap_err();
721 match err {
722 VtaError::Validation(msg) => assert!(
723 msg.contains("userinfo"),
724 "expected userinfo rejection for {bad}, got: {msg}",
725 ),
726 other => panic!("expected Validation for {bad}, got {other:?}"),
727 }
728 }
729 }
730
731 #[test]
732 fn validate_service_url_rejects_unparseable() {
733 for bad in ["not a url at all", "://no-scheme", "https://", "🦀"] {
734 assert!(
735 matches!(validate_service_url(bad), Err(VtaError::Validation(_))),
736 "expected rejection for {bad:?}",
737 );
738 }
739 }
740
741 #[test]
742 fn validate_service_url_rejects_empty_and_whitespace() {
743 for bad in ["", " ", "\t\n"] {
744 let err = validate_service_url(bad).unwrap_err();
745 match err {
746 VtaError::Validation(msg) => assert!(
747 msg.contains("empty"),
748 "expected empty-URL rejection for {bad:?}, got: {msg}",
749 ),
750 other => panic!("expected Validation for {bad:?}, got {other:?}"),
751 }
752 }
753 }
754
755 #[test]
758 fn validate_service_url_returns_parsed_url() {
759 let parsed = validate_service_url("https://vta.example.com:8443/api").unwrap();
760 assert_eq!(parsed.scheme(), "https");
761 assert_eq!(parsed.host_str(), Some("vta.example.com"));
762 assert_eq!(parsed.port(), Some(8443));
763 assert_eq!(parsed.path(), "/api");
764 }
765
766 #[test]
769 fn services_list_response_round_trips_both_kinds() {
770 let response = ServicesListResponse {
771 services: vec![
772 ServiceState::Didcomm {
773 enabled: true,
774 mediator_did: Some("did:peer:2.M".into()),
775 routing_keys: vec!["did:peer:2.K".into()],
776 },
777 ServiceState::Rest {
778 enabled: true,
779 url: Some("https://vta.example.com".into()),
780 },
781 ],
782 };
783 let json = serde_json::to_string(&response).unwrap();
784 let restored: ServicesListResponse = serde_json::from_str(&json).unwrap();
785 assert_eq!(restored, response);
786 }
787
788 #[test]
791 fn service_state_disabled_omits_config_fields() {
792 let rest_off = ServiceState::Rest {
793 enabled: false,
794 url: None,
795 };
796 let json = serde_json::to_value(&rest_off).unwrap();
797 assert_eq!(json["kind"], "rest");
798 assert_eq!(json["enabled"], false);
799 assert!(
800 json.get("url").is_none(),
801 "url must be elided when None to keep the wire form clean",
802 );
803
804 let didcomm_off = ServiceState::Didcomm {
805 enabled: false,
806 mediator_did: None,
807 routing_keys: vec![],
808 };
809 let json = serde_json::to_value(&didcomm_off).unwrap();
810 assert_eq!(json["kind"], "didcomm");
811 assert_eq!(json["enabled"], false);
812 assert!(json.get("mediator_did").is_none());
813 assert!(json.get("routing_keys").is_none());
814 }
815
816 #[test]
820 fn service_state_discriminator_is_literal_kind() {
821 let json = serde_json::to_value(ServiceState::Rest {
822 enabled: true,
823 url: Some("https://x.example".into()),
824 })
825 .unwrap();
826 assert_eq!(json["kind"], "rest");
827
828 let json = serde_json::to_value(ServiceState::Didcomm {
829 enabled: true,
830 mediator_did: Some("did:peer:2.M".into()),
831 routing_keys: vec![],
832 })
833 .unwrap();
834 assert_eq!(json["kind"], "didcomm");
835 }
836
837 #[test]
841 fn service_mutation_response_accepts_explicit_null_drain_until() {
842 let with_null = r#"{
843 "log_entry_version_id": "1-zQm...A",
844 "effective_at": "2026-05-06T13:00:00Z",
845 "drain_until": null
846 }"#;
847 let r: ServiceMutationResponse = serde_json::from_str(with_null).unwrap();
848 assert_eq!(r.drain_until, None);
849
850 let elided = r#"{
851 "log_entry_version_id": "1-zQm...A",
852 "effective_at": "2026-05-06T13:00:00Z"
853 }"#;
854 let r: ServiceMutationResponse = serde_json::from_str(elided).unwrap();
855 assert_eq!(r.drain_until, None);
856 }
857}