use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::roster::{CertPolicy, EnrollmentState};
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct JoinRequest {
pub hostname: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<koi_crypto::auth::AuthResponse>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub invite_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub csr: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sans: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct InviteRequest {
pub hostname: String,
#[serde(default)]
pub ttl_mins: i64,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct InviteResponse {
pub token: String,
pub hostname: String,
pub expires_at: String,
pub ca_fingerprint: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct MemberCsrRequest {
pub hostname: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sans: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct MemberCsrResponse {
pub csr: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct InstallCertRequest {
pub hostname: String,
pub cert_pem: String,
pub ca_pem: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ca_endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ca_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sans: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy: Option<CertPolicy>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct InstallCertResponse {
pub installed: bool,
pub cert_path: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct JoinResponse {
pub hostname: String,
pub ca_cert: String,
pub service_cert: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub service_key: String,
pub ca_fingerprint: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub cert_path: String,
#[serde(default)]
pub policy: CertPolicy,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CertmeshStatus {
pub ca_initialized: bool,
pub ca_locked: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub ca_fingerprint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_method: Option<String>,
pub enrollment_open: bool,
pub requires_approval: bool,
pub enrollment_state: EnrollmentState,
pub member_count: usize,
#[serde(default)]
pub seq: u64,
#[serde(default)]
pub policy: CertPolicy,
pub members: Vec<MemberSummary>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct MemberSummary {
pub hostname: String,
pub role: String,
pub status: String,
pub cert_fingerprint: String,
pub cert_expires: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SetHookRequest {
pub hostname: String,
pub reload: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct SetHookResponse {
pub hostname: String,
pub reload: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateCaRequest {
pub passphrase: String,
pub entropy_hex: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub operator: Option<String>,
#[serde(default)]
pub enrollment_open: bool,
#[serde(default)]
pub requires_approval: bool,
#[serde(default)]
pub auto_unlock: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub totp_secret_hex: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateCaResponse {
pub auth_setup: koi_crypto::auth::AuthSetup,
pub ca_fingerprint: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UnlockRequest {
pub passphrase: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UnlockResponse {
pub success: bool,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RotateAuthRequest {
pub passphrase: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RotateAuthResponse {
pub auth_setup: koi_crypto::auth::AuthSetup,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AuditLogResponse {
pub entries: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct DestroyResponse {
pub destroyed: bool,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct BackupRequest {
pub ca_passphrase: String,
pub backup_passphrase: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct BackupResponse {
pub backup_hex: String,
pub format: String,
pub version: u16,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RestoreRequest {
pub backup_hex: String,
pub backup_passphrase: String,
pub new_passphrase: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RestoreResponse {
pub restored: bool,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RevokeRequest {
pub hostname: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub operator: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RevokeResponse {
pub revoked: bool,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct EnrollmentSummary {
pub enrollment_state: EnrollmentState,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct PromoteRequest {
pub auth: koi_crypto::auth::AuthResponse,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "optional_byte_array"
)]
pub ephemeral_public: Option<[u8; 32]>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct PromoteResponse {
pub encrypted_ca_key: koi_crypto::keys::EncryptedKey,
pub auth_data: serde_json::Value,
pub roster_json: String,
pub ca_cert_pem: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "optional_byte_array"
)]
pub ephemeral_public: Option<[u8; 32]>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RenewRequest {
pub hostname: String,
pub csr: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RenewResponse {
pub hostname: String,
pub service_cert: String,
pub ca_cert: String,
pub ca_fingerprint: String,
pub expires: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct HookResult {
pub success: bool,
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct HealthRequest {
pub hostname: String,
pub pinned_ca_fingerprint: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct HealthResponse {
pub valid: bool,
pub ca_fingerprint: String,
}
mod optional_byte_array {
use serde::{self, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(value: &Option<[u8; 32]>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match value {
Some(bytes) => {
let hex = koi_common::encoding::hex_encode(bytes);
serializer.serialize_str(&hex)
}
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<[u8; 32]>, D::Error>
where
D: Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
match opt {
Some(hex) => {
let bytes =
koi_common::encoding::hex_decode(&hex).map_err(serde::de::Error::custom)?;
if bytes.len() != 32 {
return Err(serde::de::Error::custom(format!(
"expected 32 bytes, got {}",
bytes.len()
)));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Ok(Some(arr))
}
None => Ok(None),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn join_request_serde_round_trip() {
let req = JoinRequest {
hostname: "node-05".to_string(),
auth: Some(koi_crypto::auth::AuthResponse::Totp {
code: "123456".to_string(),
}),
invite_token: None,
csr: None,
sans: vec!["10.0.0.5".to_string()],
};
let json = serde_json::to_string(&req).unwrap();
let parsed: JoinRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.hostname, "node-05");
assert!(
matches!(parsed.auth, Some(koi_crypto::auth::AuthResponse::Totp { ref code }) if code == "123456")
);
assert!(parsed.invite_token.is_none());
assert_eq!(parsed.sans, vec!["10.0.0.5"]);
}
#[test]
fn join_request_with_invite_token_round_trip() {
let json = r#"{"hostname":"node-06","invite_token":"deadbeef"}"#;
let parsed: JoinRequest = serde_json::from_str(json).unwrap();
assert_eq!(parsed.hostname, "node-06");
assert!(parsed.auth.is_none());
assert_eq!(parsed.invite_token.as_deref(), Some("deadbeef"));
assert!(parsed.sans.is_empty());
let reser = serde_json::to_string(&parsed).unwrap();
assert!(!reser.contains("\"auth\""));
}
#[test]
fn join_request_with_csr_round_trip() {
let json = r#"{"hostname":"web-01","invite_token":"deadbeef","csr":"-----BEGIN CERTIFICATE REQUEST-----\nx\n-----END CERTIFICATE REQUEST-----\n"}"#;
let parsed: JoinRequest = serde_json::from_str(json).unwrap();
assert!(parsed
.csr
.as_deref()
.unwrap()
.contains("CERTIFICATE REQUEST"));
assert!(parsed.auth.is_none());
}
#[test]
fn member_csr_request_round_trip() {
let parsed: MemberCsrRequest =
serde_json::from_str(r#"{"hostname":"web-01","sans":["10.0.0.9"]}"#).unwrap();
assert_eq!(parsed.hostname, "web-01");
assert_eq!(parsed.sans, vec!["10.0.0.9"]);
let bare: MemberCsrRequest = serde_json::from_str(r#"{"hostname":"web-01"}"#).unwrap();
assert!(bare.sans.is_empty());
}
#[test]
fn install_cert_request_round_trip() {
let req = InstallCertRequest {
hostname: "web-01".to_string(),
cert_pem: "CERT".to_string(),
ca_pem: "CA".to_string(),
ca_endpoint: Some("http://ca-host:5641".to_string()),
ca_fingerprint: Some("deadbeef".to_string()),
sans: vec!["web-01".to_string()],
policy: Some(CertPolicy::default()),
};
let json = serde_json::to_string(&req).unwrap();
let parsed: InstallCertRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.hostname, "web-01");
assert_eq!(parsed.cert_pem, "CERT");
assert_eq!(parsed.ca_endpoint.as_deref(), Some("http://ca-host:5641"));
assert_eq!(parsed.ca_fingerprint.as_deref(), Some("deadbeef"));
let bare: InstallCertRequest =
serde_json::from_str(r#"{"hostname":"web-01","cert_pem":"C","ca_pem":"CA"}"#).unwrap();
assert!(bare.ca_endpoint.is_none());
assert!(bare.policy.is_none());
}
#[test]
fn invite_request_defaults_ttl() {
let parsed: InviteRequest = serde_json::from_str(r#"{"hostname":"web-01"}"#).unwrap();
assert_eq!(parsed.hostname, "web-01");
assert_eq!(parsed.ttl_mins, 0);
}
#[test]
fn invite_response_round_trip() {
let resp = InviteResponse {
token: "abc123.deadbeef".to_string(),
hostname: "web-01".to_string(),
expires_at: "2026-06-18T12:00:00Z".to_string(),
ca_fingerprint: "deadbeef".to_string(),
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: InviteResponse = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.token, "abc123.deadbeef");
assert_eq!(parsed.hostname, "web-01");
assert_eq!(parsed.ca_fingerprint, "deadbeef");
}
#[test]
fn join_request_without_sans_deserializes() {
let json = r#"{"hostname":"node-05","auth":{"method":"totp","code":"123456"}}"#;
let parsed: JoinRequest = serde_json::from_str(json).unwrap();
assert_eq!(parsed.hostname, "node-05");
assert!(parsed.sans.is_empty());
}
#[test]
fn join_response_serializes() {
let resp = JoinResponse {
hostname: "node-05".to_string(),
ca_cert: "-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----\n".to_string(),
service_cert: "-----BEGIN CERTIFICATE-----\nsvc\n-----END CERTIFICATE-----\n"
.to_string(),
service_key: "-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----\n"
.to_string(),
ca_fingerprint: "abc123".to_string(),
cert_path: "/home/koi/.koi/certs/node-05".to_string(),
policy: CertPolicy::default(),
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("node-05"));
assert!(json.contains("ca_fingerprint"));
}
#[test]
fn set_hook_request_serde_round_trip() {
let req = SetHookRequest {
hostname: "node-01".to_string(),
reload: "systemctl restart nginx".to_string(),
};
let json = serde_json::to_string(&req).unwrap();
let parsed: SetHookRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.hostname, "node-01");
assert_eq!(parsed.reload, "systemctl restart nginx");
}
#[test]
fn set_hook_response_serializes() {
let resp = SetHookResponse {
hostname: "node-01".to_string(),
reload: "systemctl restart nginx".to_string(),
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("node-01"));
assert!(json.contains("systemctl restart nginx"));
}
#[test]
fn promote_request_serde_round_trip() {
let req = PromoteRequest {
auth: koi_crypto::auth::AuthResponse::Totp {
code: "654321".to_string(),
},
ephemeral_public: None,
};
let json = serde_json::to_string(&req).unwrap();
let parsed: PromoteRequest = serde_json::from_str(&json).unwrap();
assert!(
matches!(parsed.auth, koi_crypto::auth::AuthResponse::Totp { ref code } if code == "654321")
);
assert!(parsed.ephemeral_public.is_none());
}
#[test]
fn promote_request_with_ephemeral_public_round_trip() {
let pub_key = [42u8; 32];
let req = PromoteRequest {
auth: koi_crypto::auth::AuthResponse::Totp {
code: "654321".to_string(),
},
ephemeral_public: Some(pub_key),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("ephemeral_public"));
let parsed: PromoteRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.ephemeral_public, Some(pub_key));
}
#[test]
fn promote_response_serde_round_trip() {
let resp = PromoteResponse {
encrypted_ca_key: koi_crypto::keys::EncryptedKey {
ciphertext: vec![1, 2, 3],
salt: vec![4, 5, 6],
nonce: vec![7, 8, 9],
kdf_params: Default::default(),
},
auth_data: serde_json::json!({"method": "totp", "encrypted_secret": {"ciphertext": [10], "salt": [11], "nonce": [12]}}),
roster_json: r#"{"metadata":{}}"#.to_string(),
ca_cert_pem: "-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----\n".to_string(),
ephemeral_public: None,
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: PromoteResponse = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.encrypted_ca_key.ciphertext, vec![1, 2, 3]);
assert_eq!(parsed.ca_cert_pem.len(), resp.ca_cert_pem.len());
assert!(parsed.ephemeral_public.is_none());
}
#[test]
fn promote_response_with_ephemeral_public_round_trip() {
let server_pub = [99u8; 32];
let resp = PromoteResponse {
encrypted_ca_key: koi_crypto::keys::EncryptedKey {
ciphertext: vec![1, 2, 3],
salt: vec![4, 5, 6],
nonce: vec![7, 8, 9],
kdf_params: Default::default(),
},
auth_data: serde_json::json!({"method": "totp"}),
roster_json: "{}".to_string(),
ca_cert_pem: "cert".to_string(),
ephemeral_public: Some(server_pub),
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: PromoteResponse = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.ephemeral_public, Some(server_pub));
}
#[test]
fn renew_request_is_csr_only_no_key() {
let req = RenewRequest {
hostname: "node-05".to_string(),
csr: "-----BEGIN CERTIFICATE REQUEST-----\nx\n-----END CERTIFICATE REQUEST-----\n"
.to_string(),
};
let json = serde_json::to_string(&req).unwrap();
assert!(
!json.contains("key"),
"renew request must never carry a key"
);
let parsed: RenewRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.hostname, "node-05");
assert!(parsed.csr.contains("CERTIFICATE REQUEST"));
}
#[test]
fn renew_response_carries_cert_not_key() {
let resp = RenewResponse {
hostname: "node-05".to_string(),
service_cert: "-----BEGIN CERTIFICATE-----\nsvc\n-----END CERTIFICATE-----\n"
.to_string(),
ca_cert: "-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----\n".to_string(),
ca_fingerprint: "abc123".to_string(),
expires: "2026-09-15T00:00:00Z".to_string(),
};
let json = serde_json::to_string(&resp).unwrap();
assert!(
!json.contains("PRIVATE KEY"),
"renew response must never carry a private key"
);
let parsed: RenewResponse = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.hostname, "node-05");
assert!(parsed.service_cert.contains("BEGIN CERTIFICATE"));
assert_eq!(parsed.ca_fingerprint, "abc123");
}
#[test]
fn hook_result_omits_none_output() {
let hr = HookResult {
success: false,
command: "bad-cmd".to_string(),
output: None,
};
let json = serde_json::to_string(&hr).unwrap();
assert!(!json.contains("output"));
}
#[test]
fn health_request_serde_round_trip() {
let req = HealthRequest {
hostname: "node-05".to_string(),
pinned_ca_fingerprint: "abcdef".to_string(),
};
let json = serde_json::to_string(&req).unwrap();
let parsed: HealthRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.hostname, "node-05");
assert_eq!(parsed.pinned_ca_fingerprint, "abcdef");
}
#[test]
fn health_response_serde_round_trip() {
let resp = HealthResponse {
valid: true,
ca_fingerprint: "cafp123".to_string(),
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: HealthResponse = serde_json::from_str(&json).unwrap();
assert!(parsed.valid);
assert_eq!(parsed.ca_fingerprint, "cafp123");
}
#[test]
fn certmesh_status_serializes() {
let status = CertmeshStatus {
ca_initialized: true,
ca_locked: false,
ca_fingerprint: Some("abc123".to_string()),
auth_method: None,
enrollment_open: true,
requires_approval: false,
enrollment_state: EnrollmentState::Open,
member_count: 1,
seq: 0,
policy: CertPolicy::default(),
members: vec![MemberSummary {
hostname: "node-01".to_string(),
role: "primary".to_string(),
status: "active".to_string(),
cert_fingerprint: "abc".to_string(),
cert_expires: "2026-03-13T00:00:00Z".to_string(),
}],
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("\"ca_initialized\":true"));
assert!(json.contains("\"member_count\":1"));
assert!(json.contains("\"enrollment_open\":true"));
assert!(json.contains("\"requires_approval\":false"));
}
#[test]
fn certmesh_status_reports_posture_booleans() {
let status = CertmeshStatus {
ca_initialized: true,
ca_locked: false,
ca_fingerprint: Some("fp-org".to_string()),
auth_method: None,
enrollment_open: false,
requires_approval: true,
enrollment_state: EnrollmentState::Closed,
member_count: 0,
seq: 0,
policy: CertPolicy::default(),
members: vec![],
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("\"enrollment_open\":false"));
assert!(json.contains("\"requires_approval\":true"));
assert!(json.contains("\"enrollment_state\":\"closed\""));
}
#[test]
fn create_ca_request_serde_round_trip() {
let req = CreateCaRequest {
passphrase: "hunter2".to_string(),
entropy_hex: "0a1b2c3d".to_string(),
operator: None,
enrollment_open: true,
requires_approval: false,
auto_unlock: true,
totp_secret_hex: None,
};
let json = serde_json::to_string(&req).unwrap();
let parsed: CreateCaRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.passphrase, "hunter2");
assert_eq!(parsed.entropy_hex, "0a1b2c3d");
assert!(parsed.operator.is_none());
assert!(parsed.enrollment_open);
assert!(!parsed.requires_approval);
assert!(parsed.auto_unlock);
}
#[test]
fn create_ca_request_with_operator() {
let req = CreateCaRequest {
passphrase: "pass".to_string(),
entropy_hex: "ff".to_string(),
operator: Some("ops@acme.com".to_string()),
enrollment_open: false,
requires_approval: true,
auto_unlock: false,
totp_secret_hex: None,
};
let json = serde_json::to_string(&req).unwrap();
let parsed: CreateCaRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.operator.as_deref(), Some("ops@acme.com"));
assert!(!parsed.enrollment_open);
assert!(parsed.requires_approval);
assert!(!parsed.auto_unlock);
}
#[test]
fn create_ca_request_omits_none_operator() {
let req = CreateCaRequest {
passphrase: "p".to_string(),
entropy_hex: "aa".to_string(),
operator: None,
enrollment_open: false,
requires_approval: false,
auto_unlock: false,
totp_secret_hex: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("operator"));
}
#[test]
fn create_ca_response_serde_round_trip() {
let resp = CreateCaResponse {
auth_setup: koi_crypto::auth::AuthSetup::Totp {
totp_uri: "otpauth://totp/Koi:admin?secret=ABC123".to_string(),
},
ca_fingerprint: "sha256:abcdef".to_string(),
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: CreateCaResponse = serde_json::from_str(&json).unwrap();
assert!(json.contains("ABC123"));
assert_eq!(parsed.ca_fingerprint, "sha256:abcdef");
}
#[test]
fn unlock_request_serde_round_trip() {
let req = UnlockRequest {
passphrase: "my-secret".to_string(),
};
let json = serde_json::to_string(&req).unwrap();
let parsed: UnlockRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.passphrase, "my-secret");
}
#[test]
fn unlock_response_serde_round_trip() {
let resp = UnlockResponse { success: true };
let json = serde_json::to_string(&resp).unwrap();
let parsed: UnlockResponse = serde_json::from_str(&json).unwrap();
assert!(parsed.success);
}
#[test]
fn rotate_auth_request_serde_round_trip() {
let req = RotateAuthRequest {
passphrase: "rotate-pass".to_string(),
method: None,
};
let json = serde_json::to_string(&req).unwrap();
let parsed: RotateAuthRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.passphrase, "rotate-pass");
}
#[test]
fn rotate_auth_response_serde_round_trip() {
let resp = RotateAuthResponse {
auth_setup: koi_crypto::auth::AuthSetup::Totp {
totp_uri: "otpauth://totp/Koi:admin?secret=NEWBASE32".to_string(),
},
};
let json = serde_json::to_string(&resp).unwrap();
let _: RotateAuthResponse = serde_json::from_str(&json).unwrap();
assert!(json.contains("NEWBASE32"));
}
#[test]
fn audit_log_response_serde_round_trip() {
let resp = AuditLogResponse {
entries: "2026-02-11T00:00:00Z ca_initialized\n".to_string(),
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: AuditLogResponse = serde_json::from_str(&json).unwrap();
assert!(parsed.entries.contains("ca_initialized"));
}
#[test]
fn destroy_response_serde_round_trip() {
let resp = DestroyResponse { destroyed: true };
let json = serde_json::to_string(&resp).unwrap();
let parsed: DestroyResponse = serde_json::from_str(&json).unwrap();
assert!(parsed.destroyed);
}
#[test]
fn certmesh_status_serde_round_trip() {
let status = CertmeshStatus {
ca_initialized: true,
ca_locked: false,
ca_fingerprint: Some("fp-round-trip".to_string()),
auth_method: None,
enrollment_open: true,
requires_approval: true,
enrollment_state: EnrollmentState::Open,
member_count: 2,
seq: 5,
policy: CertPolicy::default(),
members: vec![
MemberSummary {
hostname: "node-01".to_string(),
role: "primary".to_string(),
status: "active".to_string(),
cert_fingerprint: "fp1".to_string(),
cert_expires: "2026-06-01".to_string(),
},
MemberSummary {
hostname: "node-02".to_string(),
role: "member".to_string(),
status: "active".to_string(),
cert_fingerprint: "fp2".to_string(),
cert_expires: "2026-06-01".to_string(),
},
],
};
let json = serde_json::to_string(&status).unwrap();
let parsed: CertmeshStatus = serde_json::from_str(&json).unwrap();
assert!(parsed.ca_initialized);
assert!(!parsed.ca_locked);
assert!(parsed.enrollment_open);
assert!(parsed.requires_approval);
assert_eq!(parsed.member_count, 2);
assert_eq!(parsed.members.len(), 2);
assert_eq!(parsed.members[0].hostname, "node-01");
assert_eq!(parsed.members[1].hostname, "node-02");
}
#[test]
fn certmesh_status_uninitialized_round_trip() {
let status = CertmeshStatus {
ca_initialized: false,
ca_locked: false,
ca_fingerprint: None,
auth_method: None,
enrollment_open: false,
requires_approval: false,
enrollment_state: EnrollmentState::Closed,
member_count: 0,
seq: 0,
policy: CertPolicy::default(),
members: vec![],
};
let json = serde_json::to_string(&status).unwrap();
let parsed: CertmeshStatus = serde_json::from_str(&json).unwrap();
assert!(!parsed.ca_initialized);
assert_eq!(parsed.member_count, 0);
assert!(parsed.members.is_empty());
}
#[test]
fn enrollment_summary_serializes() {
let summary = EnrollmentSummary {
enrollment_state: EnrollmentState::Open,
};
let json = serde_json::to_string(&summary).unwrap();
assert!(json.contains("\"enrollment_state\":\"open\""));
}
}