1use base64::prelude::*;
23
24use super::style::Palette;
25use super::window::Window;
26use crate::admin::ProfileSummary;
27use crate::admin::ops::{ExpiringEntry, OrderDetail};
28use crate::sqlite::account::{Account, pubkey_fingerprint};
29use crate::sqlite::admin_session::AdminSession;
30use crate::sqlite::admin_user::AdminUser;
31use crate::sqlite::audit::AuditEntry;
32use crate::sqlite::eab::Eab;
33use crate::sqlite::order::{Order, rfc3339};
34
35fn render_client(ip: Option<&String>, ptr: Option<&String>) -> String {
43 match (ip, ptr) {
44 (Some(ip), Some(ptr)) => format!("{ip} ({ptr})"),
45 (Some(ip), None) => ip.clone(),
46 _ => "-".to_string(),
47 }
48}
49
50#[must_use]
56pub fn render_account_line(account: &Account, palette: Palette) -> String {
57 format!(
58 "{} {:<12} {} {:<40} {} {}",
59 account.id,
60 account.profile,
61 palette.status(&format!("{:<11}", account.status)),
62 render_client(
63 account.last_seen_ip.as_ref(),
64 account.last_seen_ptr.as_ref()
65 ),
66 if account.contact.is_empty() {
67 "-".to_string()
68 } else {
69 account.contact.join(",")
70 },
71 rfc3339(account.created_at),
72 )
73}
74
75#[must_use]
81pub fn render_account_detail_text(account: &Account, palette: Palette) -> String {
82 let mut out = format!(
86 "id {}\nprofile {}\nstatus {}\npubkey {}\ncreated {}\n",
87 account.id,
88 account.profile,
89 palette.status(&account.status),
90 pubkey_fingerprint(&account.pubkey),
91 rfc3339(account.created_at),
92 );
93 if !account.contact.is_empty() {
94 out.push_str(&format!("contact {}\n", account.contact.join(",")));
95 }
96 if let Some(agreed) = account.terms_of_service_agreed {
97 out.push_str(&format!("terms {agreed}\n"));
98 }
99 if let Some(seen) = account.last_seen_at {
100 out.push_str(&format!("last_seen {}\n", rfc3339(seen)));
101 }
102 if let Some(kid) = account.eab_kid {
103 out.push_str(&format!("eab_kid {kid}\n"));
104 }
105 for (label, value) in [
106 ("created_ip", account.created_ip.as_ref()),
107 ("created_ptr", account.created_ptr.as_ref()),
108 ("last_seen_ip", account.last_seen_ip.as_ref()),
109 ("last_seen_ptr", account.last_seen_ptr.as_ref()),
110 ] {
111 if let Some(value) = value {
112 out.push_str(&format!("{label:<13} {value}\n"));
113 }
114 }
115 out
116}
117
118fn paint_event(event: &str, width: usize, palette: Palette) -> String {
128 let padded = format!("{event:<width$}");
129 if event.ends_with("_failed") {
130 palette.bad(&padded)
131 } else {
132 padded
133 }
134}
135
136#[must_use]
141pub fn render_audit_line(entry: &AuditEntry, palette: Palette) -> String {
142 let actor = match &entry.actor_id {
143 Some(id) => format!("{}:{id}", entry.actor_kind),
144 None => entry.actor_kind.clone(),
145 };
146 let client = render_client(entry.client_ip.as_ref(), entry.client_ptr.as_ref());
147 let mut line = format!(
148 "{:<8} {} {} {:<12} {:<24} {:<40} {}",
149 entry.id,
150 rfc3339(entry.created_at),
151 paint_event(&entry.event, 26, palette),
152 entry.profile,
153 actor,
154 client,
155 entry.identifiers.join(","),
156 );
157 if let Some(reason) = &entry.reason {
158 line.push_str(&format!(" reason={reason}"));
159 }
160 line
161}
162
163#[must_use]
166pub fn render_audit_detail_text(entry: &AuditEntry, palette: Palette) -> String {
167 let mut out = format!(
168 "id {}\ncreated {}\nevent {}\noutcome {}\nprofile {}\nactor {}\n",
169 entry.id,
170 rfc3339(entry.created_at),
171 paint_event(&entry.event, 0, palette),
172 palette.status(&entry.outcome),
173 entry.profile,
174 match &entry.actor_id {
175 Some(id) => format!("{}:{id}", entry.actor_kind),
176 None => entry.actor_kind.clone(),
177 },
178 );
179 for (label, value) in [
180 ("account", entry.account_id.as_ref()),
181 ("order", entry.order_id.as_ref()),
182 ("serial", entry.cert_serial.as_ref()),
183 ("client_ip", entry.client_ip.as_ref()),
184 ("client_ptr", entry.client_ptr.as_ref()),
185 ("user_agent", entry.user_agent.as_ref()),
186 ("request_id", entry.request_id.as_ref()),
187 ("reason", entry.reason.as_ref()),
188 ("detail", entry.detail.as_ref()),
189 ] {
190 if let Some(value) = value {
191 out.push_str(&format!("{label:<12} {value}\n"));
192 }
193 }
194 if !entry.identifiers.is_empty() {
195 out.push_str(&format!("identifiers {}\n", entry.identifiers.join(",")));
196 }
197 out
198}
199
200#[must_use]
202pub fn render_order_line(order: &Order, palette: Palette) -> String {
203 let identifiers = order
204 .identifiers
205 .iter()
206 .map(|i| i.value.as_str())
207 .collect::<Vec<_>>()
208 .join(",");
209 let mut line = format!(
210 "{} {:<12} {} {} {}",
211 order.id,
212 order.profile,
213 palette.status(&format!("{:<9}", order.status)),
214 identifiers,
215 rfc3339(order.created_at)
216 );
217 if let Some(revoked_at) = order.revoked_at {
218 line.push_str(&palette.bad(&format!(
222 " revoked={}{}",
223 rfc3339(revoked_at),
224 order
225 .revocation_reason
226 .map(|r| format!(" reason={r}"))
227 .unwrap_or_default()
228 )));
229 }
230 line
231}
232
233#[must_use]
242pub fn render_expiring_line(entry: &ExpiringEntry, palette: Palette) -> String {
243 let order = &entry.order;
244 let identifiers = order
245 .identifiers
246 .iter()
247 .map(|i| i.value.as_str())
248 .collect::<Vec<_>>()
249 .join(",");
250 let days = format!("{:>5}", format!("{}d", entry.days_remaining));
252 let days = match entry.days_remaining {
253 0..=7 => palette.bad(&days),
254 8..=30 => palette.warn(&days),
255 _ => days,
256 };
257 let mut line = format!(
258 "{} {:<12} {} {} {}",
259 order.id,
260 order.profile,
261 days,
262 rfc3339(order.cert_not_after.unwrap_or_default()),
263 identifiers
264 );
265 if let Some(superseded) = &entry.superseded_by {
266 line.push_str(&palette.ok(&format!(
267 " replaced-by={} via={}",
268 superseded.order_id, superseded.via
269 )));
270 }
271 line
272}
273
274fn problem_summary(error: &serde_json::Value) -> String {
279 for member in ["detail", "type"] {
280 if let Some(text) = error.get(member).and_then(serde_json::Value::as_str) {
281 return text.to_string();
282 }
283 }
284 error.to_string()
285}
286
287#[must_use]
307pub fn render_order_detail_text(detail: &OrderDetail, palette: Palette) -> String {
308 let order = &detail.order;
309 let mut out = format!(
314 "id {}\nprofile {}\naccount_id {}\nstatus {}\nidentifiers {}\ncreated {}\nexpires {}\n",
315 order.id,
316 order.profile,
317 order.account_id,
318 palette.status(&order.status.to_string()),
319 order
320 .identifiers
321 .iter()
322 .map(|i| i.value.as_str())
323 .collect::<Vec<_>>()
324 .join(","),
325 rfc3339(order.created_at),
326 rfc3339(order.expires),
327 );
328 for (label, value) in [
329 ("not_before", order.not_before.map(rfc3339)),
330 ("not_after", order.not_after.map(rfc3339)),
331 ("replaces", order.replaces.clone()),
332 ("serial", order.cert_serial.clone()),
333 (
336 "cert_not_after",
337 order
338 .cert_not_after
339 .filter(|value| *value >= 0)
340 .map(rfc3339),
341 ),
342 (
348 "revoked",
349 order.revoked_at.map(|at| palette.bad(&rfc3339(at))),
350 ),
351 (
352 "reason",
353 order
354 .revoked_at
355 .and(order.revocation_reason)
356 .map(|reason| reason.to_string()),
357 ),
358 ("error", order.error.as_ref().map(problem_summary)),
359 ] {
360 if let Some(value) = value {
361 out.push_str(&format!("{label:<14} {value}\n"));
362 }
363 }
364 for (authz, challenges) in &detail.authorizations {
365 out.push_str(&format!(
366 " authz {} [{}] {}\n",
367 authz.id,
368 palette.status(&authz.status.to_string()),
369 authz.identifier.value
370 ));
371 for challenge in challenges {
372 out.push_str(&format!(
373 " challenge {} [{}] type={}\n",
374 challenge.id,
375 palette.status(&challenge.status.to_string()),
376 challenge.typ
377 ));
378 }
379 }
380 out
381}
382
383#[must_use]
385pub fn render_eab_line(eab: &Eab, palette: Palette) -> String {
386 format!(
387 "{} {} {} {}",
388 eab.kid,
389 palette.status(&format!("{:<8}", eab.status)),
390 eab.label.as_deref().unwrap_or("-"),
391 rfc3339(eab.created_at),
392 )
393}
394
395#[must_use]
397pub fn render_eab_created_text(eab: &Eab, palette: Palette) -> String {
398 format!(
399 "kid: {}\nhmacKey: {}\nlabel: {}\n\n{}\n",
400 eab.kid,
401 BASE64_URL_SAFE_NO_PAD.encode(&eab.secret),
402 eab.label.as_deref().unwrap_or("-"),
403 palette.warn("Store the hmacKey now: it is shown only this once."),
404 )
405}
406
407#[must_use]
409pub fn render_admin_user_line(user: &AdminUser, palette: Palette) -> String {
410 format!(
411 "{:<20} {} totp={} {} {}",
412 user.username,
413 palette.status(&format!("{:<8}", user.status)),
414 palette.status(&format!(
415 "{:<3}",
416 if user.has_totp() { "on" } else { "off" }
417 )),
418 rfc3339(user.created_at),
419 user.last_login_at.map_or("never".to_string(), rfc3339),
420 )
421}
422
423#[must_use]
430pub fn render_admin_totp_line(
431 user: &AdminUser,
432 recovery_codes_remaining: i64,
433 palette: Palette,
434) -> String {
435 let state = if user.has_totp() {
439 palette.status("enabled")
440 } else if user.has_pending_totp() {
441 palette.warn("pending (enrolment started, never confirmed)")
442 } else {
443 palette.status("off")
444 };
445
446 format!(
447 "{:<20} totp={} recovery-codes={}",
448 user.username, state, recovery_codes_remaining
449 )
450}
451
452#[must_use]
462pub fn render_admin_user_detail_text(
463 user: &AdminUser,
464 recovery_codes_remaining: i64,
465 palette: Palette,
466) -> String {
467 let mut out = format!(
468 "id {}\nusername {}\nstatus {}\ntotp {}\n\
469 recovery_codes {}\ncreated {}\nupdated {}\n",
470 user.id,
471 user.username,
472 palette.status(&user.status),
473 totp_state(user, palette),
474 recovery_codes_remaining,
475 rfc3339(user.created_at),
476 rfc3339(user.updated_at),
477 );
478 if let Some(last) = user.last_login_at {
482 out.push_str(&format!("last_login {}\n", rfc3339(last)));
483 }
484 out
485}
486
487fn totp_state(user: &AdminUser, palette: Palette) -> String {
496 if user.has_totp() {
497 palette.status("enabled")
498 } else if user.has_pending_totp() {
499 palette.warn("pending (enrolment started, never confirmed)")
500 } else {
501 palette.status("off")
502 }
503}
504
505#[must_use]
518pub fn render_profile_line(profile: &ProfileSummary, palette: Palette) -> String {
519 let challenges = if profile.challenge_bypass {
522 palette.warn(&format!("{:<9}", "bypassed"))
523 } else {
524 palette.status(&format!("{:<9}", "validated"))
525 };
526 format!(
527 "{:<20} challenges={} eab={:<3} {}",
528 profile.name,
529 challenges,
530 if profile.eab_enabled { "on" } else { "off" },
531 profile.directory_url(),
532 )
533}
534
535#[must_use]
540pub fn render_admin_session_line(session: &AdminSession, palette: Palette) -> String {
541 format!(
542 "{} {} {} {} expires={} {}",
543 crate::sqlite::nonce::fingerprint(&session.token_hash),
544 session.user_id,
545 palette.status(&format!("{:<11}", session.state)),
546 rfc3339(session.created_at),
547 rfc3339(session.expires_at),
548 session.created_ip.as_deref().unwrap_or("-"),
549 )
550}
551
552#[must_use]
567pub fn json_page(items: Vec<serde_json::Value>, total: i64, window: Window) -> serde_json::Value {
568 serde_json::json!({
569 "items": items,
570 "total": total,
571 "limit": window.limit,
572 "offset": window.offset,
573 })
574}
575
576fn footer_line(shown: usize, total: i64) -> String {
583 format!("{shown} of {total} row(s).")
584}
585
586fn expiring_footer_line(shown: usize, total: i64, hidden: i64) -> String {
595 if hidden > 0 {
596 format!("{shown} of {total} row(s), {hidden} superseded hidden.")
597 } else {
598 footer_line(shown, total)
599 }
600}
601
602pub fn print_footer(shown: usize, total: i64) {
604 println!("{}", footer_line(shown, total));
605}
606
607pub fn print_expiring_footer(shown: usize, total: i64, hidden: i64) {
609 println!("{}", expiring_footer_line(shown, total, hidden));
610}
611
612pub fn print_page<T>(
621 rows: &[T],
622 total: i64,
623 window: Window,
624 json: bool,
625 to_json: impl Fn(&T) -> serde_json::Value,
626 to_line: impl Fn(&T) -> String,
627) {
628 if json {
629 let rendered: Vec<_> = rows.iter().map(to_json).collect();
630 println!("{}", json_page(rendered, total, window));
631 } else {
632 for row in rows {
633 println!("{}", to_line(row));
634 }
635 print_footer(rows.len(), total);
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use std::sync::Arc;
642
643 use super::*;
644 use crate::admin::ops::SupersededBy;
645 use crate::admin::ops::load_order_detail;
646 use crate::audit::ClientContext;
647 use crate::cli::style::ColorChoice;
648 use crate::sqlite::authz::{Authorization, Challenge};
649 use crate::sqlite::db::Database;
650 use crate::sqlite::order::Identifier;
651 use crate::sqlite::status::OrderStatus;
652 use crate::testutil::{
653 account_id, account_seen_from, admin_session_fixture, admin_user_fixture, audit_entry,
654 client_context, order_fixture,
655 };
656
657 fn colour() -> Palette {
661 Palette::resolve(ColorChoice::Always, false, None)
662 }
663
664 fn strip_ansi(text: &str) -> String {
668 let mut out = String::with_capacity(text.len());
669 let mut rest = text;
670 while let Some(start) = rest.find('\x1b') {
671 out.push_str(&rest[..start]);
672 let Some(end) = rest[start..].find('m') else {
673 break;
674 };
675 rest = &rest[start + end + 1..];
676 }
677 out.push_str(rest);
678 out
679 }
680
681 #[test]
685 fn the_operator_detail_names_the_factor_and_survives_stripping() {
686 let mut user = admin_user_fixture();
687
688 let plain = render_admin_user_detail_text(&user, 0, Palette::plain());
689 assert!(plain.contains("username alice"));
690 assert!(plain.contains("totp off"));
691 assert!(plain.contains("recovery_codes 0"));
692 assert!(!plain.contains("last_login"), "{plain}");
695
696 assert_eq!(
697 strip_ansi(&render_admin_user_detail_text(&user, 0, colour())),
698 plain
699 );
700
701 user.totp_pending_secret = Some(vec![1, 2, 3]);
705 let pending = render_admin_user_detail_text(&user, 0, Palette::plain());
706 assert!(
707 pending.contains("enrolment started, never confirmed"),
708 "{pending}"
709 );
710 assert!(
711 render_admin_totp_line(&user, 0, Palette::plain())
712 .contains("enrolment started, never confirmed")
713 );
714
715 user.totp_secret = Some(vec![4, 5, 6]);
716 user.last_login_at = Some(1_700_000_500);
717 let enabled = render_admin_user_detail_text(&user, 7, Palette::plain());
718 assert!(enabled.contains("totp enabled"), "{enabled}");
719 assert!(enabled.contains("recovery_codes 7"));
720 assert!(enabled.contains("last_login "), "{enabled}");
721 }
722
723 #[test]
726 fn the_profile_line_paints_a_bypassing_endpoint_and_nothing_else() {
727 let mut profile = ProfileSummary {
728 name: "le".to_string(),
729 base_url: "https://ca.example.com/profile/le".to_string(),
730 challenge_bypass: false,
731 eab_enabled: true,
732 };
733
734 let plain = render_profile_line(&profile, Palette::plain());
735 assert!(plain.contains("https://ca.example.com/profile/le/directory"));
736 assert!(plain.contains("challenges=validated"));
737 assert!(plain.contains("eab=on"));
738 assert_eq!(strip_ansi(&render_profile_line(&profile, colour())), plain);
739
740 profile.challenge_bypass = true;
741 profile.eab_enabled = false;
742 let bypassed = render_profile_line(&profile, Palette::plain());
743 assert!(bypassed.contains("challenges=bypassed"));
744 assert!(bypassed.contains("eab=off"));
745 assert_eq!(
746 strip_ansi(&render_profile_line(&profile, colour())),
747 bypassed
748 );
749 }
750
751 #[test]
754 fn the_footer_reports_the_page_against_the_unpaged_total() {
755 assert_eq!(footer_line(2, 137), "2 of 137 row(s).");
756 assert_eq!(footer_line(0, 0), "0 of 0 row(s).");
758 }
759
760 #[test]
763 fn the_expiry_footer_names_the_rows_supersession_removed() {
764 assert_eq!(
765 expiring_footer_line(1, 4, 2),
766 "1 of 4 row(s), 2 superseded hidden."
767 );
768 assert_eq!(expiring_footer_line(4, 4, 0), footer_line(4, 4));
769 }
770
771 #[test]
775 fn the_json_envelope_matches_the_apis() {
776 let window = Window::resolve(2, 4);
777 let envelope = json_page(vec![serde_json::json!({"id": "a"})], 17, window);
778
779 assert_eq!(envelope["total"], 17);
780 assert_eq!(envelope["limit"], 2);
781 assert_eq!(envelope["offset"], 4);
782 assert_eq!(envelope["items"].as_array().unwrap().len(), 1);
783
784 let page = crate::webadmin::handlers::paging::Page {
785 limit: 2,
786 offset: 4,
787 };
788 assert_eq!(
789 envelope,
790 crate::webadmin::handlers::paging::page_envelope(
791 vec![serde_json::json!({"id": "a"})],
792 17,
793 page
794 )
795 );
796 }
797
798 #[test]
802 fn the_audit_line_renders_all_three_shapes_of_client() {
803 let entry = audit_entry();
804 let line = render_audit_line(&entry, Palette::plain());
805 assert!(line.contains("41812"), "{line}");
806 assert!(line.contains("certificate_issued"), "{line}");
807 assert!(line.contains("acme:acct-1"), "{line}");
808 assert!(line.contains("203.0.113.7 (host.example.com)"), "{line}");
809 assert!(line.contains("a.example.com,b.example.com"), "{line}");
810 assert!(!line.contains("reason="), "{line}");
812
813 let mut no_ptr = audit_entry();
814 no_ptr.client_ptr = None;
815 let line = render_audit_line(&no_ptr, Palette::plain());
816 assert!(line.contains("203.0.113.7"), "{line}");
817 assert!(!line.contains('('), "{line}");
818
819 let mut cli = audit_entry();
821 cli.actor_kind = "cli".to_string();
822 cli.actor_id = None;
823 cli.client_ip = None;
824 cli.client_ptr = None;
825 cli.event = "certificate_revoked".to_string();
826 cli.reason = Some("1".to_string());
827 let line = render_audit_line(&cli, Palette::plain());
828 assert!(line.contains(" cli "), "{line}");
829 assert!(
830 !line.contains("cli:"),
831 "an actor with no id must not render a trailing colon: {line}"
832 );
833 assert!(line.contains(" - "), "{line}");
834 assert!(line.ends_with("reason=1"), "{line}");
835 }
836
837 #[test]
841 fn only_a_failed_audit_event_is_painted() {
842 let succeeded = render_audit_line(&audit_entry(), colour());
843 assert!(!succeeded.contains('\x1b'), "{succeeded}");
844
845 let mut refused = audit_entry();
846 refused.event = "certificate_issue_failed".to_string();
847 refused.outcome = "failure".to_string();
848 let line = render_audit_line(&refused, colour());
849 assert!(line.contains("\x1b[31mcertificate_issue_failed"), "{line}");
850 assert_eq!(
851 strip_ansi(&line),
852 render_audit_line(&refused, Palette::plain()),
853 "colour must not move a column"
854 );
855 }
856
857 #[test]
860 fn the_audit_detail_omits_every_field_that_has_no_value() {
861 let full = render_audit_detail_text(&audit_entry(), Palette::plain());
862 for expected in [
863 "id 41812",
864 "event certificate_issued",
865 "outcome success",
866 "profile le",
867 "actor acme:acct-1",
868 "order order-1",
869 "serial 0a0b",
870 "client_ip 203.0.113.7",
871 "client_ptr host.example.com",
872 "user_agent certbot/2.9.0",
873 "request_id req-1",
874 "identifiers a.example.com,b.example.com",
875 ] {
876 assert!(full.contains(expected), "missing `{expected}` in:\n{full}");
877 }
878 assert!(!full.contains("reason"), "{full}");
879 assert!(!full.contains("detail"), "{full}");
880
881 let bare = AuditEntry {
882 actor_id: None,
883 account_id: None,
884 order_id: None,
885 cert_serial: None,
886 identifiers: vec![],
887 client_ip: None,
888 client_ptr: None,
889 user_agent: None,
890 request_id: None,
891 ..audit_entry()
892 };
893 let text = render_audit_detail_text(&bare, Palette::plain());
894 assert!(text.contains("actor acme\n"), "{text}");
895 for absent in ["account", "order", "serial", "client_ip", "identifiers"] {
896 assert!(
897 !text.contains(absent),
898 "`{absent}` should be absent from:\n{text}"
899 );
900 }
901 }
902
903 #[tokio::test]
906 async fn render_account_line_renders_all_three_shapes_of_client() {
907 let db = Arc::new(Database::connect_in_memory().await.unwrap());
908
909 let both = account_seen_from(
910 &[1u8, 2, 3],
911 &client_context(Some("203.0.113.7"), Some("host.example.com")),
912 &db,
913 )
914 .await;
915 let line = render_account_line(&both, Palette::plain());
916 assert!(line.contains(&both.id.to_string()), "{line}");
917 assert!(line.contains("valid"), "{line}");
918 assert!(line.contains("mailto:a@example.com"), "{line}");
919 assert!(line.contains("203.0.113.7 (host.example.com)"), "{line}");
920 assert!(!line.contains(&pubkey_fingerprint(&both.pubkey)), "{line}");
922
923 let address_only = account_seen_from(
924 &[4u8, 5, 6],
925 &client_context(Some("203.0.113.7"), None),
926 &db,
927 )
928 .await;
929 let line = render_account_line(&address_only, Palette::plain());
930 assert!(line.contains("203.0.113.7"), "{line}");
931 assert!(!line.contains('('), "{line}");
932
933 let neither = account_seen_from(&[7u8, 8, 9], &ClientContext::default(), &db).await;
934 assert!(render_account_line(&neither, Palette::plain()).contains(" - "));
935 }
936
937 #[tokio::test]
940 async fn colour_never_moves_the_account_listings_columns() {
941 let db = Arc::new(Database::connect_in_memory().await.unwrap());
942 let account = account_seen_from(&[1u8, 2, 3], &ClientContext::default(), &db).await;
943
944 let painted = render_account_line(&account, colour());
945 assert!(painted.contains("\x1b[32mvalid \x1b[0m"), "{painted}");
946 assert_eq!(
947 strip_ansi(&painted),
948 render_account_line(&account, Palette::plain())
949 );
950 }
951
952 #[tokio::test]
953 async fn render_account_detail_text_omits_every_absent_field() {
954 let db = Arc::new(Database::connect_in_memory().await.unwrap());
955
956 let seen = account_seen_from(
957 &[1u8, 2, 3],
958 &client_context(Some("203.0.113.7"), Some("host.example.com")),
959 &db,
960 )
961 .await;
962 let text = render_account_detail_text(&seen, Palette::plain());
963 assert!(
964 text.contains(&format!("id {}", seen.id)),
965 "{text}"
966 );
967 assert!(text.contains("profile default"), "{text}");
968 assert!(text.contains("status valid"), "{text}");
969 assert!(
970 text.contains(&pubkey_fingerprint(&seen.pubkey)),
971 "the fingerprint the listing gave up must be here: {text}"
972 );
973 assert!(
974 text.contains("contact mailto:a@example.com"),
975 "{text}"
976 );
977 assert!(text.contains("created_ip 203.0.113.7"), "{text}");
978 assert!(text.contains("created_ptr host.example.com"), "{text}");
979 assert!(text.contains("last_seen "), "{text}");
980 assert!(text.contains("last_seen_ip 203.0.113.7"), "{text}");
981 assert!(text.contains("last_seen_ptr host.example.com"), "{text}");
982 for line in text.lines() {
985 assert_eq!(&line[13..14], " ", "misaligned: {line:?}");
986 assert_ne!(&line[14..15], " ", "misaligned: {line:?}");
987 }
988 assert!(!text.contains("eab_kid"), "{text}");
990 assert!(!text.contains("terms"), "{text}");
991
992 let bare =
993 Account::find_or_create("default", &[9u8], vec![], &ClientContext::default(), &db)
994 .await
995 .unwrap()
996 .0;
997 let text = render_account_detail_text(&bare, Palette::plain());
998 for absent in ["contact", "created_ip", "created_ptr", "last_seen_ip"] {
999 assert!(!text.contains(absent), "{absent} in {text}");
1000 }
1001 assert!(text.contains("last_seen "), "{text}");
1003 }
1004
1005 #[test]
1006 fn render_order_line_includes_expected_fields() {
1007 let order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Pending);
1008 let line = render_order_line(&order, Palette::plain());
1009 assert!(line.contains(&order.id.to_string()));
1010 assert!(line.contains("pending"));
1011 assert!(line.contains("example.com"));
1012 }
1013
1014 #[test]
1023 fn the_order_status_column_is_painted_by_what_it_means() {
1024 for (status, code) in [
1025 (OrderStatus::Valid, "32"),
1026 (OrderStatus::Pending, "33"),
1027 (OrderStatus::Invalid, "31"),
1028 ] {
1029 let order = order_fixture(crate::sqlite::id::mint(), status);
1030 let painted = render_order_line(&order, colour());
1031 assert!(
1032 painted.contains(&format!("\x1b[{code}m{}\x1b[0m", status.as_str())),
1033 "{painted}"
1034 );
1035 assert_eq!(
1036 strip_ansi(&painted),
1037 render_order_line(&order, Palette::plain())
1038 );
1039 }
1040 }
1041
1042 #[tokio::test]
1043 async fn render_order_detail_text_surfaces_authorizations_and_challenges() {
1044 let db = Arc::new(Database::connect_in_memory().await.unwrap());
1045 let acct = account_id(&db).await;
1046 let order = Order::create(
1047 "default",
1048 acct,
1049 vec![Identifier::dns("example.com")],
1050 crate::sqlite::nonce::now_secs() + 3600,
1051 None,
1052 None,
1053 &db,
1054 )
1055 .await
1056 .unwrap();
1057 let authz = Authorization::create(
1058 order.id,
1059 Identifier::dns("example.com"),
1060 crate::sqlite::nonce::now_secs() + 3600,
1061 &db,
1062 )
1063 .await
1064 .unwrap();
1065 Challenge::create(authz.id, "http-01", &db).await.unwrap();
1066
1067 let detail = load_order_detail(order.id.to_string().as_str(), db)
1068 .await
1069 .unwrap()
1070 .unwrap();
1071 let text = render_order_detail_text(&detail, Palette::plain());
1072 assert!(text.contains(&order.id.to_string()));
1073 assert!(text.contains(&authz.id.to_string()));
1074 assert!(text.contains("http-01"));
1075
1076 let painted = render_order_detail_text(&detail, colour());
1079 assert_eq!(
1080 painted.matches("\x1b[33mpending\x1b[0m").count(),
1081 3,
1082 "the order's, the authorization's and the challenge's: {painted}"
1083 );
1084 assert_eq!(strip_ansi(&painted), text);
1085 }
1086
1087 #[test]
1096 fn render_order_detail_text_omits_every_absent_field() {
1097 let account = crate::sqlite::id::mint();
1098 let mut order = order_fixture(account, OrderStatus::Valid);
1099 order.not_before = Some(1700000000);
1100 order.not_after = Some(1700003600);
1101 order.replaces = Some("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE".to_string());
1102 order.cert_serial = Some("03a7f1c9".to_string());
1103 order.cert_not_after = Some(1700007200);
1104 order.revoked_at = Some(1700010800);
1105 order.revocation_reason = Some(1);
1106 order.error = Some(serde_json::json!({
1107 "type": "urn:ietf:params:acme:error:badCSR",
1108 "detail": "CSR names do not match the order",
1109 }));
1110 let detail = OrderDetail {
1111 order,
1112 authorizations: vec![],
1113 };
1114
1115 let text = render_order_detail_text(&detail, Palette::plain());
1116 assert!(
1117 text.contains(&format!("id {}", detail.order.id)),
1118 "{text}"
1119 );
1120 assert!(text.contains("profile default"), "{text}");
1121 assert!(
1122 text.contains(&format!("account_id {account}")),
1123 "{text}"
1124 );
1125 assert!(text.contains("status valid"), "{text}");
1126 assert!(text.contains("identifiers example.com"), "{text}");
1127 assert!(text.contains("created "), "{text}");
1128 assert!(text.contains("expires "), "{text}");
1129 assert!(
1130 text.contains("not_before 2023-11-14T22:13:20Z"),
1131 "{text}"
1132 );
1133 assert!(
1134 text.contains("not_after 2023-11-14T23:13:20Z"),
1135 "{text}"
1136 );
1137 assert!(
1138 text.contains("replaces aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE"),
1139 "{text}"
1140 );
1141 assert!(text.contains("serial 03a7f1c9"), "{text}");
1142 assert!(
1143 text.contains("cert_not_after 2023-11-15T00:13:20Z"),
1144 "{text}"
1145 );
1146 assert!(
1147 text.contains("revoked 2023-11-15T01:13:20Z"),
1148 "{text}"
1149 );
1150 assert!(text.contains("reason 1"), "{text}");
1151 assert!(
1154 text.contains("error CSR names do not match the order"),
1155 "{text}"
1156 );
1157 for line in text.lines() {
1160 assert_eq!(&line[14..15], " ", "misaligned: {line:?}");
1161 assert_ne!(&line[15..16], " ", "misaligned: {line:?}");
1162 }
1163
1164 let bare = OrderDetail {
1166 order: order_fixture(crate::sqlite::id::mint(), OrderStatus::Pending),
1167 authorizations: vec![],
1168 };
1169 let text = render_order_detail_text(&bare, Palette::plain());
1170 for absent in [
1171 "not_before",
1172 "not_after",
1173 "replaces",
1174 "serial",
1175 "cert_not_after",
1176 "revoked",
1177 "reason",
1178 "error",
1179 ] {
1180 assert!(!text.contains(absent), "{absent} in {text}");
1181 }
1182 }
1183
1184 #[test]
1196 fn the_text_and_json_order_renderings_describe_the_same_order() {
1197 const FIELDS: &[(&str, &str)] = &[
1199 ("id", "id"),
1200 ("profile", "profile"),
1201 ("account_id", "accountId"),
1202 ("status", "status"),
1203 ("identifiers", "identifiers"),
1204 ("created", "createdAt"),
1205 ("expires", "expires"),
1206 ("not_before", "notBefore"),
1207 ("not_after", "notAfter"),
1208 ("replaces", "replaces"),
1209 ("serial", "certSerial"),
1210 ("cert_not_after", "certNotAfter"),
1211 ("revoked", "revokedAt"),
1212 ("reason", "revocationReason"),
1213 ("error", "error"),
1214 ];
1215 const JSON_ONLY: &[&str] = &[
1217 "authorizations",
1218 "finalize",
1219 "certificate",
1220 "certificatePem",
1221 ];
1222
1223 let account = crate::sqlite::id::mint();
1226 let mut order = order_fixture(account, OrderStatus::Valid);
1227 order.not_before = Some(1700000000);
1228 order.not_after = Some(1700003600);
1229 order.replaces = Some("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE".to_string());
1230 order.cert_serial = Some("03a7f1c9".to_string());
1231 order.cert_not_after = Some(1700007200);
1232 order.revoked_at = Some(1700010800);
1233 order.revocation_reason = Some(1);
1234 order.error = Some(serde_json::json!({ "detail": "unreachable" }));
1235 order.certificate = Some("-----BEGIN CERTIFICATE-----\n".to_string());
1236 let detail = OrderDetail {
1237 order,
1238 authorizations: vec![],
1239 };
1240
1241 let json = crate::admin::render::render_order_detail_json(&detail, "http://localhost:3000");
1242 let members: std::collections::BTreeSet<&str> = json["order"]
1243 .as_object()
1244 .unwrap()
1245 .keys()
1246 .map(String::as_str)
1247 .filter(|member| !JSON_ONLY.contains(member))
1248 .collect();
1249 assert_eq!(
1250 members,
1251 FIELDS.iter().map(|(_, member)| *member).collect(),
1252 "a JSON member the text rendering does not print, or the reverse"
1253 );
1254
1255 let text = render_order_detail_text(&detail, Palette::plain());
1258 let labels: std::collections::BTreeSet<&str> = text
1259 .lines()
1260 .filter(|line| !line.starts_with(' '))
1261 .map(|line| line[..14].trim_end())
1262 .collect();
1263 assert_eq!(labels, FIELDS.iter().map(|(label, _)| *label).collect());
1264 }
1265
1266 #[test]
1270 fn an_unparsable_leaf_expiry_prints_no_line() {
1271 let mut order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Valid);
1272 order.cert_not_after = Some(crate::sqlite::order::UNPARSABLE_NOT_AFTER);
1273 let detail = OrderDetail {
1274 order,
1275 authorizations: vec![],
1276 };
1277 assert!(!render_order_detail_text(&detail, Palette::plain()).contains("cert_not_after"),);
1278 }
1279
1280 #[test]
1284 fn the_order_detail_paints_its_revocation_and_nothing_else_moves() {
1285 let mut order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Valid);
1286 order.revoked_at = Some(1700000000);
1287 order.revocation_reason = Some(4);
1288 let detail = OrderDetail {
1289 order,
1290 authorizations: vec![],
1291 };
1292 let painted = render_order_detail_text(&detail, colour());
1293 assert!(
1294 painted.contains("\x1b[31m2023-11-14T22:13:20Z\x1b[0m"),
1295 "{painted}"
1296 );
1297 assert_eq!(
1298 strip_ansi(&painted),
1299 render_order_detail_text(&detail, Palette::plain())
1300 );
1301 }
1302
1303 #[test]
1304 fn render_order_line_revoked_includes_reason_and_time() {
1305 let mut order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Valid);
1306 order.revoked_at = Some(1700000000);
1307 order.revocation_reason = Some(1);
1308 let line = render_order_line(&order, Palette::plain());
1309 assert!(line.contains("revoked="));
1310 assert!(line.contains("reason=1"));
1311 }
1312
1313 #[test]
1316 fn a_revoked_order_paints_its_suffix_even_though_its_status_is_valid() {
1317 let mut order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Valid);
1318 order.revoked_at = Some(1700000000);
1319 order.revocation_reason = Some(1);
1320 let painted = render_order_line(&order, colour());
1321 assert!(painted.contains("\x1b[32mvalid"), "{painted}");
1322 assert!(painted.contains("\x1b[31m revoked="), "{painted}");
1323 assert!(painted.ends_with("reason=1\x1b[0m"), "{painted}");
1324 assert_eq!(
1325 strip_ansi(&painted),
1326 render_order_line(&order, Palette::plain())
1327 );
1328 }
1329
1330 #[tokio::test]
1331 async fn render_eab_line_includes_expected_fields() {
1332 let db = Arc::new(Database::connect_in_memory().await.unwrap());
1333 let eab = Eab::create(Some("team-a".to_string()), None, &db)
1334 .await
1335 .unwrap();
1336 let line = render_eab_line(&eab, Palette::plain());
1337 assert!(line.contains(&eab.kid.to_string()));
1338 assert!(line.contains("active"));
1339 assert!(line.contains("team-a"));
1340
1341 let painted = render_eab_line(&eab, colour());
1342 assert!(painted.contains("\x1b[32mactive \x1b[0m"), "{painted}");
1343 assert_eq!(strip_ansi(&painted), line);
1344 }
1345
1346 #[tokio::test]
1347 async fn render_eab_created_text_includes_kid_and_hmac_key() {
1348 let db = Arc::new(Database::connect_in_memory().await.unwrap());
1349 let eab = Eab::create(None, None, &db).await.unwrap();
1350 let text = render_eab_created_text(&eab, Palette::plain());
1351 assert!(text.contains(&eab.kid.to_string()));
1352 assert!(text.contains(&BASE64_URL_SAFE_NO_PAD.encode(&eab.secret)));
1353 assert!(text.contains("Store the hmacKey now"));
1354 }
1355
1356 #[tokio::test]
1359 async fn the_eab_secret_warning_is_the_only_thing_painted() {
1360 let db = Arc::new(Database::connect_in_memory().await.unwrap());
1361 let eab = Eab::create(None, None, &db).await.unwrap();
1362 let painted = render_eab_created_text(&eab, colour());
1363 assert!(
1364 painted.contains("\x1b[33mStore the hmacKey now: it is shown only this once.\x1b[0m"),
1365 "{painted}"
1366 );
1367 assert_eq!(painted.matches('\x1b').count(), 2, "{painted}");
1368 assert_eq!(
1369 strip_ansi(&painted),
1370 render_eab_created_text(&eab, Palette::plain())
1371 );
1372 }
1373
1374 #[test]
1375 fn render_admin_user_line_never_shows_the_hash_and_says_never_for_no_login() {
1376 let user = admin_user_fixture();
1377 let line = render_admin_user_line(&user, Palette::plain());
1378 assert!(line.contains("alice"));
1379 assert!(line.contains("active"));
1380 assert!(line.contains("totp=off"));
1381 assert!(line.contains("never"));
1382 assert!(
1383 !line.contains("pbkdf2"),
1384 "the stored hash must never reach a terminal: {line}"
1385 );
1386 }
1387
1388 #[test]
1389 fn render_admin_user_line_reflects_totp_and_a_real_last_login() {
1390 let mut user = admin_user_fixture();
1391 user.totp_secret = Some(vec![1, 2, 3]);
1392 user.last_login_at = Some(1_700_000_500);
1393 let line = render_admin_user_line(&user, Palette::plain());
1394 assert!(line.contains("totp=on"));
1395 assert!(!line.contains("never"));
1396 }
1397
1398 #[test]
1401 fn an_operator_without_a_second_factor_stands_out() {
1402 let without = render_admin_user_line(&admin_user_fixture(), colour());
1403 assert!(without.contains("totp=\x1b[31moff\x1b[0m"), "{without}");
1404
1405 let mut user = admin_user_fixture();
1406 user.totp_secret = Some(vec![1, 2, 3]);
1407 user.status = "disabled".to_string();
1408 let with = render_admin_user_line(&user, colour());
1409 assert!(with.contains("totp=\x1b[32mon \x1b[0m"), "{with}");
1410 assert!(with.contains("\x1b[31mdisabled\x1b[0m"), "{with}");
1411 assert_eq!(
1412 strip_ansi(&with),
1413 render_admin_user_line(&user, Palette::plain())
1414 );
1415 }
1416
1417 #[test]
1420 fn the_totp_line_paints_each_of_its_three_states() {
1421 let plain = Palette::plain();
1422 let off = admin_user_fixture();
1423 assert!(
1424 render_admin_totp_line(&off, 0, plain).contains("totp=off"),
1425 "plain output unchanged"
1426 );
1427 assert!(render_admin_totp_line(&off, 0, colour()).contains("totp=\x1b[31moff\x1b[0m"));
1428
1429 let mut pending = admin_user_fixture();
1430 pending.totp_pending_secret = Some(vec![1, 2, 3]);
1431 let line = render_admin_totp_line(&pending, 0, colour());
1432 assert!(
1433 line.contains("\x1b[33mpending (enrolment started, never confirmed)\x1b[0m"),
1434 "{line}"
1435 );
1436 assert_eq!(
1437 strip_ansi(&line),
1438 render_admin_totp_line(&pending, 0, plain)
1439 );
1440
1441 let mut enabled = admin_user_fixture();
1442 enabled.totp_secret = Some(vec![1, 2, 3]);
1443 let line = render_admin_totp_line(&enabled, 7, colour());
1444 assert!(line.contains("totp=\x1b[32menabled\x1b[0m"), "{line}");
1445 assert!(line.contains("recovery-codes=7"), "{line}");
1446 }
1447
1448 #[test]
1449 fn render_admin_session_line_shows_a_fingerprint_not_the_token_hash() {
1450 let line = render_admin_session_line(&admin_session_fixture(), Palette::plain());
1451 assert!(line.contains("01234567"));
1452 assert!(
1453 !line.contains("0123456789abcdef0123456789abcdef"),
1454 "printing the hash would put every live session's lookup key on a terminal: {line}"
1455 );
1456 assert!(!line.contains("the-csrf-token"));
1457 assert!(line.contains("192.0.2.1"));
1458 assert!(line.contains("expires="));
1459 }
1460
1461 #[test]
1462 fn render_admin_session_line_dashes_a_missing_address() {
1463 let mut session = admin_session_fixture();
1464 session.created_ip = None;
1465 assert!(render_admin_session_line(&session, Palette::plain()).contains(" -"));
1466 }
1467
1468 #[test]
1471 fn a_pending_mfa_session_is_painted_apart_from_an_active_one() {
1472 let active = render_admin_session_line(&admin_session_fixture(), colour());
1473 assert!(active.contains("\x1b[32mactive \x1b[0m"), "{active}");
1474
1475 let mut session = admin_session_fixture();
1476 session.state = "pending_mfa".to_string();
1477 let painted = render_admin_session_line(&session, colour());
1478 assert!(painted.contains("\x1b[33mpending_mfa\x1b[0m"), "{painted}");
1479 assert_eq!(
1480 strip_ansi(&painted),
1481 render_admin_session_line(&session, Palette::plain())
1482 );
1483 }
1484
1485 #[test]
1488 fn the_expiring_line_bands_the_days_and_annotates_only_what_was_replaced() {
1489 let id = crate::sqlite::id::mint();
1490 let entry = move |days: i64, superseded: Option<SupersededBy>| {
1491 let mut order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Valid);
1492 order.id = id;
1493 order.cert_not_after = Some(1_700_000_000);
1494 ExpiringEntry {
1495 order,
1496 days_remaining: days,
1497 superseded_by: superseded,
1498 }
1499 };
1500
1501 let plain = render_expiring_line(&entry(40, None), Palette::plain());
1502 assert!(plain.starts_with(&format!("{id} default")), "{plain}");
1503 assert!(plain.contains(" 40d "), "{plain}");
1504 assert!(plain.contains("2023-11-14"), "{plain}");
1505 assert!(plain.ends_with("example.com"), "{plain}");
1506 assert!(
1507 !plain.contains("replaced-by"),
1508 "the absent annotation is what an operator scans for: {plain}"
1509 );
1510
1511 assert!(render_expiring_line(&entry(3, None), colour()).contains("\x1b[31m"));
1513 assert!(render_expiring_line(&entry(20, None), colour()).contains("\x1b[33m"));
1514 let far = render_expiring_line(&entry(40, None), colour());
1515 assert_eq!(
1516 far,
1517 render_expiring_line(&entry(40, None), Palette::plain())
1518 );
1519
1520 let replaced = entry(
1521 3,
1522 Some(SupersededBy {
1523 order_id: "ord-2".to_string(),
1524 cert_serial: "0a0b".to_string(),
1525 not_after: 1_800_000_000,
1526 via: "replaces".to_string(),
1527 }),
1528 );
1529 let line = render_expiring_line(&replaced, Palette::plain());
1530 assert!(line.ends_with(" replaced-by=ord-2 via=replaces"), "{line}");
1531 }
1532
1533 #[test]
1536 fn colour_never_moves_the_expiring_listings_columns() {
1537 let mut order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Valid);
1538 order.cert_not_after = Some(1_700_000_000);
1539 let entry = ExpiringEntry {
1540 order,
1541 days_remaining: 3,
1542 superseded_by: Some(SupersededBy {
1543 order_id: "ord-2".to_string(),
1544 cert_serial: "0a0b".to_string(),
1545 not_after: 1_800_000_000,
1546 via: "identifiers".to_string(),
1547 }),
1548 };
1549
1550 let painted = render_expiring_line(&entry, colour());
1551 assert!(painted.contains("\x1b[31m 3d\x1b[0m"), "{painted}");
1552 assert_eq!(
1553 strip_ansi(&painted),
1554 render_expiring_line(&entry, Palette::plain())
1555 );
1556 }
1557}