1use serde::{Deserialize, Serialize};
8
9use super::{AgentIdentity, CredentialBundle, HostProfile, InitialConfig, PolicyBinding};
10use wist_contracts::API_VERSION_V1;
11
12pub const SUBMIT_ENROLLMENT_REQUEST_KIND: &str = "submit_enrollment_request";
13pub const RENEW_AGENT_CREDENTIAL_KIND: &str = "renew_agent_credential";
14
15pub const API_VERSION: &str = API_VERSION_V1;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct EnrollmentRequest {
21 pub api_version: String,
22 pub kind: String,
23 pub token: String,
24 pub credential_request: String,
25 pub certificate_signing_request: String,
31 pub host_profile: HostProfile,
32 pub capability_summary: String,
33 pub requested_at: String,
34}
35
36impl EnrollmentRequest {
37 pub fn new(
38 token: String,
39 credential_request: String,
40 certificate_signing_request: String,
41 host_profile: HostProfile,
42 capability_summary: String,
43 requested_at: String,
44 ) -> Self {
45 Self {
46 api_version: API_VERSION.to_string(),
47 kind: SUBMIT_ENROLLMENT_REQUEST_KIND.to_string(),
48 token,
49 credential_request,
50 certificate_signing_request,
51 host_profile,
52 capability_summary,
53 requested_at,
54 }
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct EnrollmentEnvelope {
61 pub result: EnrollmentOutcome,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct EnrollmentOutcome {
67 pub status: EnrollmentStatus,
68 pub reason_code: Option<String>,
69 pub agent_id: Option<String>,
70 pub instance_id: Option<String>,
71 pub issued_identity: Option<AgentIdentity>,
72 pub credential_bundle: Option<CredentialBundle>,
73 pub initial_config: Option<InitialConfig>,
74 pub policy_binding: Option<PolicyBinding>,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78pub enum EnrollmentStatus {
79 #[serde(rename = "accepted")]
80 Accepted,
81 #[serde(rename = "rejected")]
82 Rejected,
83 #[serde(rename = "pending_review")]
84 PendingReview,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct CredentialRenewal {
90 pub api_version: String,
91 pub kind: String,
92 pub agent_id: String,
93 pub instance_id: String,
94 pub credential_request: String,
95 pub certificate_signing_request: String,
98 pub requested_at: String,
99}
100
101impl CredentialRenewal {
102 pub fn new(
103 agent_id: String,
104 instance_id: String,
105 credential_request: String,
106 certificate_signing_request: String,
107 requested_at: String,
108 ) -> Self {
109 Self {
110 api_version: API_VERSION.to_string(),
111 kind: RENEW_AGENT_CREDENTIAL_KIND.to_string(),
112 agent_id,
113 instance_id,
114 credential_request,
115 certificate_signing_request,
116 requested_at,
117 }
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct CredentialRenewed {
124 pub credential_bundle: CredentialBundle,
125}
126
127#[cfg(test)]
128mod tests {
129 use super::{
130 CredentialRenewal, EnrollmentEnvelope, EnrollmentOutcome, EnrollmentRequest,
131 EnrollmentStatus, HostProfile, RENEW_AGENT_CREDENTIAL_KIND,
132 };
133
134 #[test]
135 fn enrollment_result_status_uses_wire_names() {
136 let decoded: EnrollmentEnvelope =
137 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}}"#)
138 .expect("decode");
139
140 assert_eq!(decoded.result.status, EnrollmentStatus::Accepted);
141
142 let encoded = serde_json::to_string(&EnrollmentOutcome {
143 status: EnrollmentStatus::PendingReview,
144 reason_code: Some("manual_review".to_string()),
145 agent_id: None,
146 instance_id: None,
147 issued_identity: None,
148 credential_bundle: None,
149 initial_config: None,
150 policy_binding: None,
151 })
152 .expect("encode");
153
154 assert!(encoded.contains("\"pending_review\""));
155 }
156
157 #[test]
158 fn renew_agent_credential_uses_stable_wire_kind() {
159 let request = CredentialRenewal::new(
160 "agent-a".to_string(),
161 "instance-a".to_string(),
162 "csr".to_string(),
163 "-----BEGIN CERTIFICATE REQUEST-----\nA\n".to_string(),
164 "2026-07-29T00:00:00Z".to_string(),
165 );
166 let encoded = serde_json::to_string(&request).expect("encode");
167
168 assert!(encoded.contains(&format!("\"kind\":\"{RENEW_AGENT_CREDENTIAL_KIND}\"")));
169 assert!(encoded.contains("certificate_signing_request"));
170
171 let decoded: CredentialRenewal = serde_json::from_str(&encoded).expect("decode");
172 assert_eq!(decoded.api_version, "v1");
173 assert_eq!(decoded.kind, RENEW_AGENT_CREDENTIAL_KIND);
174
175 let mut without_csr = serde_json::to_value(&request).expect("encode");
177 without_csr
178 .as_object_mut()
179 .expect("object")
180 .remove("certificate_signing_request");
181 assert!(serde_json::from_value::<CredentialRenewal>(without_csr).is_err());
182 }
183
184 #[test]
185 fn enrollment_status_uses_wire_names_and_rejects_unknown_variants() {
186 for (status, name) in [
187 (EnrollmentStatus::Accepted, "accepted"),
188 (EnrollmentStatus::Rejected, "rejected"),
189 (EnrollmentStatus::PendingReview, "pending_review"),
190 ] {
191 assert_eq!(
192 serde_json::to_string(&status).unwrap(),
193 format!("\"{name}\"")
194 );
195 }
196 assert!(serde_json::from_str::<EnrollmentStatus>("\"unknown\"").is_err());
197 }
198
199 #[test]
200 fn enrollment_request_carries_a_required_csr() {
201 let profile = sample_host_profile();
202 let request = EnrollmentRequest::new(
203 "token-a".to_string(),
204 "csr".to_string(),
205 "-----BEGIN CERTIFICATE REQUEST-----\nA\n-----END CERTIFICATE REQUEST-----\n"
206 .to_string(),
207 profile,
208 "wist-agentd:test".to_string(),
209 "2026-09-28T00:00:00Z".to_string(),
210 );
211 let json = serde_json::to_string(&request).expect("encode");
212 assert!(json.contains("certificate_signing_request"));
213 let back: EnrollmentRequest = serde_json::from_str(&json).expect("decode");
214 assert_eq!(back, request);
215
216 let mut without_csr = serde_json::to_value(&request).expect("encode");
218 without_csr
219 .as_object_mut()
220 .expect("object")
221 .remove("certificate_signing_request");
222 assert!(serde_json::from_value::<EnrollmentRequest>(without_csr).is_err());
223 }
224
225 fn sample_host_profile() -> HostProfile {
226 HostProfile {
227 node_id: "node-1".to_string(),
228 hostname: "host-1".to_string(),
229 os: "linux".to_string(),
230 arch: "x86_64".to_string(),
231 machine_id: "mid-1".to_string(),
232 cloud_instance_id: None,
233 k8s_node_uid: None,
234 ip_addresses: vec!["10.0.0.1".to_string()],
235 }
236 }
237
238 #[test]
239 fn an_outcome_without_optional_payloads_decodes() {
240 let json = r#"{"result":{"status":"rejected","reason_code":"bad_token"}}"#;
242 let envelope: EnrollmentEnvelope = serde_json::from_str(json).expect("decode");
243 assert_eq!(envelope.result.status, EnrollmentStatus::Rejected);
244 assert!(envelope.result.issued_identity.is_none());
245 assert!(envelope.result.credential_bundle.is_none());
246 }
247
248 #[test]
250 fn new_fills_the_wire_version_and_kind() {
251 let request = EnrollmentRequest::new(
252 "t".to_string(),
253 "cr".to_string(),
254 "csr".to_string(),
255 sample_host_profile(),
256 "caps".to_string(),
257 "2026-09-28T00:00:00Z".to_string(),
258 );
259 assert_eq!(request.api_version, super::API_VERSION);
260 assert_eq!(request.kind, super::SUBMIT_ENROLLMENT_REQUEST_KIND);
261 }
262
263 #[test]
265 fn enrollment_request_wire_keys_are_stable() {
266 let request = EnrollmentRequest::new(
267 "t".to_string(),
268 "cr".to_string(),
269 "csr".to_string(),
270 sample_host_profile(),
271 "caps".to_string(),
272 "2026-09-28T00:00:00Z".to_string(),
273 );
274 let value = serde_json::to_value(&request).expect("encode");
275 let mut keys: Vec<&str> = value
276 .as_object()
277 .expect("object")
278 .keys()
279 .map(String::as_str)
280 .collect();
281 keys.sort_unstable();
282 assert_eq!(
283 keys,
284 [
285 "api_version",
286 "capability_summary",
287 "certificate_signing_request",
288 "credential_request",
289 "host_profile",
290 "kind",
291 "requested_at",
292 "token",
293 ]
294 );
295 }
296
297 #[test]
299 fn enrollment_outcome_wire_keys_are_stable() {
300 let outcome = EnrollmentOutcome {
301 status: EnrollmentStatus::Accepted,
302 reason_code: None,
303 agent_id: None,
304 instance_id: None,
305 issued_identity: None,
306 credential_bundle: None,
307 initial_config: None,
308 policy_binding: None,
309 };
310 let value = serde_json::to_value(&outcome).expect("encode");
311 let mut keys: Vec<&str> = value
312 .as_object()
313 .expect("object")
314 .keys()
315 .map(String::as_str)
316 .collect();
317 keys.sort_unstable();
318 assert_eq!(
319 keys,
320 [
321 "agent_id",
322 "credential_bundle",
323 "initial_config",
324 "instance_id",
325 "issued_identity",
326 "policy_binding",
327 "reason_code",
328 "status",
329 ]
330 );
331 }
332}