1use base64::prelude::*;
23
24use super::style::Palette;
25use crate::admin::ops::OrderDetail;
26use crate::sqlite::account::{Account, pubkey_fingerprint};
27use crate::sqlite::admin_session::AdminSession;
28use crate::sqlite::admin_user::AdminUser;
29use crate::sqlite::audit::AuditEntry;
30use crate::sqlite::eab::Eab;
31use crate::sqlite::order::{Order, rfc3339};
32
33fn render_client(ip: Option<&String>, ptr: Option<&String>) -> String {
41 match (ip, ptr) {
42 (Some(ip), Some(ptr)) => format!("{ip} ({ptr})"),
43 (Some(ip), None) => ip.clone(),
44 _ => "-".to_string(),
45 }
46}
47
48#[must_use]
54pub fn render_account_line(account: &Account, palette: Palette) -> String {
55 format!(
56 "{} {:<12} {} {:<40} {} {}",
57 account.id,
58 account.profile,
59 palette.status(&format!("{:<11}", account.status)),
60 render_client(
61 account.last_seen_ip.as_ref(),
62 account.last_seen_ptr.as_ref()
63 ),
64 if account.contact.is_empty() {
65 "-".to_string()
66 } else {
67 account.contact.join(",")
68 },
69 rfc3339(account.created_at),
70 )
71}
72
73#[must_use]
79pub fn render_account_detail_text(account: &Account, palette: Palette) -> String {
80 let mut out = format!(
84 "id {}\nprofile {}\nstatus {}\npubkey {}\ncreated {}\n",
85 account.id,
86 account.profile,
87 palette.status(&account.status),
88 pubkey_fingerprint(&account.pubkey),
89 rfc3339(account.created_at),
90 );
91 if !account.contact.is_empty() {
92 out.push_str(&format!("contact {}\n", account.contact.join(",")));
93 }
94 if let Some(agreed) = account.terms_of_service_agreed {
95 out.push_str(&format!("terms {agreed}\n"));
96 }
97 if let Some(seen) = account.last_seen_at {
98 out.push_str(&format!("last_seen {}\n", rfc3339(seen)));
99 }
100 for (label, value) in [
101 ("eab_kid", account.eab_kid.as_ref()),
102 ("created_ip", account.created_ip.as_ref()),
103 ("created_ptr", account.created_ptr.as_ref()),
104 ("last_seen_ip", account.last_seen_ip.as_ref()),
105 ("last_seen_ptr", account.last_seen_ptr.as_ref()),
106 ] {
107 if let Some(value) = value {
108 out.push_str(&format!("{label:<13} {value}\n"));
109 }
110 }
111 out
112}
113
114fn paint_event(event: &str, width: usize, palette: Palette) -> String {
124 let padded = format!("{event:<width$}");
125 if event.ends_with("_failed") {
126 palette.bad(&padded)
127 } else {
128 padded
129 }
130}
131
132#[must_use]
137pub fn render_audit_line(entry: &AuditEntry, palette: Palette) -> String {
138 let actor = match &entry.actor_id {
139 Some(id) => format!("{}:{id}", entry.actor_kind),
140 None => entry.actor_kind.clone(),
141 };
142 let client = render_client(entry.client_ip.as_ref(), entry.client_ptr.as_ref());
143 let mut line = format!(
144 "{:<8} {} {} {:<12} {:<24} {:<40} {}",
145 entry.id,
146 rfc3339(entry.created_at),
147 paint_event(&entry.event, 26, palette),
148 entry.profile,
149 actor,
150 client,
151 entry.identifiers.join(","),
152 );
153 if let Some(reason) = &entry.reason {
154 line.push_str(&format!(" reason={reason}"));
155 }
156 line
157}
158
159#[must_use]
162pub fn render_audit_detail_text(entry: &AuditEntry, palette: Palette) -> String {
163 let mut out = format!(
164 "id {}\ncreated {}\nevent {}\noutcome {}\nprofile {}\nactor {}\n",
165 entry.id,
166 rfc3339(entry.created_at),
167 paint_event(&entry.event, 0, palette),
168 palette.status(&entry.outcome),
169 entry.profile,
170 match &entry.actor_id {
171 Some(id) => format!("{}:{id}", entry.actor_kind),
172 None => entry.actor_kind.clone(),
173 },
174 );
175 for (label, value) in [
176 ("account", entry.account_id.as_ref()),
177 ("order", entry.order_id.as_ref()),
178 ("serial", entry.cert_serial.as_ref()),
179 ("client_ip", entry.client_ip.as_ref()),
180 ("client_ptr", entry.client_ptr.as_ref()),
181 ("user_agent", entry.user_agent.as_ref()),
182 ("request_id", entry.request_id.as_ref()),
183 ("reason", entry.reason.as_ref()),
184 ("detail", entry.detail.as_ref()),
185 ] {
186 if let Some(value) = value {
187 out.push_str(&format!("{label:<12} {value}\n"));
188 }
189 }
190 if !entry.identifiers.is_empty() {
191 out.push_str(&format!("identifiers {}\n", entry.identifiers.join(",")));
192 }
193 out
194}
195
196#[must_use]
198pub fn render_order_line(order: &Order, palette: Palette) -> String {
199 let identifiers = order
200 .identifiers
201 .iter()
202 .map(|i| i.value.as_str())
203 .collect::<Vec<_>>()
204 .join(",");
205 let mut line = format!(
206 "{} {:<12} {} {} {}",
207 order.id,
208 order.profile,
209 palette.status(&format!("{:<9}", order.status)),
210 identifiers,
211 rfc3339(order.created_at)
212 );
213 if let Some(revoked_at) = order.revoked_at {
214 line.push_str(&palette.bad(&format!(
218 " revoked={}{}",
219 rfc3339(revoked_at),
220 order
221 .revocation_reason
222 .map(|r| format!(" reason={r}"))
223 .unwrap_or_default()
224 )));
225 }
226 line
227}
228
229#[must_use]
231pub fn render_order_detail_text(detail: &OrderDetail, palette: Palette) -> String {
232 let mut out = format!(
233 "id: {}\nprofile: {}\naccount_id: {}\nstatus: {}\nidentifiers: {}\nexpires: {}\n",
234 detail.order.id,
235 detail.order.profile,
236 detail.order.account_id,
237 palette.status(&detail.order.status.to_string()),
238 detail
239 .order
240 .identifiers
241 .iter()
242 .map(|i| i.value.as_str())
243 .collect::<Vec<_>>()
244 .join(","),
245 rfc3339(detail.order.expires),
246 );
247 for (authz, challenges) in &detail.authorizations {
248 out.push_str(&format!(
249 " authz {} [{}] {}\n",
250 authz.id,
251 palette.status(&authz.status.to_string()),
252 authz.identifier.value
253 ));
254 for challenge in challenges {
255 out.push_str(&format!(
256 " challenge {} [{}] type={}\n",
257 challenge.id,
258 palette.status(&challenge.status.to_string()),
259 challenge.typ
260 ));
261 }
262 }
263 out
264}
265
266#[must_use]
268pub fn render_eab_line(eab: &Eab, palette: Palette) -> String {
269 format!(
270 "{} {} {} {}",
271 eab.kid,
272 palette.status(&format!("{:<8}", eab.status)),
273 eab.label.as_deref().unwrap_or("-"),
274 rfc3339(eab.created_at),
275 )
276}
277
278#[must_use]
280pub fn render_eab_created_text(eab: &Eab, palette: Palette) -> String {
281 format!(
282 "kid: {}\nhmacKey: {}\nlabel: {}\n\n{}\n",
283 eab.kid,
284 BASE64_URL_SAFE_NO_PAD.encode(&eab.secret),
285 eab.label.as_deref().unwrap_or("-"),
286 palette.warn("Store the hmacKey now: it is shown only this once."),
287 )
288}
289
290#[must_use]
292pub fn render_admin_user_line(user: &AdminUser, palette: Palette) -> String {
293 format!(
294 "{:<20} {} totp={} {} {}",
295 user.username,
296 palette.status(&format!("{:<8}", user.status)),
297 palette.status(&format!(
298 "{:<3}",
299 if user.has_totp() { "on" } else { "off" }
300 )),
301 rfc3339(user.created_at),
302 user.last_login_at.map_or("never".to_string(), rfc3339),
303 )
304}
305
306#[must_use]
313pub fn render_admin_totp_line(
314 user: &AdminUser,
315 recovery_codes_remaining: i64,
316 palette: Palette,
317) -> String {
318 let state = if user.has_totp() {
322 palette.status("enabled")
323 } else if user.has_pending_totp() {
324 palette.warn("pending (enrolment started, never confirmed)")
325 } else {
326 palette.status("off")
327 };
328
329 format!(
330 "{:<20} totp={} recovery-codes={}",
331 user.username, state, recovery_codes_remaining
332 )
333}
334
335#[must_use]
340pub fn render_admin_session_line(session: &AdminSession, palette: Palette) -> String {
341 format!(
342 "{} {} {} {} expires={} {}",
343 crate::sqlite::nonce::fingerprint(&session.token_hash),
344 session.user_id,
345 palette.status(&format!("{:<11}", session.state)),
346 rfc3339(session.created_at),
347 rfc3339(session.expires_at),
348 session.created_ip.as_deref().unwrap_or("-"),
349 )
350}
351
352pub fn print_rows<T>(
363 rows: &[T],
364 json: bool,
365 to_json: impl Fn(&T) -> serde_json::Value,
366 to_line: impl Fn(&T) -> String,
367) {
368 if json {
369 let rendered: Vec<_> = rows.iter().map(to_json).collect();
370 println!("{}", serde_json::Value::Array(rendered));
371 } else {
372 for row in rows {
373 println!("{}", to_line(row));
374 }
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use std::sync::Arc;
381
382 use super::*;
383 use crate::admin::ops::load_order_detail;
384 use crate::audit::ClientContext;
385 use crate::cli::style::ColorChoice;
386 use crate::sqlite::authz::{Authorization, Challenge};
387 use crate::sqlite::db::Database;
388 use crate::sqlite::order::Identifier;
389 use crate::sqlite::status::OrderStatus;
390 use crate::testutil::{
391 account_id, account_seen_from, admin_session_fixture, admin_user_fixture, audit_entry,
392 client_context, order_fixture,
393 };
394
395 fn colour() -> Palette {
399 Palette::resolve(ColorChoice::Always, false, None)
400 }
401
402 fn strip_ansi(text: &str) -> String {
406 let mut out = String::with_capacity(text.len());
407 let mut rest = text;
408 while let Some(start) = rest.find('\x1b') {
409 out.push_str(&rest[..start]);
410 let Some(end) = rest[start..].find('m') else {
411 break;
412 };
413 rest = &rest[start + end + 1..];
414 }
415 out.push_str(rest);
416 out
417 }
418
419 #[test]
423 fn the_audit_line_renders_all_three_shapes_of_client() {
424 let entry = audit_entry();
425 let line = render_audit_line(&entry, Palette::plain());
426 assert!(line.contains("41812"), "{line}");
427 assert!(line.contains("certificate_issued"), "{line}");
428 assert!(line.contains("acme:acct-1"), "{line}");
429 assert!(line.contains("203.0.113.7 (host.example.com)"), "{line}");
430 assert!(line.contains("a.example.com,b.example.com"), "{line}");
431 assert!(!line.contains("reason="), "{line}");
433
434 let mut no_ptr = audit_entry();
435 no_ptr.client_ptr = None;
436 let line = render_audit_line(&no_ptr, Palette::plain());
437 assert!(line.contains("203.0.113.7"), "{line}");
438 assert!(!line.contains('('), "{line}");
439
440 let mut cli = audit_entry();
442 cli.actor_kind = "cli".to_string();
443 cli.actor_id = None;
444 cli.client_ip = None;
445 cli.client_ptr = None;
446 cli.event = "certificate_revoked".to_string();
447 cli.reason = Some("1".to_string());
448 let line = render_audit_line(&cli, Palette::plain());
449 assert!(line.contains(" cli "), "{line}");
450 assert!(
451 !line.contains("cli:"),
452 "an actor with no id must not render a trailing colon: {line}"
453 );
454 assert!(line.contains(" - "), "{line}");
455 assert!(line.ends_with("reason=1"), "{line}");
456 }
457
458 #[test]
462 fn only_a_failed_audit_event_is_painted() {
463 let succeeded = render_audit_line(&audit_entry(), colour());
464 assert!(!succeeded.contains('\x1b'), "{succeeded}");
465
466 let mut refused = audit_entry();
467 refused.event = "certificate_issue_failed".to_string();
468 refused.outcome = "failure".to_string();
469 let line = render_audit_line(&refused, colour());
470 assert!(line.contains("\x1b[31mcertificate_issue_failed"), "{line}");
471 assert_eq!(
472 strip_ansi(&line),
473 render_audit_line(&refused, Palette::plain()),
474 "colour must not move a column"
475 );
476 }
477
478 #[test]
481 fn the_audit_detail_omits_every_field_that_has_no_value() {
482 let full = render_audit_detail_text(&audit_entry(), Palette::plain());
483 for expected in [
484 "id 41812",
485 "event certificate_issued",
486 "outcome success",
487 "profile le",
488 "actor acme:acct-1",
489 "order order-1",
490 "serial 0a0b",
491 "client_ip 203.0.113.7",
492 "client_ptr host.example.com",
493 "user_agent certbot/2.9.0",
494 "request_id req-1",
495 "identifiers a.example.com,b.example.com",
496 ] {
497 assert!(full.contains(expected), "missing `{expected}` in:\n{full}");
498 }
499 assert!(!full.contains("reason"), "{full}");
500 assert!(!full.contains("detail"), "{full}");
501
502 let bare = AuditEntry {
503 actor_id: None,
504 account_id: None,
505 order_id: None,
506 cert_serial: None,
507 identifiers: vec![],
508 client_ip: None,
509 client_ptr: None,
510 user_agent: None,
511 request_id: None,
512 ..audit_entry()
513 };
514 let text = render_audit_detail_text(&bare, Palette::plain());
515 assert!(text.contains("actor acme\n"), "{text}");
516 for absent in ["account", "order", "serial", "client_ip", "identifiers"] {
517 assert!(
518 !text.contains(absent),
519 "`{absent}` should be absent from:\n{text}"
520 );
521 }
522 }
523
524 #[tokio::test]
527 async fn render_account_line_renders_all_three_shapes_of_client() {
528 let db = Arc::new(Database::connect_in_memory().await.unwrap());
529
530 let both = account_seen_from(
531 &[1u8, 2, 3],
532 &client_context(Some("203.0.113.7"), Some("host.example.com")),
533 &db,
534 )
535 .await;
536 let line = render_account_line(&both, Palette::plain());
537 assert!(line.contains(&both.id), "{line}");
538 assert!(line.contains("valid"), "{line}");
539 assert!(line.contains("mailto:a@example.com"), "{line}");
540 assert!(line.contains("203.0.113.7 (host.example.com)"), "{line}");
541 assert!(!line.contains(&pubkey_fingerprint(&both.pubkey)), "{line}");
543
544 let address_only = account_seen_from(
545 &[4u8, 5, 6],
546 &client_context(Some("203.0.113.7"), None),
547 &db,
548 )
549 .await;
550 let line = render_account_line(&address_only, Palette::plain());
551 assert!(line.contains("203.0.113.7"), "{line}");
552 assert!(!line.contains('('), "{line}");
553
554 let neither = account_seen_from(&[7u8, 8, 9], &ClientContext::default(), &db).await;
555 assert!(render_account_line(&neither, Palette::plain()).contains(" - "));
556 }
557
558 #[tokio::test]
561 async fn colour_never_moves_the_account_listings_columns() {
562 let db = Arc::new(Database::connect_in_memory().await.unwrap());
563 let account = account_seen_from(&[1u8, 2, 3], &ClientContext::default(), &db).await;
564
565 let painted = render_account_line(&account, colour());
566 assert!(painted.contains("\x1b[32mvalid \x1b[0m"), "{painted}");
567 assert_eq!(
568 strip_ansi(&painted),
569 render_account_line(&account, Palette::plain())
570 );
571 }
572
573 #[tokio::test]
574 async fn render_account_detail_text_omits_every_absent_field() {
575 let db = Arc::new(Database::connect_in_memory().await.unwrap());
576
577 let seen = account_seen_from(
578 &[1u8, 2, 3],
579 &client_context(Some("203.0.113.7"), Some("host.example.com")),
580 &db,
581 )
582 .await;
583 let text = render_account_detail_text(&seen, Palette::plain());
584 assert!(
585 text.contains(&format!("id {}", seen.id)),
586 "{text}"
587 );
588 assert!(text.contains("profile default"), "{text}");
589 assert!(text.contains("status valid"), "{text}");
590 assert!(
591 text.contains(&pubkey_fingerprint(&seen.pubkey)),
592 "the fingerprint the listing gave up must be here: {text}"
593 );
594 assert!(
595 text.contains("contact mailto:a@example.com"),
596 "{text}"
597 );
598 assert!(text.contains("created_ip 203.0.113.7"), "{text}");
599 assert!(text.contains("created_ptr host.example.com"), "{text}");
600 assert!(text.contains("last_seen "), "{text}");
601 assert!(text.contains("last_seen_ip 203.0.113.7"), "{text}");
602 assert!(text.contains("last_seen_ptr host.example.com"), "{text}");
603 for line in text.lines() {
606 assert_eq!(&line[13..14], " ", "misaligned: {line:?}");
607 assert_ne!(&line[14..15], " ", "misaligned: {line:?}");
608 }
609 assert!(!text.contains("eab_kid"), "{text}");
611 assert!(!text.contains("terms"), "{text}");
612
613 let bare =
614 Account::find_or_create("default", &[9u8], vec![], &ClientContext::default(), &db)
615 .await
616 .unwrap()
617 .0;
618 let text = render_account_detail_text(&bare, Palette::plain());
619 for absent in ["contact", "created_ip", "created_ptr", "last_seen_ip"] {
620 assert!(!text.contains(absent), "{absent} in {text}");
621 }
622 assert!(text.contains("last_seen "), "{text}");
624 }
625
626 #[test]
627 fn render_order_line_includes_expected_fields() {
628 let order = order_fixture("acct", OrderStatus::Pending);
629 let line = render_order_line(&order, Palette::plain());
630 assert!(line.contains(&order.id));
631 assert!(line.contains("pending"));
632 assert!(line.contains("example.com"));
633 }
634
635 #[test]
644 fn the_order_status_column_is_painted_by_what_it_means() {
645 for (status, code) in [
646 (OrderStatus::Valid, "32"),
647 (OrderStatus::Pending, "33"),
648 (OrderStatus::Invalid, "31"),
649 ] {
650 let order = order_fixture("acct", status);
651 let painted = render_order_line(&order, colour());
652 assert!(
653 painted.contains(&format!("\x1b[{code}m{}\x1b[0m", status.as_str())),
654 "{painted}"
655 );
656 assert_eq!(
657 strip_ansi(&painted),
658 render_order_line(&order, Palette::plain())
659 );
660 }
661 }
662
663 #[tokio::test]
664 async fn render_order_detail_text_surfaces_authorizations_and_challenges() {
665 let db = Arc::new(Database::connect_in_memory().await.unwrap());
666 let acct = account_id(&db).await;
667 let order = Order::create(
668 "default",
669 &acct,
670 vec![Identifier::dns("example.com")],
671 crate::sqlite::nonce::now_secs() + 3600,
672 None,
673 None,
674 &db,
675 )
676 .await
677 .unwrap();
678 let authz = Authorization::create(
679 &order.id,
680 Identifier::dns("example.com"),
681 crate::sqlite::nonce::now_secs() + 3600,
682 &db,
683 )
684 .await
685 .unwrap();
686 Challenge::create(&authz.id, "http-01", &db).await.unwrap();
687
688 let detail = load_order_detail(&order.id, db).await.unwrap().unwrap();
689 let text = render_order_detail_text(&detail, Palette::plain());
690 assert!(text.contains(&order.id));
691 assert!(text.contains(&authz.id));
692 assert!(text.contains("http-01"));
693
694 let painted = render_order_detail_text(&detail, colour());
697 assert_eq!(painted.matches("\x1b[33m").count(), 3, "{painted}");
698 assert_eq!(strip_ansi(&painted), text);
699 }
700
701 #[test]
702 fn render_order_line_revoked_includes_reason_and_time() {
703 let mut order = order_fixture("acct", OrderStatus::Valid);
704 order.revoked_at = Some(1700000000);
705 order.revocation_reason = Some(1);
706 let line = render_order_line(&order, Palette::plain());
707 assert!(line.contains("revoked="));
708 assert!(line.contains("reason=1"));
709 }
710
711 #[test]
714 fn a_revoked_order_paints_its_suffix_even_though_its_status_is_valid() {
715 let mut order = order_fixture("acct", OrderStatus::Valid);
716 order.revoked_at = Some(1700000000);
717 order.revocation_reason = Some(1);
718 let painted = render_order_line(&order, colour());
719 assert!(painted.contains("\x1b[32mvalid"), "{painted}");
720 assert!(painted.contains("\x1b[31m revoked="), "{painted}");
721 assert!(painted.ends_with("reason=1\x1b[0m"), "{painted}");
722 assert_eq!(
723 strip_ansi(&painted),
724 render_order_line(&order, Palette::plain())
725 );
726 }
727
728 #[tokio::test]
729 async fn render_eab_line_includes_expected_fields() {
730 let db = Arc::new(Database::connect_in_memory().await.unwrap());
731 let eab = Eab::create(Some("team-a".to_string()), None, &db)
732 .await
733 .unwrap();
734 let line = render_eab_line(&eab, Palette::plain());
735 assert!(line.contains(&eab.kid));
736 assert!(line.contains("active"));
737 assert!(line.contains("team-a"));
738
739 let painted = render_eab_line(&eab, colour());
740 assert!(painted.contains("\x1b[32mactive \x1b[0m"), "{painted}");
741 assert_eq!(strip_ansi(&painted), line);
742 }
743
744 #[tokio::test]
745 async fn render_eab_created_text_includes_kid_and_hmac_key() {
746 let db = Arc::new(Database::connect_in_memory().await.unwrap());
747 let eab = Eab::create(None, None, &db).await.unwrap();
748 let text = render_eab_created_text(&eab, Palette::plain());
749 assert!(text.contains(&eab.kid));
750 assert!(text.contains(&BASE64_URL_SAFE_NO_PAD.encode(&eab.secret)));
751 assert!(text.contains("Store the hmacKey now"));
752 }
753
754 #[tokio::test]
757 async fn the_eab_secret_warning_is_the_only_thing_painted() {
758 let db = Arc::new(Database::connect_in_memory().await.unwrap());
759 let eab = Eab::create(None, None, &db).await.unwrap();
760 let painted = render_eab_created_text(&eab, colour());
761 assert!(
762 painted.contains("\x1b[33mStore the hmacKey now: it is shown only this once.\x1b[0m"),
763 "{painted}"
764 );
765 assert_eq!(painted.matches('\x1b').count(), 2, "{painted}");
766 assert_eq!(
767 strip_ansi(&painted),
768 render_eab_created_text(&eab, Palette::plain())
769 );
770 }
771
772 #[test]
773 fn render_admin_user_line_never_shows_the_hash_and_says_never_for_no_login() {
774 let user = admin_user_fixture();
775 let line = render_admin_user_line(&user, Palette::plain());
776 assert!(line.contains("alice"));
777 assert!(line.contains("active"));
778 assert!(line.contains("totp=off"));
779 assert!(line.contains("never"));
780 assert!(
781 !line.contains("pbkdf2"),
782 "the stored hash must never reach a terminal: {line}"
783 );
784 }
785
786 #[test]
787 fn render_admin_user_line_reflects_totp_and_a_real_last_login() {
788 let mut user = admin_user_fixture();
789 user.totp_secret = Some(vec![1, 2, 3]);
790 user.last_login_at = Some(1_700_000_500);
791 let line = render_admin_user_line(&user, Palette::plain());
792 assert!(line.contains("totp=on"));
793 assert!(!line.contains("never"));
794 }
795
796 #[test]
799 fn an_operator_without_a_second_factor_stands_out() {
800 let without = render_admin_user_line(&admin_user_fixture(), colour());
801 assert!(without.contains("totp=\x1b[31moff\x1b[0m"), "{without}");
802
803 let mut user = admin_user_fixture();
804 user.totp_secret = Some(vec![1, 2, 3]);
805 user.status = "disabled".to_string();
806 let with = render_admin_user_line(&user, colour());
807 assert!(with.contains("totp=\x1b[32mon \x1b[0m"), "{with}");
808 assert!(with.contains("\x1b[31mdisabled\x1b[0m"), "{with}");
809 assert_eq!(
810 strip_ansi(&with),
811 render_admin_user_line(&user, Palette::plain())
812 );
813 }
814
815 #[test]
818 fn the_totp_line_paints_each_of_its_three_states() {
819 let plain = Palette::plain();
820 let off = admin_user_fixture();
821 assert!(
822 render_admin_totp_line(&off, 0, plain).contains("totp=off"),
823 "plain output unchanged"
824 );
825 assert!(render_admin_totp_line(&off, 0, colour()).contains("totp=\x1b[31moff\x1b[0m"));
826
827 let mut pending = admin_user_fixture();
828 pending.totp_pending_secret = Some(vec![1, 2, 3]);
829 let line = render_admin_totp_line(&pending, 0, colour());
830 assert!(
831 line.contains("\x1b[33mpending (enrolment started, never confirmed)\x1b[0m"),
832 "{line}"
833 );
834 assert_eq!(
835 strip_ansi(&line),
836 render_admin_totp_line(&pending, 0, plain)
837 );
838
839 let mut enabled = admin_user_fixture();
840 enabled.totp_secret = Some(vec![1, 2, 3]);
841 let line = render_admin_totp_line(&enabled, 7, colour());
842 assert!(line.contains("totp=\x1b[32menabled\x1b[0m"), "{line}");
843 assert!(line.contains("recovery-codes=7"), "{line}");
844 }
845
846 #[test]
847 fn render_admin_session_line_shows_a_fingerprint_not_the_token_hash() {
848 let line = render_admin_session_line(&admin_session_fixture(), Palette::plain());
849 assert!(line.contains("01234567"));
850 assert!(
851 !line.contains("0123456789abcdef0123456789abcdef"),
852 "printing the hash would put every live session's lookup key on a terminal: {line}"
853 );
854 assert!(!line.contains("the-csrf-token"));
855 assert!(line.contains("192.0.2.1"));
856 assert!(line.contains("expires="));
857 }
858
859 #[test]
860 fn render_admin_session_line_dashes_a_missing_address() {
861 let mut session = admin_session_fixture();
862 session.created_ip = None;
863 assert!(render_admin_session_line(&session, Palette::plain()).contains(" -"));
864 }
865
866 #[test]
869 fn a_pending_mfa_session_is_painted_apart_from_an_active_one() {
870 let active = render_admin_session_line(&admin_session_fixture(), colour());
871 assert!(active.contains("\x1b[32mactive \x1b[0m"), "{active}");
872
873 let mut session = admin_session_fixture();
874 session.state = "pending_mfa".to_string();
875 let painted = render_admin_session_line(&session, colour());
876 assert!(painted.contains("\x1b[33mpending_mfa\x1b[0m"), "{painted}");
877 assert_eq!(
878 strip_ansi(&painted),
879 render_admin_session_line(&session, Palette::plain())
880 );
881 }
882}