1use serde::{Deserialize, Serialize};
7use utoipa::ToSchema;
8
9use crate::roster::{CertPolicy, EnrollmentState};
10
11#[derive(Debug, Serialize, Deserialize, ToSchema)]
17pub struct JoinRequest {
18 pub hostname: String,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub auth: Option<koi_crypto::auth::AuthResponse>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub invite_token: Option<String>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub csr: Option<String>,
34 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub sans: Vec<String>,
38}
39
40#[derive(Debug, Serialize, Deserialize, ToSchema)]
42pub struct InviteRequest {
43 pub hostname: String,
45 #[serde(default)]
47 pub ttl_mins: i64,
48}
49
50#[derive(Debug, Serialize, Deserialize, ToSchema)]
52pub struct InviteResponse {
53 pub token: String,
59 pub hostname: String,
61 pub expires_at: String,
63 pub ca_fingerprint: String,
67}
68
69#[derive(Debug, Serialize, Deserialize, ToSchema)]
77pub struct MemberCsrRequest {
78 pub hostname: String,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub sans: Vec<String>,
83}
84
85#[derive(Debug, Serialize, Deserialize, ToSchema)]
87pub struct MemberCsrResponse {
88 pub csr: String,
89}
90
91#[derive(Debug, Serialize, Deserialize, ToSchema)]
99pub struct InstallCertRequest {
100 pub hostname: String,
102 pub cert_pem: String,
104 pub ca_pem: String,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub ca_endpoint: Option<String>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub ca_fingerprint: Option<String>,
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub sans: Vec<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub policy: Option<CertPolicy>,
119}
120
121#[derive(Debug, Serialize, Deserialize, ToSchema)]
123pub struct InstallCertResponse {
124 pub installed: bool,
125 pub cert_path: String,
126}
127
128#[derive(Debug, Serialize, Deserialize, ToSchema)]
130pub struct JoinResponse {
131 pub hostname: String,
132 pub ca_cert: String,
133 pub service_cert: String,
134 #[serde(default, skip_serializing_if = "String::is_empty")]
138 pub service_key: String,
139 pub ca_fingerprint: String,
140 #[serde(default, skip_serializing_if = "String::is_empty")]
143 pub cert_path: String,
144 #[serde(default)]
148 pub policy: CertPolicy,
149}
150
151#[derive(Debug, Serialize, Deserialize, ToSchema)]
157pub struct CertmeshStatus {
158 pub ca_initialized: bool,
159 pub ca_locked: bool,
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub ca_fingerprint: Option<String>,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub auth_method: Option<String>,
165 pub enrollment_open: bool,
167 pub requires_approval: bool,
169 pub enrollment_state: EnrollmentState,
170 pub member_count: usize,
171 #[serde(default)]
173 pub seq: u64,
174 #[serde(default)]
176 pub policy: CertPolicy,
177 pub members: Vec<MemberSummary>,
178}
179
180#[derive(Debug, Serialize, Deserialize, ToSchema)]
182pub struct MemberSummary {
183 pub hostname: String,
184 pub role: String,
185 pub status: String,
186 pub cert_fingerprint: String,
187 pub cert_expires: String,
188}
189
190#[derive(Debug, Serialize, Deserialize, ToSchema)]
192pub struct SetHookRequest {
193 pub hostname: String,
195 pub reload: String,
197}
198
199#[derive(Debug, Serialize, ToSchema)]
201pub struct SetHookResponse {
202 pub hostname: String,
203 pub reload: String,
204}
205
206#[derive(Debug, Serialize, Deserialize, ToSchema)]
216pub struct CreateCaRequest {
217 pub passphrase: String,
219 pub entropy_hex: String,
221 #[serde(skip_serializing_if = "Option::is_none")]
223 pub operator: Option<String>,
224 #[serde(default)]
226 pub enrollment_open: bool,
227 #[serde(default)]
229 pub requires_approval: bool,
230 #[serde(default)]
232 pub auto_unlock: bool,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub totp_secret_hex: Option<String>,
240}
241
242#[derive(Debug, Serialize, Deserialize, ToSchema)]
244pub struct CreateCaResponse {
245 pub auth_setup: koi_crypto::auth::AuthSetup,
247 pub ca_fingerprint: String,
249}
250
251#[derive(Debug, Serialize, Deserialize, ToSchema)]
253pub struct UnlockRequest {
254 pub passphrase: String,
255}
256
257#[derive(Debug, Serialize, Deserialize, ToSchema)]
259pub struct UnlockResponse {
260 pub success: bool,
261}
262
263#[derive(Debug, Serialize, Deserialize, ToSchema)]
265pub struct RotateAuthRequest {
266 pub passphrase: String,
267 #[serde(skip_serializing_if = "Option::is_none")]
269 pub method: Option<String>,
270}
271
272#[derive(Debug, Serialize, Deserialize, ToSchema)]
274pub struct RotateAuthResponse {
275 pub auth_setup: koi_crypto::auth::AuthSetup,
277}
278
279#[derive(Debug, Serialize, Deserialize, ToSchema)]
281pub struct AuditLogResponse {
282 pub entries: String,
283}
284
285#[derive(Debug, Serialize, Deserialize, ToSchema)]
287pub struct DestroyResponse {
288 pub destroyed: bool,
289}
290
291#[derive(Debug, Serialize, Deserialize, ToSchema)]
295pub struct BackupRequest {
296 pub ca_passphrase: String,
297 pub backup_passphrase: String,
298}
299
300#[derive(Debug, Serialize, Deserialize, ToSchema)]
302pub struct BackupResponse {
303 pub backup_hex: String,
304 pub format: String,
305 pub version: u16,
306}
307
308#[derive(Debug, Serialize, Deserialize, ToSchema)]
310pub struct RestoreRequest {
311 pub backup_hex: String,
312 pub backup_passphrase: String,
313 pub new_passphrase: String,
314}
315
316#[derive(Debug, Serialize, Deserialize, ToSchema)]
318pub struct RestoreResponse {
319 pub restored: bool,
320}
321
322#[derive(Debug, Serialize, Deserialize, ToSchema)]
324pub struct RevokeRequest {
325 pub hostname: String,
326 #[serde(skip_serializing_if = "Option::is_none")]
327 pub reason: Option<String>,
328 #[serde(skip_serializing_if = "Option::is_none")]
329 pub operator: Option<String>,
330}
331
332#[derive(Debug, Serialize, Deserialize, ToSchema)]
334pub struct RevokeResponse {
335 pub revoked: bool,
336}
337
338#[derive(Debug, Serialize, Deserialize, ToSchema)]
340pub struct EnrollmentSummary {
341 pub enrollment_state: EnrollmentState,
342}
343
344#[derive(Debug, Serialize, Deserialize, ToSchema)]
348pub struct PromoteRequest {
349 pub auth: koi_crypto::auth::AuthResponse,
350 #[serde(
355 default,
356 skip_serializing_if = "Option::is_none",
357 with = "optional_byte_array"
358 )]
359 pub ephemeral_public: Option<[u8; 32]>,
360}
361
362#[derive(Debug, Serialize, Deserialize, ToSchema)]
370pub struct PromoteResponse {
371 pub encrypted_ca_key: koi_crypto::keys::EncryptedKey,
372 pub auth_data: serde_json::Value,
374 pub roster_json: String,
375 pub ca_cert_pem: String,
376 #[serde(
379 default,
380 skip_serializing_if = "Option::is_none",
381 with = "optional_byte_array"
382 )]
383 pub ephemeral_public: Option<[u8; 32]>,
384}
385
386#[derive(Debug, Serialize, Deserialize, ToSchema)]
392pub struct RenewRequest {
393 pub hostname: String,
394 pub csr: String,
396}
397
398#[derive(Debug, Serialize, Deserialize, ToSchema)]
403pub struct RenewResponse {
404 pub hostname: String,
405 pub service_cert: String,
407 pub ca_cert: String,
409 pub ca_fingerprint: String,
411 pub expires: String,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
417pub struct HookResult {
418 pub success: bool,
419 pub command: String,
420 #[serde(skip_serializing_if = "Option::is_none")]
421 pub output: Option<String>,
422}
423
424#[derive(Debug, Serialize, Deserialize, ToSchema)]
426pub struct HealthRequest {
427 pub hostname: String,
428 pub pinned_ca_fingerprint: String,
429}
430
431#[derive(Debug, Serialize, Deserialize, ToSchema)]
433pub struct HealthResponse {
434 pub valid: bool,
435 pub ca_fingerprint: String,
436}
437
438mod optional_byte_array {
440 use serde::{self, Deserialize, Deserializer, Serializer};
441
442 pub fn serialize<S>(value: &Option<[u8; 32]>, serializer: S) -> Result<S::Ok, S::Error>
443 where
444 S: Serializer,
445 {
446 match value {
447 Some(bytes) => {
448 let hex = koi_common::encoding::hex_encode(bytes);
449 serializer.serialize_str(&hex)
450 }
451 None => serializer.serialize_none(),
452 }
453 }
454
455 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<[u8; 32]>, D::Error>
456 where
457 D: Deserializer<'de>,
458 {
459 let opt: Option<String> = Option::deserialize(deserializer)?;
460 match opt {
461 Some(hex) => {
462 let bytes =
463 koi_common::encoding::hex_decode(&hex).map_err(serde::de::Error::custom)?;
464 if bytes.len() != 32 {
465 return Err(serde::de::Error::custom(format!(
466 "expected 32 bytes, got {}",
467 bytes.len()
468 )));
469 }
470 let mut arr = [0u8; 32];
471 arr.copy_from_slice(&bytes);
472 Ok(Some(arr))
473 }
474 None => Ok(None),
475 }
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
484 fn join_request_serde_round_trip() {
485 let req = JoinRequest {
486 hostname: "node-05".to_string(),
487 auth: Some(koi_crypto::auth::AuthResponse::Totp {
488 code: "123456".to_string(),
489 }),
490 invite_token: None,
491 csr: None,
492 sans: vec!["10.0.0.5".to_string()],
493 };
494 let json = serde_json::to_string(&req).unwrap();
495 let parsed: JoinRequest = serde_json::from_str(&json).unwrap();
496 assert_eq!(parsed.hostname, "node-05");
497 assert!(
498 matches!(parsed.auth, Some(koi_crypto::auth::AuthResponse::Totp { ref code }) if code == "123456")
499 );
500 assert!(parsed.invite_token.is_none());
501 assert_eq!(parsed.sans, vec!["10.0.0.5"]);
502 }
503
504 #[test]
505 fn join_request_with_invite_token_round_trip() {
506 let json = r#"{"hostname":"node-06","invite_token":"deadbeef"}"#;
507 let parsed: JoinRequest = serde_json::from_str(json).unwrap();
508 assert_eq!(parsed.hostname, "node-06");
509 assert!(parsed.auth.is_none());
510 assert_eq!(parsed.invite_token.as_deref(), Some("deadbeef"));
511 assert!(parsed.sans.is_empty());
512 let reser = serde_json::to_string(&parsed).unwrap();
514 assert!(!reser.contains("\"auth\""));
515 }
516
517 #[test]
518 fn join_request_with_csr_round_trip() {
519 let json = r#"{"hostname":"web-01","invite_token":"deadbeef","csr":"-----BEGIN CERTIFICATE REQUEST-----\nx\n-----END CERTIFICATE REQUEST-----\n"}"#;
520 let parsed: JoinRequest = serde_json::from_str(json).unwrap();
521 assert!(parsed
522 .csr
523 .as_deref()
524 .unwrap()
525 .contains("CERTIFICATE REQUEST"));
526 assert!(parsed.auth.is_none());
527 }
528
529 #[test]
530 fn member_csr_request_round_trip() {
531 let parsed: MemberCsrRequest =
532 serde_json::from_str(r#"{"hostname":"web-01","sans":["10.0.0.9"]}"#).unwrap();
533 assert_eq!(parsed.hostname, "web-01");
534 assert_eq!(parsed.sans, vec!["10.0.0.9"]);
535 let bare: MemberCsrRequest = serde_json::from_str(r#"{"hostname":"web-01"}"#).unwrap();
537 assert!(bare.sans.is_empty());
538 }
539
540 #[test]
541 fn install_cert_request_round_trip() {
542 let req = InstallCertRequest {
543 hostname: "web-01".to_string(),
544 cert_pem: "CERT".to_string(),
545 ca_pem: "CA".to_string(),
546 ca_endpoint: Some("http://ca-host:5641".to_string()),
547 ca_fingerprint: Some("deadbeef".to_string()),
548 sans: vec!["web-01".to_string()],
549 policy: Some(CertPolicy::default()),
550 };
551 let json = serde_json::to_string(&req).unwrap();
552 let parsed: InstallCertRequest = serde_json::from_str(&json).unwrap();
553 assert_eq!(parsed.hostname, "web-01");
554 assert_eq!(parsed.cert_pem, "CERT");
555 assert_eq!(parsed.ca_endpoint.as_deref(), Some("http://ca-host:5641"));
556 assert_eq!(parsed.ca_fingerprint.as_deref(), Some("deadbeef"));
557
558 let bare: InstallCertRequest =
560 serde_json::from_str(r#"{"hostname":"web-01","cert_pem":"C","ca_pem":"CA"}"#).unwrap();
561 assert!(bare.ca_endpoint.is_none());
562 assert!(bare.policy.is_none());
563 }
564
565 #[test]
566 fn invite_request_defaults_ttl() {
567 let parsed: InviteRequest = serde_json::from_str(r#"{"hostname":"web-01"}"#).unwrap();
568 assert_eq!(parsed.hostname, "web-01");
569 assert_eq!(parsed.ttl_mins, 0);
570 }
571
572 #[test]
573 fn invite_response_round_trip() {
574 let resp = InviteResponse {
575 token: "abc123.deadbeef".to_string(),
576 hostname: "web-01".to_string(),
577 expires_at: "2026-06-18T12:00:00Z".to_string(),
578 ca_fingerprint: "deadbeef".to_string(),
579 };
580 let json = serde_json::to_string(&resp).unwrap();
581 let parsed: InviteResponse = serde_json::from_str(&json).unwrap();
582 assert_eq!(parsed.token, "abc123.deadbeef");
583 assert_eq!(parsed.hostname, "web-01");
584 assert_eq!(parsed.ca_fingerprint, "deadbeef");
585 }
586
587 #[test]
588 fn join_request_without_sans_deserializes() {
589 let json = r#"{"hostname":"node-05","auth":{"method":"totp","code":"123456"}}"#;
591 let parsed: JoinRequest = serde_json::from_str(json).unwrap();
592 assert_eq!(parsed.hostname, "node-05");
593 assert!(parsed.sans.is_empty());
594 }
595
596 #[test]
597 fn join_response_serializes() {
598 let resp = JoinResponse {
599 hostname: "node-05".to_string(),
600 ca_cert: "-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----\n".to_string(),
601 service_cert: "-----BEGIN CERTIFICATE-----\nsvc\n-----END CERTIFICATE-----\n"
602 .to_string(),
603 service_key: "-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----\n"
604 .to_string(),
605 ca_fingerprint: "abc123".to_string(),
606 cert_path: "/home/koi/.koi/certs/node-05".to_string(),
607 policy: CertPolicy::default(),
608 };
609 let json = serde_json::to_string(&resp).unwrap();
610 assert!(json.contains("node-05"));
611 assert!(json.contains("ca_fingerprint"));
612 }
613
614 #[test]
615 fn set_hook_request_serde_round_trip() {
616 let req = SetHookRequest {
617 hostname: "node-01".to_string(),
618 reload: "systemctl restart nginx".to_string(),
619 };
620 let json = serde_json::to_string(&req).unwrap();
621 let parsed: SetHookRequest = serde_json::from_str(&json).unwrap();
622 assert_eq!(parsed.hostname, "node-01");
623 assert_eq!(parsed.reload, "systemctl restart nginx");
624 }
625
626 #[test]
627 fn set_hook_response_serializes() {
628 let resp = SetHookResponse {
629 hostname: "node-01".to_string(),
630 reload: "systemctl restart nginx".to_string(),
631 };
632 let json = serde_json::to_string(&resp).unwrap();
633 assert!(json.contains("node-01"));
634 assert!(json.contains("systemctl restart nginx"));
635 }
636
637 #[test]
640 fn promote_request_serde_round_trip() {
641 let req = PromoteRequest {
642 auth: koi_crypto::auth::AuthResponse::Totp {
643 code: "654321".to_string(),
644 },
645 ephemeral_public: None,
646 };
647 let json = serde_json::to_string(&req).unwrap();
648 let parsed: PromoteRequest = serde_json::from_str(&json).unwrap();
649 assert!(
650 matches!(parsed.auth, koi_crypto::auth::AuthResponse::Totp { ref code } if code == "654321")
651 );
652 assert!(parsed.ephemeral_public.is_none());
653 }
654
655 #[test]
656 fn promote_request_with_ephemeral_public_round_trip() {
657 let pub_key = [42u8; 32];
658 let req = PromoteRequest {
659 auth: koi_crypto::auth::AuthResponse::Totp {
660 code: "654321".to_string(),
661 },
662 ephemeral_public: Some(pub_key),
663 };
664 let json = serde_json::to_string(&req).unwrap();
665 assert!(json.contains("ephemeral_public"));
666 let parsed: PromoteRequest = serde_json::from_str(&json).unwrap();
667 assert_eq!(parsed.ephemeral_public, Some(pub_key));
668 }
669
670 #[test]
671 fn promote_response_serde_round_trip() {
672 let resp = PromoteResponse {
673 encrypted_ca_key: koi_crypto::keys::EncryptedKey {
674 ciphertext: vec![1, 2, 3],
675 salt: vec![4, 5, 6],
676 nonce: vec![7, 8, 9],
677 kdf_params: Default::default(),
678 },
679 auth_data: serde_json::json!({"method": "totp", "encrypted_secret": {"ciphertext": [10], "salt": [11], "nonce": [12]}}),
680 roster_json: r#"{"metadata":{}}"#.to_string(),
681 ca_cert_pem: "-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----\n".to_string(),
682 ephemeral_public: None,
683 };
684 let json = serde_json::to_string(&resp).unwrap();
685 let parsed: PromoteResponse = serde_json::from_str(&json).unwrap();
686 assert_eq!(parsed.encrypted_ca_key.ciphertext, vec![1, 2, 3]);
687 assert_eq!(parsed.ca_cert_pem.len(), resp.ca_cert_pem.len());
688 assert!(parsed.ephemeral_public.is_none());
689 }
690
691 #[test]
692 fn promote_response_with_ephemeral_public_round_trip() {
693 let server_pub = [99u8; 32];
694 let resp = PromoteResponse {
695 encrypted_ca_key: koi_crypto::keys::EncryptedKey {
696 ciphertext: vec![1, 2, 3],
697 salt: vec![4, 5, 6],
698 nonce: vec![7, 8, 9],
699 kdf_params: Default::default(),
700 },
701 auth_data: serde_json::json!({"method": "totp"}),
702 roster_json: "{}".to_string(),
703 ca_cert_pem: "cert".to_string(),
704 ephemeral_public: Some(server_pub),
705 };
706 let json = serde_json::to_string(&resp).unwrap();
707 let parsed: PromoteResponse = serde_json::from_str(&json).unwrap();
708 assert_eq!(parsed.ephemeral_public, Some(server_pub));
709 }
710
711 #[test]
712 fn renew_request_is_csr_only_no_key() {
713 let req = RenewRequest {
716 hostname: "node-05".to_string(),
717 csr: "-----BEGIN CERTIFICATE REQUEST-----\nx\n-----END CERTIFICATE REQUEST-----\n"
718 .to_string(),
719 };
720 let json = serde_json::to_string(&req).unwrap();
721 assert!(
722 !json.contains("key"),
723 "renew request must never carry a key"
724 );
725 let parsed: RenewRequest = serde_json::from_str(&json).unwrap();
726 assert_eq!(parsed.hostname, "node-05");
727 assert!(parsed.csr.contains("CERTIFICATE REQUEST"));
728 }
729
730 #[test]
731 fn renew_response_carries_cert_not_key() {
732 let resp = RenewResponse {
733 hostname: "node-05".to_string(),
734 service_cert: "-----BEGIN CERTIFICATE-----\nsvc\n-----END CERTIFICATE-----\n"
735 .to_string(),
736 ca_cert: "-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----\n".to_string(),
737 ca_fingerprint: "abc123".to_string(),
738 expires: "2026-09-15T00:00:00Z".to_string(),
739 };
740 let json = serde_json::to_string(&resp).unwrap();
741 assert!(
742 !json.contains("PRIVATE KEY"),
743 "renew response must never carry a private key"
744 );
745 let parsed: RenewResponse = serde_json::from_str(&json).unwrap();
746 assert_eq!(parsed.hostname, "node-05");
747 assert!(parsed.service_cert.contains("BEGIN CERTIFICATE"));
748 assert_eq!(parsed.ca_fingerprint, "abc123");
749 }
750
751 #[test]
752 fn hook_result_omits_none_output() {
753 let hr = HookResult {
754 success: false,
755 command: "bad-cmd".to_string(),
756 output: None,
757 };
758 let json = serde_json::to_string(&hr).unwrap();
759 assert!(!json.contains("output"));
760 }
761
762 #[test]
763 fn health_request_serde_round_trip() {
764 let req = HealthRequest {
765 hostname: "node-05".to_string(),
766 pinned_ca_fingerprint: "abcdef".to_string(),
767 };
768 let json = serde_json::to_string(&req).unwrap();
769 let parsed: HealthRequest = serde_json::from_str(&json).unwrap();
770 assert_eq!(parsed.hostname, "node-05");
771 assert_eq!(parsed.pinned_ca_fingerprint, "abcdef");
772 }
773
774 #[test]
775 fn health_response_serde_round_trip() {
776 let resp = HealthResponse {
777 valid: true,
778 ca_fingerprint: "cafp123".to_string(),
779 };
780 let json = serde_json::to_string(&resp).unwrap();
781 let parsed: HealthResponse = serde_json::from_str(&json).unwrap();
782 assert!(parsed.valid);
783 assert_eq!(parsed.ca_fingerprint, "cafp123");
784 }
785
786 #[test]
789 fn certmesh_status_serializes() {
790 let status = CertmeshStatus {
791 ca_initialized: true,
792 ca_locked: false,
793 ca_fingerprint: Some("abc123".to_string()),
794 auth_method: None,
795 enrollment_open: true,
796 requires_approval: false,
797 enrollment_state: EnrollmentState::Open,
798 member_count: 1,
799 seq: 0,
800 policy: CertPolicy::default(),
801 members: vec![MemberSummary {
802 hostname: "node-01".to_string(),
803 role: "primary".to_string(),
804 status: "active".to_string(),
805 cert_fingerprint: "abc".to_string(),
806 cert_expires: "2026-03-13T00:00:00Z".to_string(),
807 }],
808 };
809 let json = serde_json::to_string(&status).unwrap();
810 assert!(json.contains("\"ca_initialized\":true"));
811 assert!(json.contains("\"member_count\":1"));
812 assert!(json.contains("\"enrollment_open\":true"));
813 assert!(json.contains("\"requires_approval\":false"));
814 }
815
816 #[test]
817 fn certmesh_status_reports_posture_booleans() {
818 let status = CertmeshStatus {
819 ca_initialized: true,
820 ca_locked: false,
821 ca_fingerprint: Some("fp-org".to_string()),
822 auth_method: None,
823 enrollment_open: false,
824 requires_approval: true,
825 enrollment_state: EnrollmentState::Closed,
826 member_count: 0,
827 seq: 0,
828 policy: CertPolicy::default(),
829 members: vec![],
830 };
831 let json = serde_json::to_string(&status).unwrap();
832 assert!(json.contains("\"enrollment_open\":false"));
833 assert!(json.contains("\"requires_approval\":true"));
834 assert!(json.contains("\"enrollment_state\":\"closed\""));
835 }
836
837 #[test]
840 fn create_ca_request_serde_round_trip() {
841 let req = CreateCaRequest {
842 passphrase: "hunter2".to_string(),
843 entropy_hex: "0a1b2c3d".to_string(),
844 operator: None,
845 enrollment_open: true,
846 requires_approval: false,
847 auto_unlock: true,
848 totp_secret_hex: None,
849 };
850 let json = serde_json::to_string(&req).unwrap();
851 let parsed: CreateCaRequest = serde_json::from_str(&json).unwrap();
852 assert_eq!(parsed.passphrase, "hunter2");
853 assert_eq!(parsed.entropy_hex, "0a1b2c3d");
854 assert!(parsed.operator.is_none());
855 assert!(parsed.enrollment_open);
856 assert!(!parsed.requires_approval);
857 assert!(parsed.auto_unlock);
858 }
859
860 #[test]
861 fn create_ca_request_with_operator() {
862 let req = CreateCaRequest {
863 passphrase: "pass".to_string(),
864 entropy_hex: "ff".to_string(),
865 operator: Some("ops@acme.com".to_string()),
866 enrollment_open: false,
867 requires_approval: true,
868 auto_unlock: false,
869 totp_secret_hex: None,
870 };
871 let json = serde_json::to_string(&req).unwrap();
872 let parsed: CreateCaRequest = serde_json::from_str(&json).unwrap();
873 assert_eq!(parsed.operator.as_deref(), Some("ops@acme.com"));
874 assert!(!parsed.enrollment_open);
875 assert!(parsed.requires_approval);
876 assert!(!parsed.auto_unlock);
877 }
878
879 #[test]
880 fn create_ca_request_omits_none_operator() {
881 let req = CreateCaRequest {
882 passphrase: "p".to_string(),
883 entropy_hex: "aa".to_string(),
884 operator: None,
885 enrollment_open: false,
886 requires_approval: false,
887 auto_unlock: false,
888 totp_secret_hex: None,
889 };
890 let json = serde_json::to_string(&req).unwrap();
891 assert!(!json.contains("operator"));
892 }
893
894 #[test]
895 fn create_ca_response_serde_round_trip() {
896 let resp = CreateCaResponse {
897 auth_setup: koi_crypto::auth::AuthSetup::Totp {
898 totp_uri: "otpauth://totp/Koi:admin?secret=ABC123".to_string(),
899 },
900 ca_fingerprint: "sha256:abcdef".to_string(),
901 };
902 let json = serde_json::to_string(&resp).unwrap();
903 let parsed: CreateCaResponse = serde_json::from_str(&json).unwrap();
904 assert!(json.contains("ABC123"));
905 assert_eq!(parsed.ca_fingerprint, "sha256:abcdef");
906 }
907
908 #[test]
909 fn unlock_request_serde_round_trip() {
910 let req = UnlockRequest {
911 passphrase: "my-secret".to_string(),
912 };
913 let json = serde_json::to_string(&req).unwrap();
914 let parsed: UnlockRequest = serde_json::from_str(&json).unwrap();
915 assert_eq!(parsed.passphrase, "my-secret");
916 }
917
918 #[test]
919 fn unlock_response_serde_round_trip() {
920 let resp = UnlockResponse { success: true };
921 let json = serde_json::to_string(&resp).unwrap();
922 let parsed: UnlockResponse = serde_json::from_str(&json).unwrap();
923 assert!(parsed.success);
924 }
925
926 #[test]
927 fn rotate_auth_request_serde_round_trip() {
928 let req = RotateAuthRequest {
929 passphrase: "rotate-pass".to_string(),
930 method: None,
931 };
932 let json = serde_json::to_string(&req).unwrap();
933 let parsed: RotateAuthRequest = serde_json::from_str(&json).unwrap();
934 assert_eq!(parsed.passphrase, "rotate-pass");
935 }
936
937 #[test]
938 fn rotate_auth_response_serde_round_trip() {
939 let resp = RotateAuthResponse {
940 auth_setup: koi_crypto::auth::AuthSetup::Totp {
941 totp_uri: "otpauth://totp/Koi:admin?secret=NEWBASE32".to_string(),
942 },
943 };
944 let json = serde_json::to_string(&resp).unwrap();
945 let _: RotateAuthResponse = serde_json::from_str(&json).unwrap();
946 assert!(json.contains("NEWBASE32"));
947 }
948
949 #[test]
950 fn audit_log_response_serde_round_trip() {
951 let resp = AuditLogResponse {
952 entries: "2026-02-11T00:00:00Z ca_initialized\n".to_string(),
953 };
954 let json = serde_json::to_string(&resp).unwrap();
955 let parsed: AuditLogResponse = serde_json::from_str(&json).unwrap();
956 assert!(parsed.entries.contains("ca_initialized"));
957 }
958
959 #[test]
960 fn destroy_response_serde_round_trip() {
961 let resp = DestroyResponse { destroyed: true };
962 let json = serde_json::to_string(&resp).unwrap();
963 let parsed: DestroyResponse = serde_json::from_str(&json).unwrap();
964 assert!(parsed.destroyed);
965 }
966
967 #[test]
968 fn certmesh_status_serde_round_trip() {
969 let status = CertmeshStatus {
970 ca_initialized: true,
971 ca_locked: false,
972 ca_fingerprint: Some("fp-round-trip".to_string()),
973 auth_method: None,
974 enrollment_open: true,
975 requires_approval: true,
976 enrollment_state: EnrollmentState::Open,
977 member_count: 2,
978 seq: 5,
979 policy: CertPolicy::default(),
980 members: vec![
981 MemberSummary {
982 hostname: "node-01".to_string(),
983 role: "primary".to_string(),
984 status: "active".to_string(),
985 cert_fingerprint: "fp1".to_string(),
986 cert_expires: "2026-06-01".to_string(),
987 },
988 MemberSummary {
989 hostname: "node-02".to_string(),
990 role: "member".to_string(),
991 status: "active".to_string(),
992 cert_fingerprint: "fp2".to_string(),
993 cert_expires: "2026-06-01".to_string(),
994 },
995 ],
996 };
997 let json = serde_json::to_string(&status).unwrap();
998 let parsed: CertmeshStatus = serde_json::from_str(&json).unwrap();
999 assert!(parsed.ca_initialized);
1000 assert!(!parsed.ca_locked);
1001 assert!(parsed.enrollment_open);
1002 assert!(parsed.requires_approval);
1003 assert_eq!(parsed.member_count, 2);
1004 assert_eq!(parsed.members.len(), 2);
1005 assert_eq!(parsed.members[0].hostname, "node-01");
1006 assert_eq!(parsed.members[1].hostname, "node-02");
1007 }
1008
1009 #[test]
1010 fn certmesh_status_uninitialized_round_trip() {
1011 let status = CertmeshStatus {
1012 ca_initialized: false,
1013 ca_locked: false,
1014 ca_fingerprint: None,
1015 auth_method: None,
1016 enrollment_open: false,
1017 requires_approval: false,
1018 enrollment_state: EnrollmentState::Closed,
1019 member_count: 0,
1020 seq: 0,
1021 policy: CertPolicy::default(),
1022 members: vec![],
1023 };
1024 let json = serde_json::to_string(&status).unwrap();
1025 let parsed: CertmeshStatus = serde_json::from_str(&json).unwrap();
1026 assert!(!parsed.ca_initialized);
1027 assert_eq!(parsed.member_count, 0);
1028 assert!(parsed.members.is_empty());
1029 }
1030
1031 #[test]
1032 fn enrollment_summary_serializes() {
1033 let summary = EnrollmentSummary {
1034 enrollment_state: EnrollmentState::Open,
1035 };
1036 let json = serde_json::to_string(&summary).unwrap();
1037 assert!(json.contains("\"enrollment_state\":\"open\""));
1038 }
1039}