1use base64::prelude::*;
18use serde_json::Value;
19use uuid::Uuid;
20
21use crate::admin::ops::{ExpiringEntry, OrderDetail};
22use crate::sqlite::account::{Account, pubkey_fingerprint};
23use crate::sqlite::admin_session::AdminSession;
24use crate::sqlite::admin_user::AdminUser;
25use crate::sqlite::eab::Eab;
26use crate::sqlite::order::{Order, rfc3339};
27
28#[must_use]
34pub fn profile_base_url(base_url: &str, profile: &str) -> String {
35 format!(
36 "{}{}/{profile}",
37 base_url.trim_end_matches('/'),
38 crate::PROFILE_PREFIX
39 )
40}
41
42#[must_use]
49pub fn render_account_json(account: &Account, base_url: &str) -> Value {
50 let mut object = account
51 .to_json(&profile_base_url(base_url, &account.profile))
52 .as_object()
53 .cloned()
54 .unwrap_or_default();
55 object.insert("id".to_string(), Value::String(account.id.to_string()));
56 object.insert(
57 "profile".to_string(),
58 Value::String(account.profile.clone()),
59 );
60 object.insert(
61 "createdAt".to_string(),
62 Value::String(rfc3339(account.created_at)),
63 );
64 object.insert(
65 "pubkeyFingerprint".to_string(),
66 Value::String(pubkey_fingerprint(&account.pubkey)),
67 );
68 if let Some(seen) = account.last_seen_at {
69 object.insert("lastSeenAt".to_string(), Value::String(rfc3339(seen)));
70 }
71 for (key, value) in [
72 ("createdIp", account.created_ip.as_ref()),
73 ("createdPtr", account.created_ptr.as_ref()),
74 ("lastSeenIp", account.last_seen_ip.as_ref()),
75 ("lastSeenPtr", account.last_seen_ptr.as_ref()),
76 ] {
77 if let Some(value) = value {
78 object.insert(key.to_string(), Value::String(value.clone()));
79 }
80 }
81 Value::Object(object)
82}
83
84#[must_use]
86pub fn render_order_json(order: &Order, base_url: &str, authz_ids: &[Uuid]) -> Value {
87 let mut object = order
88 .to_json(&profile_base_url(base_url, &order.profile), authz_ids)
89 .as_object()
90 .cloned()
91 .unwrap_or_default();
92 object.insert("id".to_string(), Value::String(order.id.to_string()));
93 object.insert("profile".to_string(), Value::String(order.profile.clone()));
94 object.insert(
99 "accountId".to_string(),
100 Value::String(order.account_id.to_string()),
101 );
102 object.insert(
103 "createdAt".to_string(),
104 Value::String(rfc3339(order.created_at)),
105 );
106 if let Some(serial) = order.cert_serial.as_ref() {
115 object.insert("certSerial".to_string(), Value::String(serial.clone()));
116 }
117 if let Some(not_after) = order.cert_not_after.filter(|value| *value >= 0) {
125 object.insert(
126 "certNotAfter".to_string(),
127 Value::String(rfc3339(not_after)),
128 );
129 }
130 if let Some(revoked_at) = order.revoked_at {
131 object.insert("revokedAt".to_string(), Value::String(rfc3339(revoked_at)));
132 if let Some(reason) = order.revocation_reason {
133 object.insert("revocationReason".to_string(), Value::from(reason));
134 }
135 }
136 Value::Object(object)
137}
138
139#[must_use]
141pub fn render_order_detail_json(detail: &OrderDetail, base_url: &str) -> Value {
142 let authz_ids: Vec<Uuid> = detail.authorizations.iter().map(|(a, _)| a.id).collect();
143 let profile_base = profile_base_url(base_url, &detail.order.profile);
144 let authorizations: Vec<Value> = detail
145 .authorizations
146 .iter()
147 .map(|(a, c)| a.to_json(&profile_base, c))
148 .collect();
149 let mut order = render_order_json(&detail.order, base_url, &authz_ids);
150 if let (Some(object), Some(pem)) = (order.as_object_mut(), detail.order.certificate.as_ref()) {
161 object.insert("certificatePem".to_string(), Value::String(pem.clone()));
162 }
163
164 let mut root = serde_json::Map::new();
165 root.insert("order".to_string(), order);
166 root.insert("authorizations".to_string(), Value::Array(authorizations));
167 Value::Object(root)
168}
169
170#[must_use]
184pub fn render_expiring_json(entry: &ExpiringEntry) -> Value {
185 let order = &entry.order;
186 let mut object = serde_json::Map::new();
187 object.insert("orderId".to_string(), Value::String(order.id.to_string()));
188 object.insert("profile".to_string(), Value::String(order.profile.clone()));
189 object.insert(
190 "accountId".to_string(),
191 Value::String(order.account_id.to_string()),
192 );
193 object.insert(
194 "certSerial".to_string(),
195 Value::String(order.cert_serial.clone().unwrap_or_default()),
196 );
197 object.insert(
198 "identifiers".to_string(),
199 Value::Array(
200 order
201 .identifiers
202 .iter()
203 .map(|identifier| Value::String(identifier.value.clone()))
204 .collect(),
205 ),
206 );
207 object.insert(
208 "notAfter".to_string(),
209 Value::String(rfc3339(order.cert_not_after.unwrap_or_default())),
210 );
211 object.insert(
212 "daysRemaining".to_string(),
213 Value::from(entry.days_remaining),
214 );
215 if let Some(superseded) = &entry.superseded_by {
216 object.insert(
217 "supersededBy".to_string(),
218 serde_json::json!({
219 "orderId": superseded.order_id,
220 "certSerial": superseded.cert_serial,
221 "notAfter": rfc3339(superseded.not_after),
222 "via": superseded.via,
223 }),
224 );
225 }
226 Value::Object(object)
227}
228
229#[must_use]
231pub fn render_eab_json(eab: &Eab) -> Value {
232 eab.to_json()
233}
234
235#[must_use]
237pub fn render_eab_created_json(eab: &Eab) -> Value {
238 let mut object = eab.to_json().as_object().cloned().unwrap_or_default();
239 object.insert(
240 "hmacKey".to_string(),
241 Value::String(BASE64_URL_SAFE_NO_PAD.encode(&eab.secret)),
242 );
243 Value::Object(object)
244}
245
246#[must_use]
252pub fn render_admin_user_json(user: &AdminUser) -> Value {
253 user.to_json()
254}
255
256#[must_use]
266pub fn render_admin_user_detail_json(user: &AdminUser, recovery_codes_remaining: i64) -> Value {
267 let mut object = render_admin_user_json(user)
268 .as_object()
269 .cloned()
270 .unwrap_or_default();
271 object.insert(
272 "enrolmentPending".to_string(),
273 Value::Bool(user.has_pending_totp()),
274 );
275 object.insert(
276 "recoveryCodesRemaining".to_string(),
277 Value::from(recovery_codes_remaining),
278 );
279 Value::Object(object)
280}
281
282#[must_use]
284pub fn render_admin_session_json(session: &AdminSession) -> Value {
285 session.to_json()
286}
287
288#[must_use]
296pub fn render_nonce_stats_json(count: i64, ttl_seconds: u64) -> Value {
297 serde_json::json!({
298 "count": count,
299 "ttlSeconds": ttl_seconds,
300 })
301}
302
303pub struct ProfileSummary {
316 pub name: String,
317 pub base_url: String,
318 pub challenge_bypass: bool,
319 pub eab_enabled: bool,
320}
321
322impl ProfileSummary {
323 #[must_use]
325 pub fn mounted(profile: &crate::Profile) -> Self {
326 Self {
327 name: profile.name.clone(),
328 base_url: profile.base_url.clone(),
329 challenge_bypass: profile.challenges.is_bypassed(),
330 eab_enabled: profile.eab.enabled,
331 }
332 }
333
334 #[must_use]
341 pub fn configured(base_url: &str, profile: &crate::config::ProfileConfig) -> Self {
342 Self {
343 name: profile.name.clone(),
344 base_url: profile_base_url(base_url, &profile.name),
345 challenge_bypass: profile.sections.challenge.bypass,
346 eab_enabled: profile.sections.eab.enabled,
347 }
348 }
349
350 #[must_use]
352 pub fn directory_url(&self) -> String {
353 format!("{}{}", self.base_url, crate::routes::DIRECTORY)
354 }
355}
356
357#[must_use]
359pub fn render_profile_json(profile: &ProfileSummary) -> Value {
360 serde_json::json!({
361 "name": profile.name,
362 "baseUrl": profile.base_url,
363 "directory": profile.directory_url(),
364 "challengeBypass": profile.challenge_bypass,
365 "eabEnabled": profile.eab_enabled,
366 })
367}
368
369#[cfg(test)]
370mod tests {
371 use std::sync::Arc;
372
373 use super::*;
374 use crate::admin::ops::load_order_detail;
375 use crate::audit::ClientContext;
376 use crate::sqlite::authz::{Authorization, Challenge};
377 use crate::sqlite::db::Database;
378 use crate::sqlite::order::Identifier;
379 use crate::sqlite::status::OrderStatus;
380 use crate::testutil::{
381 account_id, account_seen_from, admin_session_fixture, admin_user_fixture, client_context,
382 order_fixture,
383 };
384
385 #[tokio::test]
386 async fn render_account_json_includes_id_and_base_fields() {
387 let db = Arc::new(Database::connect_in_memory().await.unwrap());
388 let account = account_seen_from(
389 &[1u8, 2, 3],
390 &client_context(Some("203.0.113.7"), Some("host.example.com")),
391 &db,
392 )
393 .await;
394
395 let json = render_account_json(&account, "http://localhost:3000");
396 assert_eq!(json["id"], account.id.to_string());
397 assert_eq!(json["status"], "valid");
398 assert!(
399 json["orders"]
400 .as_str()
401 .unwrap()
402 .contains(&account.id.to_string())
403 );
404 assert!(json["pubkeyFingerprint"].is_string());
405 assert_eq!(json["createdIp"], "203.0.113.7");
406 assert_eq!(json["createdPtr"], "host.example.com");
407 assert_eq!(json["lastSeenIp"], "203.0.113.7");
408 assert_eq!(json["lastSeenPtr"], "host.example.com");
409 assert!(json["lastSeenAt"].as_str().unwrap().contains('T'));
410 }
411
412 #[tokio::test]
415 async fn render_account_json_omits_the_traceability_members_it_has_no_value_for() {
416 let db = Arc::new(Database::connect_in_memory().await.unwrap());
417 let account = account_seen_from(&[1u8, 2, 3], &ClientContext::default(), &db).await;
418
419 let json = render_account_json(&account, "http://localhost:3000");
420 let object = json.as_object().unwrap();
421 for key in ["createdIp", "createdPtr", "lastSeenIp", "lastSeenPtr"] {
422 assert!(!object.contains_key(key), "{key} in {json}");
423 }
424 }
425
426 #[test]
427 fn render_order_json_includes_id_and_authorizations() {
428 let account = crate::sqlite::id::mint();
429 let authz = crate::sqlite::id::mint();
430 let order = order_fixture(account, OrderStatus::Pending);
431 let json = render_order_json(&order, "http://localhost:3000", &[authz]);
432 assert_eq!(json["id"], order.id.to_string());
433 assert_eq!(json["profile"], "default");
436 assert_eq!(json["accountId"], account.to_string());
439 assert_eq!(
440 json["authorizations"],
441 serde_json::json!([format!(
442 "http://localhost:3000/profile/default/authz/{authz}"
443 )])
444 );
445 }
446
447 #[tokio::test]
448 async fn render_order_detail_json_nests_authorizations_and_challenges() {
449 let db = Arc::new(Database::connect_in_memory().await.unwrap());
450 let acct = account_id(&db).await;
451 let order = Order::create(
452 "default",
453 acct,
454 vec![Identifier::dns("example.com")],
455 crate::sqlite::nonce::now_secs() + 3600,
456 None,
457 None,
458 &db,
459 )
460 .await
461 .unwrap();
462 let authz = Authorization::create(
463 order.id,
464 Identifier::dns("example.com"),
465 crate::sqlite::nonce::now_secs() + 3600,
466 &db,
467 )
468 .await
469 .unwrap();
470 Challenge::create(authz.id, "http-01", &db).await.unwrap();
471
472 let detail = load_order_detail(order.id.to_string().as_str(), db)
473 .await
474 .unwrap()
475 .unwrap();
476 let json = render_order_detail_json(&detail, "http://localhost:3000");
477 assert_eq!(json["order"]["id"], order.id.to_string());
478 assert_eq!(json["authorizations"].as_array().unwrap().len(), 1);
479 assert_eq!(
480 json["authorizations"][0]["challenges"]
481 .as_array()
482 .unwrap()
483 .len(),
484 1
485 );
486 }
487
488 #[tokio::test]
489 async fn render_eab_created_json_includes_the_hmac_key_and_line_json_does_not() {
490 let db = Arc::new(Database::connect_in_memory().await.unwrap());
491 let eab = Eab::create(None, None, &db).await.unwrap();
492
493 let created = render_eab_created_json(&eab);
494 assert!(created["hmacKey"].is_string());
495
496 let listed = render_eab_json(&eab);
497 assert!(listed.get("hmacKey").is_none());
498 }
499
500 #[test]
501 fn render_order_json_revoked_includes_reason_and_time() {
502 let mut order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Valid);
503 order.revoked_at = Some(1700000000);
504 order.revocation_reason = Some(1);
505 let json = render_order_json(&order, "http://localhost:3000", &[]);
506 assert_eq!(json["revokedAt"].as_str().unwrap().len(), 20);
507 assert_eq!(json["revocationReason"], 1);
508 }
509
510 #[test]
511 fn render_order_json_carries_the_cert_serial_and_omits_it_when_unissued() {
512 let mut order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Valid);
516 order.cert_serial = Some("03a7f1c9".to_string());
517 let json = render_order_json(&order, "http://localhost:3000", &[]);
518 assert_eq!(json["certSerial"], "03a7f1c9");
519
520 let unissued = order_fixture(crate::sqlite::id::mint(), OrderStatus::Pending);
523 let json = render_order_json(&unissued, "http://localhost:3000", &[]);
524 assert!(json.get("certSerial").is_none());
525 }
526
527 #[test]
528 fn render_admin_user_json_omits_every_secret() {
529 let mut user = admin_user_fixture();
530 user.totp_secret = Some(vec![9, 9, 9]);
531 let json = render_admin_user_json(&user);
532 let rendered = json.to_string();
533 assert!(!rendered.contains("pbkdf2"));
534 assert!(!rendered.contains("totpSecret"));
535 assert_eq!(json["username"], "alice");
536 assert_eq!(json["totpEnabled"], true);
537 }
538
539 #[test]
540 fn render_admin_session_json_omits_the_hash_and_the_csrf_token() {
541 let json = render_admin_session_json(&admin_session_fixture());
542 let rendered = json.to_string();
543 assert!(!rendered.contains("0123456789abcdef0123456789abcdef"));
544 assert!(!rendered.contains("the-csrf-token"));
545 assert_eq!(json["id"], "01234567");
546 assert_eq!(json["state"], "active");
547 }
548}