1use serde::{Deserialize, Serialize};
9
10use wist_contracts::API_VERSION_V1;
11
12pub use wist_contracts::enrollment::{
13 AgentIdentity, AgentIdentityStatus, CredentialBundle, HostProfile, InitialConfig, PolicyBinding,
14};
15
16pub const SUBMIT_ENROLLMENT_REQUEST_KIND: &str = "submit_enrollment_request";
17pub const RENEW_AGENT_CREDENTIAL_KIND: &str = "renew_agent_credential";
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct EnrollmentRequest {
22 pub api_version: String,
23 pub kind: String,
24 pub token: String,
25 pub credential_request: String,
26 pub certificate_signing_request: String,
32 pub host_profile: HostProfile,
33 pub capability_summary: String,
34 pub requested_at: String,
35}
36
37impl EnrollmentRequest {
38 pub fn new(
39 token: String,
40 credential_request: String,
41 certificate_signing_request: String,
42 host_profile: HostProfile,
43 capability_summary: String,
44 requested_at: String,
45 ) -> Self {
46 Self {
47 api_version: API_VERSION_V1.to_string(),
48 kind: SUBMIT_ENROLLMENT_REQUEST_KIND.to_string(),
49 token,
50 credential_request,
51 certificate_signing_request,
52 host_profile,
53 capability_summary,
54 requested_at,
55 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct EnrollmentEnvelope {
62 pub result: EnrollmentOutcome,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct EnrollmentOutcome {
68 pub status: EnrollmentStatus,
69 pub reason_code: Option<String>,
70 pub agent_id: Option<String>,
71 pub instance_id: Option<String>,
72 pub issued_identity: Option<AgentIdentity>,
73 pub credential_bundle: Option<CredentialBundle>,
74 pub initial_config: Option<InitialConfig>,
75 pub policy_binding: Option<PolicyBinding>,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79pub enum EnrollmentStatus {
80 #[serde(rename = "accepted")]
81 Accepted,
82 #[serde(rename = "rejected")]
83 Rejected,
84 #[serde(rename = "pending_review")]
85 PendingReview,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct CredentialRenewal {
91 pub api_version: String,
92 pub kind: String,
93 pub agent_id: String,
94 pub instance_id: String,
95 pub credential_request: String,
96 pub certificate_signing_request: String,
99 pub requested_at: String,
100}
101
102impl CredentialRenewal {
103 pub fn new(
104 agent_id: String,
105 instance_id: String,
106 credential_request: String,
107 certificate_signing_request: String,
108 requested_at: String,
109 ) -> Self {
110 Self {
111 api_version: API_VERSION_V1.to_string(),
112 kind: RENEW_AGENT_CREDENTIAL_KIND.to_string(),
113 agent_id,
114 instance_id,
115 credential_request,
116 certificate_signing_request,
117 requested_at,
118 }
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct CredentialRenewed {
125 pub credential_bundle: CredentialBundle,
126}
127
128#[cfg(test)]
129mod tests {
130 use super::{
131 CredentialRenewal, EnrollmentEnvelope, EnrollmentOutcome, EnrollmentRequest,
132 EnrollmentStatus, HostProfile, RENEW_AGENT_CREDENTIAL_KIND,
133 };
134
135 #[test]
136 fn enrollment_result_status_uses_wire_names() {
137 let decoded: EnrollmentEnvelope =
138 serde_json::from_str(r#"{"result":{"status":"accepted","reason_code":null,"agent_id":"agent-1","instance_id":"host-a","issued_identity":null,"credential_bundle":null,"initial_config":null,"policy_binding":null}}"#)
139 .expect("decode");
140
141 assert_eq!(decoded.result.status, EnrollmentStatus::Accepted);
142
143 let encoded = serde_json::to_string(&EnrollmentOutcome {
144 status: EnrollmentStatus::PendingReview,
145 reason_code: Some("manual_review".to_string()),
146 agent_id: None,
147 instance_id: None,
148 issued_identity: None,
149 credential_bundle: None,
150 initial_config: None,
151 policy_binding: None,
152 })
153 .expect("encode");
154
155 assert!(encoded.contains("\"pending_review\""));
156 }
157
158 #[test]
159 fn renew_agent_credential_uses_stable_wire_kind() {
160 let request = CredentialRenewal::new(
161 "agent-a".to_string(),
162 "instance-a".to_string(),
163 "csr".to_string(),
164 "-----BEGIN CERTIFICATE REQUEST-----\nA\n".to_string(),
165 "2026-07-29T00:00:00Z".to_string(),
166 );
167 let encoded = serde_json::to_string(&request).expect("encode");
168
169 assert!(encoded.contains(&format!("\"kind\":\"{RENEW_AGENT_CREDENTIAL_KIND}\"")));
170 assert!(encoded.contains("certificate_signing_request"));
171
172 let decoded: CredentialRenewal = serde_json::from_str(&encoded).expect("decode");
173 assert_eq!(decoded.api_version, "v1");
174 assert_eq!(decoded.kind, RENEW_AGENT_CREDENTIAL_KIND);
175
176 let mut without_csr = serde_json::to_value(&request).expect("encode");
178 without_csr
179 .as_object_mut()
180 .expect("object")
181 .remove("certificate_signing_request");
182 assert!(serde_json::from_value::<CredentialRenewal>(without_csr).is_err());
183 }
184
185 #[test]
186 fn enrollment_status_uses_wire_names_and_rejects_unknown_variants() {
187 for (status, name) in [
188 (EnrollmentStatus::Accepted, "accepted"),
189 (EnrollmentStatus::Rejected, "rejected"),
190 (EnrollmentStatus::PendingReview, "pending_review"),
191 ] {
192 assert_eq!(
193 serde_json::to_string(&status).unwrap(),
194 format!("\"{name}\"")
195 );
196 }
197 assert!(serde_json::from_str::<EnrollmentStatus>("\"unknown\"").is_err());
198 }
199
200 #[test]
201 fn enrollment_request_carries_a_required_csr() {
202 let profile = sample_host_profile();
203 let request = EnrollmentRequest::new(
204 "token-a".to_string(),
205 "csr".to_string(),
206 "-----BEGIN CERTIFICATE REQUEST-----\nA\n-----END CERTIFICATE REQUEST-----\n"
207 .to_string(),
208 profile,
209 "wist-agentd:test".to_string(),
210 "2026-09-28T00:00:00Z".to_string(),
211 );
212 let json = serde_json::to_string(&request).expect("encode");
213 assert!(json.contains("certificate_signing_request"));
214 let back: EnrollmentRequest = serde_json::from_str(&json).expect("decode");
215 assert_eq!(back, request);
216
217 let mut without_csr = serde_json::to_value(&request).expect("encode");
219 without_csr
220 .as_object_mut()
221 .expect("object")
222 .remove("certificate_signing_request");
223 assert!(serde_json::from_value::<EnrollmentRequest>(without_csr).is_err());
224 }
225
226 fn sample_host_profile() -> HostProfile {
227 HostProfile {
228 node_id: "node-1".to_string(),
229 hostname: "host-1".to_string(),
230 os: "linux".to_string(),
231 arch: "x86_64".to_string(),
232 machine_id: "mid-1".to_string(),
233 cloud_instance_id: None,
234 k8s_node_uid: None,
235 ip_addresses: vec!["10.0.0.1".to_string()],
236 }
237 }
238
239 #[test]
240 fn an_outcome_without_optional_payloads_decodes() {
241 let json = r#"{"result":{"status":"rejected","reason_code":"bad_token"}}"#;
243 let envelope: EnrollmentEnvelope = serde_json::from_str(json).expect("decode");
244 assert_eq!(envelope.result.status, EnrollmentStatus::Rejected);
245 assert!(envelope.result.issued_identity.is_none());
246 assert!(envelope.result.credential_bundle.is_none());
247 }
248}