1use base64::prelude::*;
23
24use super::style::Palette;
25use super::window::Window;
26use crate::admin::ops::{ExpiringEntry, OrderDetail};
27use crate::sqlite::account::{Account, pubkey_fingerprint};
28use crate::sqlite::admin_session::AdminSession;
29use crate::sqlite::admin_user::AdminUser;
30use crate::sqlite::audit::AuditEntry;
31use crate::sqlite::eab::Eab;
32use crate::sqlite::order::{Order, rfc3339};
33
34fn render_client(ip: Option<&String>, ptr: Option<&String>) -> String {
42 match (ip, ptr) {
43 (Some(ip), Some(ptr)) => format!("{ip} ({ptr})"),
44 (Some(ip), None) => ip.clone(),
45 _ => "-".to_string(),
46 }
47}
48
49#[must_use]
55pub fn render_account_line(account: &Account, palette: Palette) -> String {
56 format!(
57 "{} {:<12} {} {:<40} {} {}",
58 account.id,
59 account.profile,
60 palette.status(&format!("{:<11}", account.status)),
61 render_client(
62 account.last_seen_ip.as_ref(),
63 account.last_seen_ptr.as_ref()
64 ),
65 if account.contact.is_empty() {
66 "-".to_string()
67 } else {
68 account.contact.join(",")
69 },
70 rfc3339(account.created_at),
71 )
72}
73
74#[must_use]
80pub fn render_account_detail_text(account: &Account, palette: Palette) -> String {
81 let mut out = format!(
85 "id {}\nprofile {}\nstatus {}\npubkey {}\ncreated {}\n",
86 account.id,
87 account.profile,
88 palette.status(&account.status),
89 pubkey_fingerprint(&account.pubkey),
90 rfc3339(account.created_at),
91 );
92 if !account.contact.is_empty() {
93 out.push_str(&format!("contact {}\n", account.contact.join(",")));
94 }
95 if let Some(agreed) = account.terms_of_service_agreed {
96 out.push_str(&format!("terms {agreed}\n"));
97 }
98 if let Some(seen) = account.last_seen_at {
99 out.push_str(&format!("last_seen {}\n", rfc3339(seen)));
100 }
101 for (label, value) in [
102 ("eab_kid", account.eab_kid.as_ref()),
103 ("created_ip", account.created_ip.as_ref()),
104 ("created_ptr", account.created_ptr.as_ref()),
105 ("last_seen_ip", account.last_seen_ip.as_ref()),
106 ("last_seen_ptr", account.last_seen_ptr.as_ref()),
107 ] {
108 if let Some(value) = value {
109 out.push_str(&format!("{label:<13} {value}\n"));
110 }
111 }
112 out
113}
114
115fn paint_event(event: &str, width: usize, palette: Palette) -> String {
125 let padded = format!("{event:<width$}");
126 if event.ends_with("_failed") {
127 palette.bad(&padded)
128 } else {
129 padded
130 }
131}
132
133#[must_use]
138pub fn render_audit_line(entry: &AuditEntry, palette: Palette) -> String {
139 let actor = match &entry.actor_id {
140 Some(id) => format!("{}:{id}", entry.actor_kind),
141 None => entry.actor_kind.clone(),
142 };
143 let client = render_client(entry.client_ip.as_ref(), entry.client_ptr.as_ref());
144 let mut line = format!(
145 "{:<8} {} {} {:<12} {:<24} {:<40} {}",
146 entry.id,
147 rfc3339(entry.created_at),
148 paint_event(&entry.event, 26, palette),
149 entry.profile,
150 actor,
151 client,
152 entry.identifiers.join(","),
153 );
154 if let Some(reason) = &entry.reason {
155 line.push_str(&format!(" reason={reason}"));
156 }
157 line
158}
159
160#[must_use]
163pub fn render_audit_detail_text(entry: &AuditEntry, palette: Palette) -> String {
164 let mut out = format!(
165 "id {}\ncreated {}\nevent {}\noutcome {}\nprofile {}\nactor {}\n",
166 entry.id,
167 rfc3339(entry.created_at),
168 paint_event(&entry.event, 0, palette),
169 palette.status(&entry.outcome),
170 entry.profile,
171 match &entry.actor_id {
172 Some(id) => format!("{}:{id}", entry.actor_kind),
173 None => entry.actor_kind.clone(),
174 },
175 );
176 for (label, value) in [
177 ("account", entry.account_id.as_ref()),
178 ("order", entry.order_id.as_ref()),
179 ("serial", entry.cert_serial.as_ref()),
180 ("client_ip", entry.client_ip.as_ref()),
181 ("client_ptr", entry.client_ptr.as_ref()),
182 ("user_agent", entry.user_agent.as_ref()),
183 ("request_id", entry.request_id.as_ref()),
184 ("reason", entry.reason.as_ref()),
185 ("detail", entry.detail.as_ref()),
186 ] {
187 if let Some(value) = value {
188 out.push_str(&format!("{label:<12} {value}\n"));
189 }
190 }
191 if !entry.identifiers.is_empty() {
192 out.push_str(&format!("identifiers {}\n", entry.identifiers.join(",")));
193 }
194 out
195}
196
197#[must_use]
199pub fn render_order_line(order: &Order, palette: Palette) -> String {
200 let identifiers = order
201 .identifiers
202 .iter()
203 .map(|i| i.value.as_str())
204 .collect::<Vec<_>>()
205 .join(",");
206 let mut line = format!(
207 "{} {:<12} {} {} {}",
208 order.id,
209 order.profile,
210 palette.status(&format!("{:<9}", order.status)),
211 identifiers,
212 rfc3339(order.created_at)
213 );
214 if let Some(revoked_at) = order.revoked_at {
215 line.push_str(&palette.bad(&format!(
219 " revoked={}{}",
220 rfc3339(revoked_at),
221 order
222 .revocation_reason
223 .map(|r| format!(" reason={r}"))
224 .unwrap_or_default()
225 )));
226 }
227 line
228}
229
230#[must_use]
239pub fn render_expiring_line(entry: &ExpiringEntry, palette: Palette) -> String {
240 let order = &entry.order;
241 let identifiers = order
242 .identifiers
243 .iter()
244 .map(|i| i.value.as_str())
245 .collect::<Vec<_>>()
246 .join(",");
247 let days = format!("{:>5}", format!("{}d", entry.days_remaining));
249 let days = match entry.days_remaining {
250 0..=7 => palette.bad(&days),
251 8..=30 => palette.warn(&days),
252 _ => days,
253 };
254 let mut line = format!(
255 "{} {:<12} {} {} {}",
256 order.id,
257 order.profile,
258 days,
259 rfc3339(order.cert_not_after.unwrap_or_default()),
260 identifiers
261 );
262 if let Some(superseded) = &entry.superseded_by {
263 line.push_str(&palette.ok(&format!(
264 " replaced-by={} via={}",
265 superseded.order_id, superseded.via
266 )));
267 }
268 line
269}
270
271fn problem_summary(error: &serde_json::Value) -> String {
276 for member in ["detail", "type"] {
277 if let Some(text) = error.get(member).and_then(serde_json::Value::as_str) {
278 return text.to_string();
279 }
280 }
281 error.to_string()
282}
283
284#[must_use]
304pub fn render_order_detail_text(detail: &OrderDetail, palette: Palette) -> String {
305 let order = &detail.order;
306 let mut out = format!(
311 "id {}\nprofile {}\naccount_id {}\nstatus {}\nidentifiers {}\ncreated {}\nexpires {}\n",
312 order.id,
313 order.profile,
314 order.account_id,
315 palette.status(&order.status.to_string()),
316 order
317 .identifiers
318 .iter()
319 .map(|i| i.value.as_str())
320 .collect::<Vec<_>>()
321 .join(","),
322 rfc3339(order.created_at),
323 rfc3339(order.expires),
324 );
325 for (label, value) in [
326 ("not_before", order.not_before.map(rfc3339)),
327 ("not_after", order.not_after.map(rfc3339)),
328 ("replaces", order.replaces.clone()),
329 ("serial", order.cert_serial.clone()),
330 (
333 "cert_not_after",
334 order
335 .cert_not_after
336 .filter(|value| *value >= 0)
337 .map(rfc3339),
338 ),
339 (
345 "revoked",
346 order.revoked_at.map(|at| palette.bad(&rfc3339(at))),
347 ),
348 (
349 "reason",
350 order
351 .revoked_at
352 .and(order.revocation_reason)
353 .map(|reason| reason.to_string()),
354 ),
355 ("error", order.error.as_ref().map(problem_summary)),
356 ] {
357 if let Some(value) = value {
358 out.push_str(&format!("{label:<14} {value}\n"));
359 }
360 }
361 for (authz, challenges) in &detail.authorizations {
362 out.push_str(&format!(
363 " authz {} [{}] {}\n",
364 authz.id,
365 palette.status(&authz.status.to_string()),
366 authz.identifier.value
367 ));
368 for challenge in challenges {
369 out.push_str(&format!(
370 " challenge {} [{}] type={}\n",
371 challenge.id,
372 palette.status(&challenge.status.to_string()),
373 challenge.typ
374 ));
375 }
376 }
377 out
378}
379
380#[must_use]
382pub fn render_eab_line(eab: &Eab, palette: Palette) -> String {
383 format!(
384 "{} {} {} {}",
385 eab.kid,
386 palette.status(&format!("{:<8}", eab.status)),
387 eab.label.as_deref().unwrap_or("-"),
388 rfc3339(eab.created_at),
389 )
390}
391
392#[must_use]
394pub fn render_eab_created_text(eab: &Eab, palette: Palette) -> String {
395 format!(
396 "kid: {}\nhmacKey: {}\nlabel: {}\n\n{}\n",
397 eab.kid,
398 BASE64_URL_SAFE_NO_PAD.encode(&eab.secret),
399 eab.label.as_deref().unwrap_or("-"),
400 palette.warn("Store the hmacKey now: it is shown only this once."),
401 )
402}
403
404#[must_use]
406pub fn render_admin_user_line(user: &AdminUser, palette: Palette) -> String {
407 format!(
408 "{:<20} {} totp={} {} {}",
409 user.username,
410 palette.status(&format!("{:<8}", user.status)),
411 palette.status(&format!(
412 "{:<3}",
413 if user.has_totp() { "on" } else { "off" }
414 )),
415 rfc3339(user.created_at),
416 user.last_login_at.map_or("never".to_string(), rfc3339),
417 )
418}
419
420#[must_use]
427pub fn render_admin_totp_line(
428 user: &AdminUser,
429 recovery_codes_remaining: i64,
430 palette: Palette,
431) -> String {
432 let state = if user.has_totp() {
436 palette.status("enabled")
437 } else if user.has_pending_totp() {
438 palette.warn("pending (enrolment started, never confirmed)")
439 } else {
440 palette.status("off")
441 };
442
443 format!(
444 "{:<20} totp={} recovery-codes={}",
445 user.username, state, recovery_codes_remaining
446 )
447}
448
449#[must_use]
454pub fn render_admin_session_line(session: &AdminSession, palette: Palette) -> String {
455 format!(
456 "{} {} {} {} expires={} {}",
457 crate::sqlite::nonce::fingerprint(&session.token_hash),
458 session.user_id,
459 palette.status(&format!("{:<11}", session.state)),
460 rfc3339(session.created_at),
461 rfc3339(session.expires_at),
462 session.created_ip.as_deref().unwrap_or("-"),
463 )
464}
465
466pub fn print_rows<T>(
477 rows: &[T],
478 json: bool,
479 to_json: impl Fn(&T) -> serde_json::Value,
480 to_line: impl Fn(&T) -> String,
481) {
482 if json {
483 let rendered: Vec<_> = rows.iter().map(to_json).collect();
484 println!("{}", serde_json::Value::Array(rendered));
485 } else {
486 for row in rows {
487 println!("{}", to_line(row));
488 }
489 }
490}
491
492#[must_use]
505pub fn json_page(items: Vec<serde_json::Value>, total: i64, window: Window) -> serde_json::Value {
506 serde_json::json!({
507 "items": items,
508 "total": total,
509 "limit": window.limit,
510 "offset": window.offset,
511 })
512}
513
514fn footer_line(shown: usize, total: i64) -> String {
521 format!("{shown} of {total} row(s).")
522}
523
524fn expiring_footer_line(shown: usize, total: i64, hidden: i64) -> String {
533 if hidden > 0 {
534 format!("{shown} of {total} row(s), {hidden} superseded hidden.")
535 } else {
536 footer_line(shown, total)
537 }
538}
539
540pub fn print_footer(shown: usize, total: i64) {
542 println!("{}", footer_line(shown, total));
543}
544
545pub fn print_expiring_footer(shown: usize, total: i64, hidden: i64) {
547 println!("{}", expiring_footer_line(shown, total, hidden));
548}
549
550pub fn print_page<T>(
559 rows: &[T],
560 total: i64,
561 window: Window,
562 json: bool,
563 to_json: impl Fn(&T) -> serde_json::Value,
564 to_line: impl Fn(&T) -> String,
565) {
566 if json {
567 let rendered: Vec<_> = rows.iter().map(to_json).collect();
568 println!("{}", json_page(rendered, total, window));
569 } else {
570 for row in rows {
571 println!("{}", to_line(row));
572 }
573 print_footer(rows.len(), total);
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use std::sync::Arc;
580
581 use super::*;
582 use crate::admin::ops::SupersededBy;
583 use crate::admin::ops::load_order_detail;
584 use crate::audit::ClientContext;
585 use crate::cli::style::ColorChoice;
586 use crate::sqlite::authz::{Authorization, Challenge};
587 use crate::sqlite::db::Database;
588 use crate::sqlite::order::Identifier;
589 use crate::sqlite::status::OrderStatus;
590 use crate::testutil::{
591 account_id, account_seen_from, admin_session_fixture, admin_user_fixture, audit_entry,
592 client_context, order_fixture,
593 };
594
595 fn colour() -> Palette {
599 Palette::resolve(ColorChoice::Always, false, None)
600 }
601
602 fn strip_ansi(text: &str) -> String {
606 let mut out = String::with_capacity(text.len());
607 let mut rest = text;
608 while let Some(start) = rest.find('\x1b') {
609 out.push_str(&rest[..start]);
610 let Some(end) = rest[start..].find('m') else {
611 break;
612 };
613 rest = &rest[start + end + 1..];
614 }
615 out.push_str(rest);
616 out
617 }
618
619 #[test]
622 fn the_footer_reports_the_page_against_the_unpaged_total() {
623 assert_eq!(footer_line(2, 137), "2 of 137 row(s).");
624 assert_eq!(footer_line(0, 0), "0 of 0 row(s).");
626 }
627
628 #[test]
631 fn the_expiry_footer_names_the_rows_supersession_removed() {
632 assert_eq!(
633 expiring_footer_line(1, 4, 2),
634 "1 of 4 row(s), 2 superseded hidden."
635 );
636 assert_eq!(expiring_footer_line(4, 4, 0), footer_line(4, 4));
637 }
638
639 #[test]
643 fn the_json_envelope_matches_the_apis() {
644 let window = Window::resolve(2, 4);
645 let envelope = json_page(vec![serde_json::json!({"id": "a"})], 17, window);
646
647 assert_eq!(envelope["total"], 17);
648 assert_eq!(envelope["limit"], 2);
649 assert_eq!(envelope["offset"], 4);
650 assert_eq!(envelope["items"].as_array().unwrap().len(), 1);
651
652 let page = crate::webadmin::handlers::paging::Page {
653 limit: 2,
654 offset: 4,
655 };
656 assert_eq!(
657 envelope,
658 crate::webadmin::handlers::paging::page_envelope(
659 vec![serde_json::json!({"id": "a"})],
660 17,
661 page
662 )
663 );
664 }
665
666 #[test]
670 fn the_audit_line_renders_all_three_shapes_of_client() {
671 let entry = audit_entry();
672 let line = render_audit_line(&entry, Palette::plain());
673 assert!(line.contains("41812"), "{line}");
674 assert!(line.contains("certificate_issued"), "{line}");
675 assert!(line.contains("acme:acct-1"), "{line}");
676 assert!(line.contains("203.0.113.7 (host.example.com)"), "{line}");
677 assert!(line.contains("a.example.com,b.example.com"), "{line}");
678 assert!(!line.contains("reason="), "{line}");
680
681 let mut no_ptr = audit_entry();
682 no_ptr.client_ptr = None;
683 let line = render_audit_line(&no_ptr, Palette::plain());
684 assert!(line.contains("203.0.113.7"), "{line}");
685 assert!(!line.contains('('), "{line}");
686
687 let mut cli = audit_entry();
689 cli.actor_kind = "cli".to_string();
690 cli.actor_id = None;
691 cli.client_ip = None;
692 cli.client_ptr = None;
693 cli.event = "certificate_revoked".to_string();
694 cli.reason = Some("1".to_string());
695 let line = render_audit_line(&cli, Palette::plain());
696 assert!(line.contains(" cli "), "{line}");
697 assert!(
698 !line.contains("cli:"),
699 "an actor with no id must not render a trailing colon: {line}"
700 );
701 assert!(line.contains(" - "), "{line}");
702 assert!(line.ends_with("reason=1"), "{line}");
703 }
704
705 #[test]
709 fn only_a_failed_audit_event_is_painted() {
710 let succeeded = render_audit_line(&audit_entry(), colour());
711 assert!(!succeeded.contains('\x1b'), "{succeeded}");
712
713 let mut refused = audit_entry();
714 refused.event = "certificate_issue_failed".to_string();
715 refused.outcome = "failure".to_string();
716 let line = render_audit_line(&refused, colour());
717 assert!(line.contains("\x1b[31mcertificate_issue_failed"), "{line}");
718 assert_eq!(
719 strip_ansi(&line),
720 render_audit_line(&refused, Palette::plain()),
721 "colour must not move a column"
722 );
723 }
724
725 #[test]
728 fn the_audit_detail_omits_every_field_that_has_no_value() {
729 let full = render_audit_detail_text(&audit_entry(), Palette::plain());
730 for expected in [
731 "id 41812",
732 "event certificate_issued",
733 "outcome success",
734 "profile le",
735 "actor acme:acct-1",
736 "order order-1",
737 "serial 0a0b",
738 "client_ip 203.0.113.7",
739 "client_ptr host.example.com",
740 "user_agent certbot/2.9.0",
741 "request_id req-1",
742 "identifiers a.example.com,b.example.com",
743 ] {
744 assert!(full.contains(expected), "missing `{expected}` in:\n{full}");
745 }
746 assert!(!full.contains("reason"), "{full}");
747 assert!(!full.contains("detail"), "{full}");
748
749 let bare = AuditEntry {
750 actor_id: None,
751 account_id: None,
752 order_id: None,
753 cert_serial: None,
754 identifiers: vec![],
755 client_ip: None,
756 client_ptr: None,
757 user_agent: None,
758 request_id: None,
759 ..audit_entry()
760 };
761 let text = render_audit_detail_text(&bare, Palette::plain());
762 assert!(text.contains("actor acme\n"), "{text}");
763 for absent in ["account", "order", "serial", "client_ip", "identifiers"] {
764 assert!(
765 !text.contains(absent),
766 "`{absent}` should be absent from:\n{text}"
767 );
768 }
769 }
770
771 #[tokio::test]
774 async fn render_account_line_renders_all_three_shapes_of_client() {
775 let db = Arc::new(Database::connect_in_memory().await.unwrap());
776
777 let both = account_seen_from(
778 &[1u8, 2, 3],
779 &client_context(Some("203.0.113.7"), Some("host.example.com")),
780 &db,
781 )
782 .await;
783 let line = render_account_line(&both, Palette::plain());
784 assert!(line.contains(&both.id), "{line}");
785 assert!(line.contains("valid"), "{line}");
786 assert!(line.contains("mailto:a@example.com"), "{line}");
787 assert!(line.contains("203.0.113.7 (host.example.com)"), "{line}");
788 assert!(!line.contains(&pubkey_fingerprint(&both.pubkey)), "{line}");
790
791 let address_only = account_seen_from(
792 &[4u8, 5, 6],
793 &client_context(Some("203.0.113.7"), None),
794 &db,
795 )
796 .await;
797 let line = render_account_line(&address_only, Palette::plain());
798 assert!(line.contains("203.0.113.7"), "{line}");
799 assert!(!line.contains('('), "{line}");
800
801 let neither = account_seen_from(&[7u8, 8, 9], &ClientContext::default(), &db).await;
802 assert!(render_account_line(&neither, Palette::plain()).contains(" - "));
803 }
804
805 #[tokio::test]
808 async fn colour_never_moves_the_account_listings_columns() {
809 let db = Arc::new(Database::connect_in_memory().await.unwrap());
810 let account = account_seen_from(&[1u8, 2, 3], &ClientContext::default(), &db).await;
811
812 let painted = render_account_line(&account, colour());
813 assert!(painted.contains("\x1b[32mvalid \x1b[0m"), "{painted}");
814 assert_eq!(
815 strip_ansi(&painted),
816 render_account_line(&account, Palette::plain())
817 );
818 }
819
820 #[tokio::test]
821 async fn render_account_detail_text_omits_every_absent_field() {
822 let db = Arc::new(Database::connect_in_memory().await.unwrap());
823
824 let seen = account_seen_from(
825 &[1u8, 2, 3],
826 &client_context(Some("203.0.113.7"), Some("host.example.com")),
827 &db,
828 )
829 .await;
830 let text = render_account_detail_text(&seen, Palette::plain());
831 assert!(
832 text.contains(&format!("id {}", seen.id)),
833 "{text}"
834 );
835 assert!(text.contains("profile default"), "{text}");
836 assert!(text.contains("status valid"), "{text}");
837 assert!(
838 text.contains(&pubkey_fingerprint(&seen.pubkey)),
839 "the fingerprint the listing gave up must be here: {text}"
840 );
841 assert!(
842 text.contains("contact mailto:a@example.com"),
843 "{text}"
844 );
845 assert!(text.contains("created_ip 203.0.113.7"), "{text}");
846 assert!(text.contains("created_ptr host.example.com"), "{text}");
847 assert!(text.contains("last_seen "), "{text}");
848 assert!(text.contains("last_seen_ip 203.0.113.7"), "{text}");
849 assert!(text.contains("last_seen_ptr host.example.com"), "{text}");
850 for line in text.lines() {
853 assert_eq!(&line[13..14], " ", "misaligned: {line:?}");
854 assert_ne!(&line[14..15], " ", "misaligned: {line:?}");
855 }
856 assert!(!text.contains("eab_kid"), "{text}");
858 assert!(!text.contains("terms"), "{text}");
859
860 let bare =
861 Account::find_or_create("default", &[9u8], vec![], &ClientContext::default(), &db)
862 .await
863 .unwrap()
864 .0;
865 let text = render_account_detail_text(&bare, Palette::plain());
866 for absent in ["contact", "created_ip", "created_ptr", "last_seen_ip"] {
867 assert!(!text.contains(absent), "{absent} in {text}");
868 }
869 assert!(text.contains("last_seen "), "{text}");
871 }
872
873 #[test]
874 fn render_order_line_includes_expected_fields() {
875 let order = order_fixture("acct", OrderStatus::Pending);
876 let line = render_order_line(&order, Palette::plain());
877 assert!(line.contains(&order.id));
878 assert!(line.contains("pending"));
879 assert!(line.contains("example.com"));
880 }
881
882 #[test]
891 fn the_order_status_column_is_painted_by_what_it_means() {
892 for (status, code) in [
893 (OrderStatus::Valid, "32"),
894 (OrderStatus::Pending, "33"),
895 (OrderStatus::Invalid, "31"),
896 ] {
897 let order = order_fixture("acct", status);
898 let painted = render_order_line(&order, colour());
899 assert!(
900 painted.contains(&format!("\x1b[{code}m{}\x1b[0m", status.as_str())),
901 "{painted}"
902 );
903 assert_eq!(
904 strip_ansi(&painted),
905 render_order_line(&order, Palette::plain())
906 );
907 }
908 }
909
910 #[tokio::test]
911 async fn render_order_detail_text_surfaces_authorizations_and_challenges() {
912 let db = Arc::new(Database::connect_in_memory().await.unwrap());
913 let acct = account_id(&db).await;
914 let order = Order::create(
915 "default",
916 &acct,
917 vec![Identifier::dns("example.com")],
918 crate::sqlite::nonce::now_secs() + 3600,
919 None,
920 None,
921 &db,
922 )
923 .await
924 .unwrap();
925 let authz = Authorization::create(
926 &order.id,
927 Identifier::dns("example.com"),
928 crate::sqlite::nonce::now_secs() + 3600,
929 &db,
930 )
931 .await
932 .unwrap();
933 Challenge::create(&authz.id, "http-01", &db).await.unwrap();
934
935 let detail = load_order_detail(&order.id, db).await.unwrap().unwrap();
936 let text = render_order_detail_text(&detail, Palette::plain());
937 assert!(text.contains(&order.id));
938 assert!(text.contains(&authz.id));
939 assert!(text.contains("http-01"));
940
941 let painted = render_order_detail_text(&detail, colour());
944 assert_eq!(
945 painted.matches("\x1b[33mpending\x1b[0m").count(),
946 3,
947 "the order's, the authorization's and the challenge's: {painted}"
948 );
949 assert_eq!(strip_ansi(&painted), text);
950 }
951
952 #[test]
961 fn render_order_detail_text_omits_every_absent_field() {
962 let mut order = order_fixture("acct", OrderStatus::Valid);
963 order.not_before = Some(1700000000);
964 order.not_after = Some(1700003600);
965 order.replaces = Some("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE".to_string());
966 order.cert_serial = Some("03a7f1c9".to_string());
967 order.cert_not_after = Some(1700007200);
968 order.revoked_at = Some(1700010800);
969 order.revocation_reason = Some(1);
970 order.error = Some(serde_json::json!({
971 "type": "urn:ietf:params:acme:error:badCSR",
972 "detail": "CSR names do not match the order",
973 }));
974 let detail = OrderDetail {
975 order,
976 authorizations: vec![],
977 };
978
979 let text = render_order_detail_text(&detail, Palette::plain());
980 assert!(
981 text.contains(&format!("id {}", detail.order.id)),
982 "{text}"
983 );
984 assert!(text.contains("profile default"), "{text}");
985 assert!(text.contains("account_id acct"), "{text}");
986 assert!(text.contains("status valid"), "{text}");
987 assert!(text.contains("identifiers example.com"), "{text}");
988 assert!(text.contains("created "), "{text}");
989 assert!(text.contains("expires "), "{text}");
990 assert!(
991 text.contains("not_before 2023-11-14T22:13:20Z"),
992 "{text}"
993 );
994 assert!(
995 text.contains("not_after 2023-11-14T23:13:20Z"),
996 "{text}"
997 );
998 assert!(
999 text.contains("replaces aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE"),
1000 "{text}"
1001 );
1002 assert!(text.contains("serial 03a7f1c9"), "{text}");
1003 assert!(
1004 text.contains("cert_not_after 2023-11-15T00:13:20Z"),
1005 "{text}"
1006 );
1007 assert!(
1008 text.contains("revoked 2023-11-15T01:13:20Z"),
1009 "{text}"
1010 );
1011 assert!(text.contains("reason 1"), "{text}");
1012 assert!(
1015 text.contains("error CSR names do not match the order"),
1016 "{text}"
1017 );
1018 for line in text.lines() {
1021 assert_eq!(&line[14..15], " ", "misaligned: {line:?}");
1022 assert_ne!(&line[15..16], " ", "misaligned: {line:?}");
1023 }
1024
1025 let bare = OrderDetail {
1027 order: order_fixture("acct", OrderStatus::Pending),
1028 authorizations: vec![],
1029 };
1030 let text = render_order_detail_text(&bare, Palette::plain());
1031 for absent in [
1032 "not_before",
1033 "not_after",
1034 "replaces",
1035 "serial",
1036 "cert_not_after",
1037 "revoked",
1038 "reason",
1039 "error",
1040 ] {
1041 assert!(!text.contains(absent), "{absent} in {text}");
1042 }
1043 }
1044
1045 #[test]
1057 fn the_text_and_json_order_renderings_describe_the_same_order() {
1058 const FIELDS: &[(&str, &str)] = &[
1060 ("id", "id"),
1061 ("profile", "profile"),
1062 ("account_id", "accountId"),
1063 ("status", "status"),
1064 ("identifiers", "identifiers"),
1065 ("created", "createdAt"),
1066 ("expires", "expires"),
1067 ("not_before", "notBefore"),
1068 ("not_after", "notAfter"),
1069 ("replaces", "replaces"),
1070 ("serial", "certSerial"),
1071 ("cert_not_after", "certNotAfter"),
1072 ("revoked", "revokedAt"),
1073 ("reason", "revocationReason"),
1074 ("error", "error"),
1075 ];
1076 const JSON_ONLY: &[&str] = &[
1078 "authorizations",
1079 "finalize",
1080 "certificate",
1081 "certificatePem",
1082 ];
1083
1084 let mut order = order_fixture("acct", OrderStatus::Valid);
1087 order.not_before = Some(1700000000);
1088 order.not_after = Some(1700003600);
1089 order.replaces = Some("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE".to_string());
1090 order.cert_serial = Some("03a7f1c9".to_string());
1091 order.cert_not_after = Some(1700007200);
1092 order.revoked_at = Some(1700010800);
1093 order.revocation_reason = Some(1);
1094 order.error = Some(serde_json::json!({ "detail": "unreachable" }));
1095 order.certificate = Some("-----BEGIN CERTIFICATE-----\n".to_string());
1096 let detail = OrderDetail {
1097 order,
1098 authorizations: vec![],
1099 };
1100
1101 let json = crate::admin::render::render_order_detail_json(&detail, "http://localhost:3000");
1102 let members: std::collections::BTreeSet<&str> = json["order"]
1103 .as_object()
1104 .unwrap()
1105 .keys()
1106 .map(String::as_str)
1107 .filter(|member| !JSON_ONLY.contains(member))
1108 .collect();
1109 assert_eq!(
1110 members,
1111 FIELDS.iter().map(|(_, member)| *member).collect(),
1112 "a JSON member the text rendering does not print, or the reverse"
1113 );
1114
1115 let text = render_order_detail_text(&detail, Palette::plain());
1118 let labels: std::collections::BTreeSet<&str> = text
1119 .lines()
1120 .filter(|line| !line.starts_with(' '))
1121 .map(|line| line[..14].trim_end())
1122 .collect();
1123 assert_eq!(labels, FIELDS.iter().map(|(label, _)| *label).collect());
1124 }
1125
1126 #[test]
1130 fn an_unparsable_leaf_expiry_prints_no_line() {
1131 let mut order = order_fixture("acct", OrderStatus::Valid);
1132 order.cert_not_after = Some(crate::sqlite::order::UNPARSABLE_NOT_AFTER);
1133 let detail = OrderDetail {
1134 order,
1135 authorizations: vec![],
1136 };
1137 assert!(!render_order_detail_text(&detail, Palette::plain()).contains("cert_not_after"),);
1138 }
1139
1140 #[test]
1144 fn the_order_detail_paints_its_revocation_and_nothing_else_moves() {
1145 let mut order = order_fixture("acct", OrderStatus::Valid);
1146 order.revoked_at = Some(1700000000);
1147 order.revocation_reason = Some(4);
1148 let detail = OrderDetail {
1149 order,
1150 authorizations: vec![],
1151 };
1152 let painted = render_order_detail_text(&detail, colour());
1153 assert!(
1154 painted.contains("\x1b[31m2023-11-14T22:13:20Z\x1b[0m"),
1155 "{painted}"
1156 );
1157 assert_eq!(
1158 strip_ansi(&painted),
1159 render_order_detail_text(&detail, Palette::plain())
1160 );
1161 }
1162
1163 #[test]
1164 fn render_order_line_revoked_includes_reason_and_time() {
1165 let mut order = order_fixture("acct", OrderStatus::Valid);
1166 order.revoked_at = Some(1700000000);
1167 order.revocation_reason = Some(1);
1168 let line = render_order_line(&order, Palette::plain());
1169 assert!(line.contains("revoked="));
1170 assert!(line.contains("reason=1"));
1171 }
1172
1173 #[test]
1176 fn a_revoked_order_paints_its_suffix_even_though_its_status_is_valid() {
1177 let mut order = order_fixture("acct", OrderStatus::Valid);
1178 order.revoked_at = Some(1700000000);
1179 order.revocation_reason = Some(1);
1180 let painted = render_order_line(&order, colour());
1181 assert!(painted.contains("\x1b[32mvalid"), "{painted}");
1182 assert!(painted.contains("\x1b[31m revoked="), "{painted}");
1183 assert!(painted.ends_with("reason=1\x1b[0m"), "{painted}");
1184 assert_eq!(
1185 strip_ansi(&painted),
1186 render_order_line(&order, Palette::plain())
1187 );
1188 }
1189
1190 #[tokio::test]
1191 async fn render_eab_line_includes_expected_fields() {
1192 let db = Arc::new(Database::connect_in_memory().await.unwrap());
1193 let eab = Eab::create(Some("team-a".to_string()), None, &db)
1194 .await
1195 .unwrap();
1196 let line = render_eab_line(&eab, Palette::plain());
1197 assert!(line.contains(&eab.kid));
1198 assert!(line.contains("active"));
1199 assert!(line.contains("team-a"));
1200
1201 let painted = render_eab_line(&eab, colour());
1202 assert!(painted.contains("\x1b[32mactive \x1b[0m"), "{painted}");
1203 assert_eq!(strip_ansi(&painted), line);
1204 }
1205
1206 #[tokio::test]
1207 async fn render_eab_created_text_includes_kid_and_hmac_key() {
1208 let db = Arc::new(Database::connect_in_memory().await.unwrap());
1209 let eab = Eab::create(None, None, &db).await.unwrap();
1210 let text = render_eab_created_text(&eab, Palette::plain());
1211 assert!(text.contains(&eab.kid));
1212 assert!(text.contains(&BASE64_URL_SAFE_NO_PAD.encode(&eab.secret)));
1213 assert!(text.contains("Store the hmacKey now"));
1214 }
1215
1216 #[tokio::test]
1219 async fn the_eab_secret_warning_is_the_only_thing_painted() {
1220 let db = Arc::new(Database::connect_in_memory().await.unwrap());
1221 let eab = Eab::create(None, None, &db).await.unwrap();
1222 let painted = render_eab_created_text(&eab, colour());
1223 assert!(
1224 painted.contains("\x1b[33mStore the hmacKey now: it is shown only this once.\x1b[0m"),
1225 "{painted}"
1226 );
1227 assert_eq!(painted.matches('\x1b').count(), 2, "{painted}");
1228 assert_eq!(
1229 strip_ansi(&painted),
1230 render_eab_created_text(&eab, Palette::plain())
1231 );
1232 }
1233
1234 #[test]
1235 fn render_admin_user_line_never_shows_the_hash_and_says_never_for_no_login() {
1236 let user = admin_user_fixture();
1237 let line = render_admin_user_line(&user, Palette::plain());
1238 assert!(line.contains("alice"));
1239 assert!(line.contains("active"));
1240 assert!(line.contains("totp=off"));
1241 assert!(line.contains("never"));
1242 assert!(
1243 !line.contains("pbkdf2"),
1244 "the stored hash must never reach a terminal: {line}"
1245 );
1246 }
1247
1248 #[test]
1249 fn render_admin_user_line_reflects_totp_and_a_real_last_login() {
1250 let mut user = admin_user_fixture();
1251 user.totp_secret = Some(vec![1, 2, 3]);
1252 user.last_login_at = Some(1_700_000_500);
1253 let line = render_admin_user_line(&user, Palette::plain());
1254 assert!(line.contains("totp=on"));
1255 assert!(!line.contains("never"));
1256 }
1257
1258 #[test]
1261 fn an_operator_without_a_second_factor_stands_out() {
1262 let without = render_admin_user_line(&admin_user_fixture(), colour());
1263 assert!(without.contains("totp=\x1b[31moff\x1b[0m"), "{without}");
1264
1265 let mut user = admin_user_fixture();
1266 user.totp_secret = Some(vec![1, 2, 3]);
1267 user.status = "disabled".to_string();
1268 let with = render_admin_user_line(&user, colour());
1269 assert!(with.contains("totp=\x1b[32mon \x1b[0m"), "{with}");
1270 assert!(with.contains("\x1b[31mdisabled\x1b[0m"), "{with}");
1271 assert_eq!(
1272 strip_ansi(&with),
1273 render_admin_user_line(&user, Palette::plain())
1274 );
1275 }
1276
1277 #[test]
1280 fn the_totp_line_paints_each_of_its_three_states() {
1281 let plain = Palette::plain();
1282 let off = admin_user_fixture();
1283 assert!(
1284 render_admin_totp_line(&off, 0, plain).contains("totp=off"),
1285 "plain output unchanged"
1286 );
1287 assert!(render_admin_totp_line(&off, 0, colour()).contains("totp=\x1b[31moff\x1b[0m"));
1288
1289 let mut pending = admin_user_fixture();
1290 pending.totp_pending_secret = Some(vec![1, 2, 3]);
1291 let line = render_admin_totp_line(&pending, 0, colour());
1292 assert!(
1293 line.contains("\x1b[33mpending (enrolment started, never confirmed)\x1b[0m"),
1294 "{line}"
1295 );
1296 assert_eq!(
1297 strip_ansi(&line),
1298 render_admin_totp_line(&pending, 0, plain)
1299 );
1300
1301 let mut enabled = admin_user_fixture();
1302 enabled.totp_secret = Some(vec![1, 2, 3]);
1303 let line = render_admin_totp_line(&enabled, 7, colour());
1304 assert!(line.contains("totp=\x1b[32menabled\x1b[0m"), "{line}");
1305 assert!(line.contains("recovery-codes=7"), "{line}");
1306 }
1307
1308 #[test]
1309 fn render_admin_session_line_shows_a_fingerprint_not_the_token_hash() {
1310 let line = render_admin_session_line(&admin_session_fixture(), Palette::plain());
1311 assert!(line.contains("01234567"));
1312 assert!(
1313 !line.contains("0123456789abcdef0123456789abcdef"),
1314 "printing the hash would put every live session's lookup key on a terminal: {line}"
1315 );
1316 assert!(!line.contains("the-csrf-token"));
1317 assert!(line.contains("192.0.2.1"));
1318 assert!(line.contains("expires="));
1319 }
1320
1321 #[test]
1322 fn render_admin_session_line_dashes_a_missing_address() {
1323 let mut session = admin_session_fixture();
1324 session.created_ip = None;
1325 assert!(render_admin_session_line(&session, Palette::plain()).contains(" -"));
1326 }
1327
1328 #[test]
1331 fn a_pending_mfa_session_is_painted_apart_from_an_active_one() {
1332 let active = render_admin_session_line(&admin_session_fixture(), colour());
1333 assert!(active.contains("\x1b[32mactive \x1b[0m"), "{active}");
1334
1335 let mut session = admin_session_fixture();
1336 session.state = "pending_mfa".to_string();
1337 let painted = render_admin_session_line(&session, colour());
1338 assert!(painted.contains("\x1b[33mpending_mfa\x1b[0m"), "{painted}");
1339 assert_eq!(
1340 strip_ansi(&painted),
1341 render_admin_session_line(&session, Palette::plain())
1342 );
1343 }
1344
1345 #[test]
1348 fn the_expiring_line_bands_the_days_and_annotates_only_what_was_replaced() {
1349 let entry = |days: i64, superseded: Option<SupersededBy>| {
1350 let mut order = order_fixture("acct-1", OrderStatus::Valid);
1351 order.id = "ord-1".to_string();
1352 order.cert_not_after = Some(1_700_000_000);
1353 ExpiringEntry {
1354 order,
1355 days_remaining: days,
1356 superseded_by: superseded,
1357 }
1358 };
1359
1360 let plain = render_expiring_line(&entry(40, None), Palette::plain());
1361 assert!(plain.starts_with("ord-1 default "), "{plain}");
1362 assert!(plain.contains(" 40d "), "{plain}");
1363 assert!(plain.contains("2023-11-14"), "{plain}");
1364 assert!(plain.ends_with("example.com"), "{plain}");
1365 assert!(
1366 !plain.contains("replaced-by"),
1367 "the absent annotation is what an operator scans for: {plain}"
1368 );
1369
1370 assert!(render_expiring_line(&entry(3, None), colour()).contains("\x1b[31m"));
1372 assert!(render_expiring_line(&entry(20, None), colour()).contains("\x1b[33m"));
1373 let far = render_expiring_line(&entry(40, None), colour());
1374 assert_eq!(
1375 far,
1376 render_expiring_line(&entry(40, None), Palette::plain())
1377 );
1378
1379 let replaced = entry(
1380 3,
1381 Some(SupersededBy {
1382 order_id: "ord-2".to_string(),
1383 cert_serial: "0a0b".to_string(),
1384 not_after: 1_800_000_000,
1385 via: "replaces".to_string(),
1386 }),
1387 );
1388 let line = render_expiring_line(&replaced, Palette::plain());
1389 assert!(line.ends_with(" replaced-by=ord-2 via=replaces"), "{line}");
1390 }
1391
1392 #[test]
1395 fn colour_never_moves_the_expiring_listings_columns() {
1396 let mut order = order_fixture("acct-1", OrderStatus::Valid);
1397 order.cert_not_after = Some(1_700_000_000);
1398 let entry = ExpiringEntry {
1399 order,
1400 days_remaining: 3,
1401 superseded_by: Some(SupersededBy {
1402 order_id: "ord-2".to_string(),
1403 cert_serial: "0a0b".to_string(),
1404 not_after: 1_800_000_000,
1405 via: "identifiers".to_string(),
1406 }),
1407 };
1408
1409 let painted = render_expiring_line(&entry, colour());
1410 assert!(painted.contains("\x1b[31m 3d\x1b[0m"), "{painted}");
1411 assert_eq!(
1412 strip_ansi(&painted),
1413 render_expiring_line(&entry, Palette::plain())
1414 );
1415 }
1416}