1use base64::prelude::*;
23
24use super::window::Window;
25use acme_proxy_admin::admin::ProfileSummary;
26use acme_proxy_admin::admin::ops::JobDetail;
27use acme_proxy_admin::admin::ops::OrderDetail;
28use acme_proxy_admin::admin::ops::UpstreamOrderDetail;
29use acme_proxy_core::datetime::rfc3339;
30use acme_proxy_core::palette::Palette;
31use acme_proxy_store::account::Account;
32use acme_proxy_store::account::pubkey_fingerprint;
33use acme_proxy_store::admin_session::AdminSession;
34use acme_proxy_store::admin_user::AdminUser;
35use acme_proxy_store::audit::AuditEntry;
36use acme_proxy_store::eab::Eab;
37use acme_proxy_store::expiring::ExpiringEntry;
38use acme_proxy_store::job::Job;
39use acme_proxy_store::order::Order;
40use acme_proxy_store::upstream_order::UpstreamOrderRow;
41
42fn render_client(ip: Option<&String>, ptr: Option<&String>) -> String {
50 match (ip, ptr) {
51 (Some(ip), Some(ptr)) => format!("{ip} ({ptr})"),
52 (Some(ip), None) => ip.clone(),
53 _ => "-".to_string(),
54 }
55}
56
57#[must_use]
63pub fn render_account_line(account: &Account, palette: Palette) -> String {
64 format!(
65 "{} {:<12} {} {:<40} {} {}",
66 account.id,
67 account.profile,
68 palette.status(&format!("{:<11}", account.status)),
69 render_client(
70 account.last_seen_ip.as_ref(),
71 account.last_seen_ptr.as_ref()
72 ),
73 if account.contact.is_empty() {
74 "-".to_string()
75 } else {
76 account.contact.join(",")
77 },
78 rfc3339(account.created_at),
79 )
80}
81
82#[must_use]
88pub fn render_account_detail_text(account: &Account, palette: Palette) -> String {
89 let mut out = format!(
93 "id {}\nprofile {}\nstatus {}\npubkey {}\ncreated {}\n",
94 account.id,
95 account.profile,
96 palette.status(&account.status),
97 pubkey_fingerprint(&account.pubkey),
98 rfc3339(account.created_at),
99 );
100 if !account.contact.is_empty() {
101 out.push_str(&format!("contact {}\n", account.contact.join(",")));
102 }
103 if let Some(agreed) = account.terms_of_service_agreed {
104 out.push_str(&format!("terms {agreed}\n"));
105 }
106 if let Some(seen) = account.last_seen_at {
107 out.push_str(&format!("last_seen {}\n", rfc3339(seen)));
108 }
109 if let Some(kid) = account.eab_kid {
110 out.push_str(&format!("eab_kid {kid}\n"));
111 }
112 for (label, value) in [
113 ("created_ip", account.created_ip.as_ref()),
114 ("created_ptr", account.created_ptr.as_ref()),
115 ("last_seen_ip", account.last_seen_ip.as_ref()),
116 ("last_seen_ptr", account.last_seen_ptr.as_ref()),
117 ] {
118 if let Some(value) = value {
119 out.push_str(&format!("{label:<13} {value}\n"));
120 }
121 }
122 out
123}
124
125fn paint_event(event: &str, width: usize, palette: Palette) -> String {
135 let padded = format!("{event:<width$}");
136 if event.ends_with("_failed") {
137 palette.bad(&padded)
138 } else {
139 padded
140 }
141}
142
143#[must_use]
148pub fn render_audit_line(entry: &AuditEntry, palette: Palette) -> String {
149 let actor = match &entry.actor_id {
150 Some(id) => format!("{}:{id}", entry.actor_kind),
151 None => entry.actor_kind.clone(),
152 };
153 let client = render_client(entry.client_ip.as_ref(), entry.client_ptr.as_ref());
154 let mut line = format!(
155 "{:<8} {} {} {:<12} {:<24} {:<40} {}",
156 entry.id,
157 rfc3339(entry.created_at),
158 paint_event(&entry.event, 26, palette),
159 entry.profile,
160 actor,
161 client,
162 entry.identifiers.join(","),
163 );
164 if let Some(reason) = &entry.reason {
165 line.push_str(&format!(" reason={reason}"));
166 }
167 line
168}
169
170#[must_use]
173pub fn render_audit_detail_text(entry: &AuditEntry, palette: Palette) -> String {
174 let mut out = format!(
175 "id {}\ncreated {}\nevent {}\noutcome {}\nprofile {}\nactor {}\n",
176 entry.id,
177 rfc3339(entry.created_at),
178 paint_event(&entry.event, 0, palette),
179 palette.status(&entry.outcome),
180 entry.profile,
181 match &entry.actor_id {
182 Some(id) => format!("{}:{id}", entry.actor_kind),
183 None => entry.actor_kind.clone(),
184 },
185 );
186 for (label, value) in [
187 ("account", entry.account_id.as_ref()),
188 ("order", entry.order_id.as_ref()),
189 ("serial", entry.cert_serial.as_ref()),
190 ("client_ip", entry.client_ip.as_ref()),
191 ("client_ptr", entry.client_ptr.as_ref()),
192 ("user_agent", entry.user_agent.as_ref()),
193 ("request_id", entry.request_id.as_ref()),
194 ("reason", entry.reason.as_ref()),
195 ("detail", entry.detail.as_ref()),
196 ] {
197 if let Some(value) = value {
198 out.push_str(&format!("{label:<12} {value}\n"));
199 }
200 }
201 if !entry.identifiers.is_empty() {
202 out.push_str(&format!("identifiers {}\n", entry.identifiers.join(",")));
203 }
204 out
205}
206
207#[must_use]
209pub fn render_order_line(order: &Order, palette: Palette) -> String {
210 let identifiers = order
211 .identifiers
212 .iter()
213 .map(|i| i.value.as_str())
214 .collect::<Vec<_>>()
215 .join(",");
216 let mut line = format!(
217 "{} {:<12} {} {} {}",
218 order.id,
219 order.profile,
220 palette.status(&format!("{:<9}", order.status)),
221 identifiers,
222 rfc3339(order.created_at)
223 );
224 if let Some(revoked_at) = order.revoked_at {
225 line.push_str(&palette.bad(&format!(
229 " revoked={}{}",
230 rfc3339(revoked_at),
231 order
232 .revocation_reason
233 .map(|r| format!(" reason={r}"))
234 .unwrap_or_default()
235 )));
236 }
237 line
238}
239
240#[must_use]
249pub fn render_expiring_line(entry: &ExpiringEntry, palette: Palette) -> String {
250 let order = &entry.order;
251 let identifiers = order
252 .identifiers
253 .iter()
254 .map(|i| i.value.as_str())
255 .collect::<Vec<_>>()
256 .join(",");
257 let days = format!("{:>5}", format!("{}d", entry.days_remaining));
259 let days = match entry.days_remaining {
260 0..=7 => palette.bad(&days),
261 8..=30 => palette.warn(&days),
262 _ => days,
263 };
264 let mut line = format!(
265 "{} {:<12} {} {} {}",
266 order.id,
267 order.profile,
268 days,
269 rfc3339(order.cert_not_after.unwrap_or_default()),
270 identifiers
271 );
272 if let Some(superseded) = &entry.superseded_by {
273 line.push_str(&palette.ok(&format!(
274 " replaced-by={} via={}",
275 superseded.order_id, superseded.via
276 )));
277 }
278 line
279}
280
281#[must_use]
284pub fn render_job_line(job: &Job, palette: Palette) -> String {
285 let mut line = format!(
286 "{} {:<22} {} {:>5} {} {}",
287 job.id,
288 job.kind,
289 palette.status(&format!("{:<10}", job.status)),
290 format!("{}/{}", job.attempts, job.max_attempts),
291 rfc3339(job.run_at),
292 job.dedup_key,
293 );
294 if let Some(error) = job.last_error.as_deref() {
295 line.push_str(&palette.bad(&format!(" error={}", truncate(error, 60))));
296 }
297 line
298}
299
300#[must_use]
304pub fn render_job_detail_text(detail: &JobDetail, palette: Palette) -> String {
305 let job = &detail.job;
306 let mut out = String::new();
307 out.push_str(&format!("{:<13} {}\n", "id", job.id));
308 out.push_str(&format!("{:<13} {}\n", "kind", job.kind));
309 out.push_str(&format!("{:<13} {}\n", "dedup_key", job.dedup_key));
310 out.push_str(&format!(
311 "{:<13} {}\n",
312 "status",
313 palette.status(&job.status)
314 ));
315 out.push_str(&format!("{:<13} {}\n", "run_at", rfc3339(job.run_at)));
316 out.push_str(&format!(
317 "{:<13} {}/{}\n",
318 "attempts", job.attempts, job.max_attempts
319 ));
320 for (label, value) in [
321 ("deadline", job.deadline.map(rfc3339)),
322 ("lease_until", job.lease_until.map(rfc3339)),
323 ("lease_owner", job.lease_owner.clone()),
324 ("last_error", job.last_error.clone()),
325 ] {
326 if let Some(value) = value {
327 out.push_str(&format!("{label:<13} {value}\n"));
328 }
329 }
330 out.push_str(&format!(
331 "{:<13} {}\n",
332 "created_at",
333 rfc3339(job.created_at)
334 ));
335 out.push_str(&format!(
336 "{:<13} {}\n",
337 "updated_at",
338 rfc3339(job.updated_at)
339 ));
340 out.push_str(&format!("{:<13} {}\n", "payload", job.payload));
341
342 if let Some(upstream) = detail.upstream_order.as_ref() {
343 out.push_str("\nUpstream order:\n");
344 out.push_str(&render_upstream_order_block(upstream, palette));
345 }
346 out
347}
348
349#[must_use]
351pub fn render_upstream_order_line(row: &UpstreamOrderRow, palette: Palette) -> String {
352 let identifiers = row
353 .identifiers
354 .iter()
355 .map(|i| i.value.as_str())
356 .collect::<Vec<_>>()
357 .join(",");
358 format!(
359 "{} {:<12} {} {:<10} {} {}",
360 row.order_id,
361 row.profile,
362 palette.status(&format!("{:<10}", row.status)),
363 row.local_status,
364 identifiers,
365 rfc3339(row.updated_at),
366 )
367}
368
369fn render_upstream_order_block(row: &UpstreamOrderRow, palette: Palette) -> String {
373 let mut out = String::new();
374 let identifiers = row
375 .identifiers
376 .iter()
377 .map(|i| i.value.as_str())
378 .collect::<Vec<_>>()
379 .join(",");
380 out.push_str(&format!(" {:<24} {}\n", "order_id", row.order_id));
381 out.push_str(&format!(" {:<24} {}\n", "profile", row.profile));
382 out.push_str(&format!(
383 " {:<24} {}\n",
384 "status",
385 palette.status(&row.status)
386 ));
387 out.push_str(&format!(" {:<24} {}\n", "local_status", row.local_status));
388 out.push_str(&format!(" {:<24} {identifiers}\n", "identifiers"));
389 out.push_str(&format!(
390 " {:<24} {}\n",
391 "upstream_order_url", row.upstream_order_url
392 ));
393 for (label, value) in [
394 ("upstream_finalize_url", row.upstream_finalize_url.clone()),
395 (
396 "upstream_certificate_url",
397 row.upstream_certificate_url.clone(),
398 ),
399 ("error", row.error.clone()),
400 ("client_ip", row.client_ip.clone()),
401 ("client_ptr", row.client_ptr.clone()),
402 ("user_agent", row.user_agent.clone()),
403 ("request_id", row.request_id.clone()),
404 ] {
405 if let Some(value) = value {
406 out.push_str(&format!(" {label:<24} {value}\n"));
407 }
408 }
409 out.push_str(&format!(
410 " {:<24} {}\n",
411 "created_at",
412 rfc3339(row.created_at)
413 ));
414 out.push_str(&format!(
415 " {:<24} {}\n",
416 "updated_at",
417 rfc3339(row.updated_at)
418 ));
419 out
420}
421
422#[must_use]
425pub fn render_upstream_order_detail_text(detail: &UpstreamOrderDetail, palette: Palette) -> String {
426 let mut out = render_upstream_order_block(&detail.upstream_order, palette);
427 if let Some(job) = detail.job.as_ref() {
428 out.push_str("\nRelay job:\n");
429 out.push_str(&format!(" {:<24} {}\n", "id", job.id));
430 out.push_str(&format!(
431 " {:<24} {}\n",
432 "status",
433 palette.status(&job.status)
434 ));
435 out.push_str(&format!(
436 " {:<24} {}/{}\n",
437 "attempts", job.attempts, job.max_attempts
438 ));
439 out.push_str(&format!(" {:<24} {}\n", "run_at", rfc3339(job.run_at)));
440 if let Some(error) = job.last_error.as_deref() {
441 out.push_str(&format!(" {:<24} {error}\n", "last_error"));
442 }
443 }
444 out
445}
446
447fn truncate(text: &str, max: usize) -> String {
449 if text.len() <= max {
450 return text.to_string();
451 }
452 let mut end = max;
453 while !text.is_char_boundary(end) {
454 end -= 1;
455 }
456 format!("{}…", &text[..end])
457}
458
459fn problem_summary(error: &serde_json::Value) -> String {
464 for member in ["detail", "type"] {
465 if let Some(text) = error.get(member).and_then(serde_json::Value::as_str) {
466 return text.to_string();
467 }
468 }
469 error.to_string()
470}
471
472#[must_use]
492pub fn render_order_detail_text(detail: &OrderDetail, palette: Palette) -> String {
493 let order = &detail.order;
494 let mut out = format!(
499 "id {}\nprofile {}\naccount_id {}\nstatus {}\nidentifiers {}\ncreated {}\nexpires {}\n",
500 order.id,
501 order.profile,
502 order.account_id,
503 palette.status(&order.status.to_string()),
504 order
505 .identifiers
506 .iter()
507 .map(|i| i.value.as_str())
508 .collect::<Vec<_>>()
509 .join(","),
510 rfc3339(order.created_at),
511 rfc3339(order.expires),
512 );
513 for (label, value) in [
514 ("not_before", order.not_before.map(rfc3339)),
515 ("not_after", order.not_after.map(rfc3339)),
516 ("replaces", order.replaces.clone()),
517 ("serial", order.cert_serial.clone()),
518 (
521 "cert_not_after",
522 order
523 .cert_not_after
524 .filter(|value| *value >= 0)
525 .map(rfc3339),
526 ),
527 (
533 "revoked",
534 order.revoked_at.map(|at| palette.bad(&rfc3339(at))),
535 ),
536 (
537 "reason",
538 order
539 .revoked_at
540 .and(order.revocation_reason)
541 .map(|reason| reason.to_string()),
542 ),
543 ("error", order.error.as_ref().map(problem_summary)),
544 ] {
545 if let Some(value) = value {
546 out.push_str(&format!("{label:<14} {value}\n"));
547 }
548 }
549 for (authz, challenges) in &detail.authorizations {
550 out.push_str(&format!(
551 " authz {} [{}] {}\n",
552 authz.id,
553 palette.status(&authz.status.to_string()),
554 authz.identifier.value
555 ));
556 for challenge in challenges {
557 out.push_str(&format!(
558 " challenge {} [{}] type={}\n",
559 challenge.id,
560 palette.status(&challenge.status.to_string()),
561 challenge.typ
562 ));
563 }
564 }
565 out
566}
567
568#[must_use]
570pub fn render_eab_line(eab: &Eab, palette: Palette) -> String {
571 format!(
572 "{} {} {} {}",
573 eab.kid,
574 palette.status(&format!("{:<8}", eab.status)),
575 eab.label.as_deref().unwrap_or("-"),
576 rfc3339(eab.created_at),
577 )
578}
579
580#[must_use]
582pub fn render_eab_created_text(eab: &Eab, palette: Palette) -> String {
583 format!(
584 "kid: {}\nhmacKey: {}\nlabel: {}\n\n{}\n",
585 eab.kid,
586 BASE64_URL_SAFE_NO_PAD.encode(&eab.secret),
587 eab.label.as_deref().unwrap_or("-"),
588 palette.warn("Store the hmacKey now: it is shown only this once."),
589 )
590}
591
592#[must_use]
597pub fn render_admin_user_line(user: &AdminUser, palette: Palette) -> String {
598 format!(
599 "{:<20} {} {:<8} totp={} {} {}",
600 user.username,
601 palette.status(&format!("{:<8}", user.status)),
602 user.role().as_str(),
603 palette.status(&format!(
604 "{:<3}",
605 if user.has_totp() { "on" } else { "off" }
606 )),
607 rfc3339(user.created_at),
608 user.last_login_at.map_or("never".to_string(), rfc3339),
609 )
610}
611
612#[must_use]
619pub fn render_admin_totp_line(
620 user: &AdminUser,
621 recovery_codes_remaining: i64,
622 palette: Palette,
623) -> String {
624 let state = if user.has_totp() {
628 palette.status("enabled")
629 } else if user.has_pending_totp() {
630 palette.warn("pending (enrolment started, never confirmed)")
631 } else {
632 palette.status("off")
633 };
634
635 format!(
636 "{:<20} totp={} recovery-codes={}",
637 user.username, state, recovery_codes_remaining
638 )
639}
640
641#[must_use]
651pub fn render_admin_user_detail_text(
652 user: &AdminUser,
653 recovery_codes_remaining: i64,
654 palette: Palette,
655) -> String {
656 let mut out = format!(
657 "id {}\nusername {}\nstatus {}\nrole {}\n\
658 totp {}\nrecovery_codes {}\ncreated {}\nupdated {}\n",
659 user.id,
660 user.username,
661 palette.status(&user.status),
662 user.role().as_str(),
663 totp_state(user, palette),
664 recovery_codes_remaining,
665 rfc3339(user.created_at),
666 rfc3339(user.updated_at),
667 );
668 if let Some(last) = user.last_login_at {
672 out.push_str(&format!("last_login {}\n", rfc3339(last)));
673 }
674 if let Some(contact) = &user.contact_email {
675 out.push_str(&format!("contact {contact}\n"));
676 }
677 if !user.known_login_ips.is_empty() {
678 out.push_str(&format!(
679 "known_ips {}\n",
680 user.known_login_ips.join(", ")
681 ));
682 }
683 out
684}
685
686fn totp_state(user: &AdminUser, palette: Palette) -> String {
695 if user.has_totp() {
696 palette.status("enabled")
697 } else if user.has_pending_totp() {
698 palette.warn("pending (enrolment started, never confirmed)")
699 } else {
700 palette.status("off")
701 }
702}
703
704#[must_use]
717pub fn render_profile_line(profile: &ProfileSummary, palette: Palette) -> String {
718 let challenges = if profile.challenge_bypass {
721 palette.warn(&format!("{:<9}", "bypassed"))
722 } else {
723 palette.status(&format!("{:<9}", "validated"))
724 };
725 format!(
726 "{:<20} challenges={} eab={:<3} {}",
727 profile.name,
728 challenges,
729 if profile.eab_enabled { "on" } else { "off" },
730 profile.directory_url(),
731 )
732}
733
734#[must_use]
739pub fn render_admin_session_line(session: &AdminSession, palette: Palette) -> String {
740 format!(
741 "{} {} {} {} expires={} {}",
742 acme_proxy_store::nonce::fingerprint(&session.token_hash),
743 session.user_id,
744 palette.status(&format!("{:<11}", session.state)),
745 rfc3339(session.created_at),
746 rfc3339(session.expires_at),
747 session.created_ip.as_deref().unwrap_or("-"),
748 )
749}
750
751#[must_use]
766pub fn json_page(items: Vec<serde_json::Value>, total: i64, window: Window) -> serde_json::Value {
767 serde_json::json!({
768 "items": items,
769 "total": total,
770 "limit": window.limit,
771 "offset": window.offset,
772 })
773}
774
775fn footer_line(shown: usize, total: i64) -> String {
782 format!("{shown} of {total} row(s).")
783}
784
785fn expiring_footer_line(shown: usize, total: i64, hidden: i64) -> String {
794 if hidden > 0 {
795 format!("{shown} of {total} row(s), {hidden} superseded hidden.")
796 } else {
797 footer_line(shown, total)
798 }
799}
800
801pub fn print_footer(shown: usize, total: i64) {
803 println!("{}", footer_line(shown, total));
804}
805
806pub fn print_expiring_footer(shown: usize, total: i64, hidden: i64) {
808 println!("{}", expiring_footer_line(shown, total, hidden));
809}
810
811pub fn print_page<T>(
820 rows: &[T],
821 total: i64,
822 window: Window,
823 json: bool,
824 to_json: impl Fn(&T) -> serde_json::Value,
825 to_line: impl Fn(&T) -> String,
826) {
827 if json {
828 let rendered: Vec<_> = rows.iter().map(to_json).collect();
829 println!("{}", json_page(rendered, total, window));
830 } else {
831 for row in rows {
832 println!("{}", to_line(row));
833 }
834 print_footer(rows.len(), total);
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use std::sync::Arc;
841
842 use super::*;
843 use acme_proxy_admin::admin::ops::load_order_detail;
844 use acme_proxy_core::audit::ClientContext;
845 use acme_proxy_core::identifier::Identifier;
846 use acme_proxy_store::authz::Authorization;
847 use acme_proxy_store::authz::Challenge;
848 use acme_proxy_store::db::Database;
849 use acme_proxy_store::expiring::SupersededBy;
850 use acme_proxy_store::status::OrderStatus;
851 use acme_proxy_store::testutil::account_id;
852 use acme_proxy_store::testutil::account_seen_from;
853 use acme_proxy_store::testutil::admin_session_fixture;
854 use acme_proxy_store::testutil::admin_user_fixture;
855 use acme_proxy_store::testutil::audit_entry;
856 use acme_proxy_store::testutil::client_context;
857 use acme_proxy_store::testutil::job_fixture;
858 use acme_proxy_store::testutil::order_fixture;
859 use acme_proxy_store::testutil::upstream_order_row_fixture;
860
861 fn colour() -> Palette {
865 Palette::new(true)
866 }
867
868 fn strip_ansi(text: &str) -> String {
872 let mut out = String::with_capacity(text.len());
873 let mut rest = text;
874 while let Some(start) = rest.find('\x1b') {
875 out.push_str(&rest[..start]);
876 let Some(end) = rest[start..].find('m') else {
877 break;
878 };
879 rest = &rest[start + end + 1..];
880 }
881 out.push_str(rest);
882 out
883 }
884
885 #[test]
889 fn the_operator_detail_names_the_factor_and_survives_stripping() {
890 let mut user = admin_user_fixture();
891
892 let plain = render_admin_user_detail_text(&user, 0, Palette::plain());
893 assert!(plain.contains("username alice"));
894 assert!(plain.contains("totp off"));
895 assert!(plain.contains("recovery_codes 0"));
896 assert!(!plain.contains("last_login"), "{plain}");
899
900 assert_eq!(
901 strip_ansi(&render_admin_user_detail_text(&user, 0, colour())),
902 plain
903 );
904
905 user.totp_pending_secret = Some(vec![1, 2, 3]);
909 let pending = render_admin_user_detail_text(&user, 0, Palette::plain());
910 assert!(
911 pending.contains("enrolment started, never confirmed"),
912 "{pending}"
913 );
914 assert!(
915 render_admin_totp_line(&user, 0, Palette::plain())
916 .contains("enrolment started, never confirmed")
917 );
918
919 user.totp_secret = Some(vec![4, 5, 6]);
920 user.last_login_at = Some(1_700_000_500);
921 let enabled = render_admin_user_detail_text(&user, 7, Palette::plain());
922 assert!(enabled.contains("totp enabled"), "{enabled}");
923 assert!(enabled.contains("recovery_codes 7"));
924 assert!(enabled.contains("last_login "), "{enabled}");
925 }
926
927 #[test]
930 fn the_profile_line_paints_a_bypassing_endpoint_and_nothing_else() {
931 let mut profile = ProfileSummary {
932 name: "le".to_string(),
933 base_url: "https://ca.example.com/profile/le".to_string(),
934 challenge_bypass: false,
935 eab_enabled: true,
936 };
937
938 let plain = render_profile_line(&profile, Palette::plain());
939 assert!(plain.contains("https://ca.example.com/profile/le/directory"));
940 assert!(plain.contains("challenges=validated"));
941 assert!(plain.contains("eab=on"));
942 assert_eq!(strip_ansi(&render_profile_line(&profile, colour())), plain);
943
944 profile.challenge_bypass = true;
945 profile.eab_enabled = false;
946 let bypassed = render_profile_line(&profile, Palette::plain());
947 assert!(bypassed.contains("challenges=bypassed"));
948 assert!(bypassed.contains("eab=off"));
949 assert_eq!(
950 strip_ansi(&render_profile_line(&profile, colour())),
951 bypassed
952 );
953 }
954
955 #[test]
958 fn the_footer_reports_the_page_against_the_unpaged_total() {
959 assert_eq!(footer_line(2, 137), "2 of 137 row(s).");
960 assert_eq!(footer_line(0, 0), "0 of 0 row(s).");
962 }
963
964 #[test]
967 fn the_expiry_footer_names_the_rows_supersession_removed() {
968 assert_eq!(
969 expiring_footer_line(1, 4, 2),
970 "1 of 4 row(s), 2 superseded hidden."
971 );
972 assert_eq!(expiring_footer_line(4, 4, 0), footer_line(4, 4));
973 }
974
975 #[test]
979 fn the_json_envelope_matches_the_apis() {
980 let window = Window::resolve(2, 4);
981 let envelope = json_page(vec![serde_json::json!({"id": "a"})], 17, window);
982
983 assert_eq!(envelope["total"], 17);
984 assert_eq!(envelope["limit"], 2);
985 assert_eq!(envelope["offset"], 4);
986 assert_eq!(envelope["items"].as_array().unwrap().len(), 1);
987
988 let page = acme_proxy_admin::webadmin::handlers::paging::Page {
989 limit: 2,
990 offset: 4,
991 };
992 assert_eq!(
993 envelope,
994 acme_proxy_admin::webadmin::handlers::paging::page_envelope(
995 vec![serde_json::json!({"id": "a"})],
996 17,
997 page
998 )
999 );
1000 }
1001
1002 #[test]
1006 fn the_audit_line_renders_all_three_shapes_of_client() {
1007 let entry = audit_entry();
1008 let line = render_audit_line(&entry, Palette::plain());
1009 assert!(line.contains("41812"), "{line}");
1010 assert!(line.contains("certificate_issued"), "{line}");
1011 assert!(line.contains("acme:acct-1"), "{line}");
1012 assert!(line.contains("203.0.113.7 (host.example.com)"), "{line}");
1013 assert!(line.contains("a.example.com,b.example.com"), "{line}");
1014 assert!(!line.contains("reason="), "{line}");
1016
1017 let mut no_ptr = audit_entry();
1018 no_ptr.client_ptr = None;
1019 let line = render_audit_line(&no_ptr, Palette::plain());
1020 assert!(line.contains("203.0.113.7"), "{line}");
1021 assert!(!line.contains('('), "{line}");
1022
1023 let mut cli = audit_entry();
1025 cli.actor_kind = "cli".to_string();
1026 cli.actor_id = None;
1027 cli.client_ip = None;
1028 cli.client_ptr = None;
1029 cli.event = "certificate_revoked".to_string();
1030 cli.reason = Some("1".to_string());
1031 let line = render_audit_line(&cli, Palette::plain());
1032 assert!(line.contains(" cli "), "{line}");
1033 assert!(
1034 !line.contains("cli:"),
1035 "an actor with no id must not render a trailing colon: {line}"
1036 );
1037 assert!(line.contains(" - "), "{line}");
1038 assert!(line.ends_with("reason=1"), "{line}");
1039 }
1040
1041 #[test]
1045 fn only_a_failed_audit_event_is_painted() {
1046 let succeeded = render_audit_line(&audit_entry(), colour());
1047 assert!(!succeeded.contains('\x1b'), "{succeeded}");
1048
1049 let mut refused = audit_entry();
1050 refused.event = "certificate_issue_failed".to_string();
1051 refused.outcome = "failure".to_string();
1052 let line = render_audit_line(&refused, colour());
1053 assert!(line.contains("\x1b[31mcertificate_issue_failed"), "{line}");
1054 assert_eq!(
1055 strip_ansi(&line),
1056 render_audit_line(&refused, Palette::plain()),
1057 "colour must not move a column"
1058 );
1059 }
1060
1061 #[test]
1064 fn the_audit_detail_omits_every_field_that_has_no_value() {
1065 let full = render_audit_detail_text(&audit_entry(), Palette::plain());
1066 for expected in [
1067 "id 41812",
1068 "event certificate_issued",
1069 "outcome success",
1070 "profile le",
1071 "actor acme:acct-1",
1072 "order order-1",
1073 "serial 0a0b",
1074 "client_ip 203.0.113.7",
1075 "client_ptr host.example.com",
1076 "user_agent certbot/2.9.0",
1077 "request_id req-1",
1078 "identifiers a.example.com,b.example.com",
1079 ] {
1080 assert!(full.contains(expected), "missing `{expected}` in:\n{full}");
1081 }
1082 assert!(!full.contains("reason"), "{full}");
1083 assert!(!full.contains("detail"), "{full}");
1084
1085 let bare = AuditEntry {
1086 actor_id: None,
1087 account_id: None,
1088 order_id: None,
1089 cert_serial: None,
1090 identifiers: vec![],
1091 client_ip: None,
1092 client_ptr: None,
1093 user_agent: None,
1094 request_id: None,
1095 ..audit_entry()
1096 };
1097 let text = render_audit_detail_text(&bare, Palette::plain());
1098 assert!(text.contains("actor acme\n"), "{text}");
1099 for absent in ["account", "order", "serial", "client_ip", "identifiers"] {
1100 assert!(
1101 !text.contains(absent),
1102 "`{absent}` should be absent from:\n{text}"
1103 );
1104 }
1105 }
1106
1107 #[tokio::test]
1110 async fn render_account_line_renders_all_three_shapes_of_client() {
1111 let db = Arc::new(Database::connect_in_memory().await.unwrap());
1112
1113 let both = account_seen_from(
1114 &[1u8, 2, 3],
1115 &client_context(Some("203.0.113.7"), Some("host.example.com")),
1116 &db,
1117 )
1118 .await;
1119 let line = render_account_line(&both, Palette::plain());
1120 assert!(line.contains(&both.id.to_string()), "{line}");
1121 assert!(line.contains("valid"), "{line}");
1122 assert!(line.contains("mailto:a@example.com"), "{line}");
1123 assert!(line.contains("203.0.113.7 (host.example.com)"), "{line}");
1124 assert!(!line.contains(&pubkey_fingerprint(&both.pubkey)), "{line}");
1126
1127 let address_only = account_seen_from(
1128 &[4u8, 5, 6],
1129 &client_context(Some("203.0.113.7"), None),
1130 &db,
1131 )
1132 .await;
1133 let line = render_account_line(&address_only, Palette::plain());
1134 assert!(line.contains("203.0.113.7"), "{line}");
1135 assert!(!line.contains('('), "{line}");
1136
1137 let neither = account_seen_from(&[7u8, 8, 9], &ClientContext::default(), &db).await;
1138 assert!(render_account_line(&neither, Palette::plain()).contains(" - "));
1139 }
1140
1141 #[tokio::test]
1144 async fn colour_never_moves_the_account_listings_columns() {
1145 let db = Arc::new(Database::connect_in_memory().await.unwrap());
1146 let account = account_seen_from(&[1u8, 2, 3], &ClientContext::default(), &db).await;
1147
1148 let painted = render_account_line(&account, colour());
1149 assert!(painted.contains("\x1b[32mvalid \x1b[0m"), "{painted}");
1150 assert_eq!(
1151 strip_ansi(&painted),
1152 render_account_line(&account, Palette::plain())
1153 );
1154 }
1155
1156 #[tokio::test]
1157 async fn render_account_detail_text_omits_every_absent_field() {
1158 let db = Arc::new(Database::connect_in_memory().await.unwrap());
1159
1160 let seen = account_seen_from(
1161 &[1u8, 2, 3],
1162 &client_context(Some("203.0.113.7"), Some("host.example.com")),
1163 &db,
1164 )
1165 .await;
1166 let text = render_account_detail_text(&seen, Palette::plain());
1167 assert!(
1168 text.contains(&format!("id {}", seen.id)),
1169 "{text}"
1170 );
1171 assert!(text.contains("profile default"), "{text}");
1172 assert!(text.contains("status valid"), "{text}");
1173 assert!(
1174 text.contains(&pubkey_fingerprint(&seen.pubkey)),
1175 "the fingerprint the listing gave up must be here: {text}"
1176 );
1177 assert!(
1178 text.contains("contact mailto:a@example.com"),
1179 "{text}"
1180 );
1181 assert!(text.contains("created_ip 203.0.113.7"), "{text}");
1182 assert!(text.contains("created_ptr host.example.com"), "{text}");
1183 assert!(text.contains("last_seen "), "{text}");
1184 assert!(text.contains("last_seen_ip 203.0.113.7"), "{text}");
1185 assert!(text.contains("last_seen_ptr host.example.com"), "{text}");
1186 for line in text.lines() {
1189 assert_eq!(&line[13..14], " ", "misaligned: {line:?}");
1190 assert_ne!(&line[14..15], " ", "misaligned: {line:?}");
1191 }
1192 assert!(!text.contains("eab_kid"), "{text}");
1194 assert!(!text.contains("terms"), "{text}");
1195
1196 let bare =
1197 Account::find_or_create("default", &[9u8], vec![], &ClientContext::default(), &db)
1198 .await
1199 .unwrap()
1200 .0;
1201 let text = render_account_detail_text(&bare, Palette::plain());
1202 for absent in ["contact", "created_ip", "created_ptr", "last_seen_ip"] {
1203 assert!(!text.contains(absent), "{absent} in {text}");
1204 }
1205 assert!(text.contains("last_seen "), "{text}");
1207 }
1208
1209 #[test]
1210 fn render_order_line_includes_expected_fields() {
1211 let order = order_fixture(acme_proxy_store::id::mint(), OrderStatus::Pending);
1212 let line = render_order_line(&order, Palette::plain());
1213 assert!(line.contains(&order.id.to_string()));
1214 assert!(line.contains("pending"));
1215 assert!(line.contains("example.com"));
1216 }
1217
1218 #[test]
1227 fn the_order_status_column_is_painted_by_what_it_means() {
1228 for (status, code) in [
1229 (OrderStatus::Valid, "32"),
1230 (OrderStatus::Pending, "33"),
1231 (OrderStatus::Invalid, "31"),
1232 ] {
1233 let order = order_fixture(acme_proxy_store::id::mint(), status);
1234 let painted = render_order_line(&order, colour());
1235 assert!(
1236 painted.contains(&format!("\x1b[{code}m{}\x1b[0m", status.as_str())),
1237 "{painted}"
1238 );
1239 assert_eq!(
1240 strip_ansi(&painted),
1241 render_order_line(&order, Palette::plain())
1242 );
1243 }
1244 }
1245
1246 #[tokio::test]
1247 async fn render_order_detail_text_surfaces_authorizations_and_challenges() {
1248 let db = Arc::new(Database::connect_in_memory().await.unwrap());
1249 let acct = account_id(&db).await;
1250 let order = Order::create(
1251 "default",
1252 acct,
1253 vec![Identifier::dns("example.com")],
1254 acme_proxy_store::nonce::now_secs() + 3600,
1255 None,
1256 None,
1257 &db,
1258 )
1259 .await
1260 .unwrap();
1261 let authz = Authorization::create(
1262 order.id,
1263 Identifier::dns("example.com"),
1264 acme_proxy_store::nonce::now_secs() + 3600,
1265 &db,
1266 )
1267 .await
1268 .unwrap();
1269 Challenge::create(authz.id, "http-01", &db).await.unwrap();
1270
1271 let detail = load_order_detail(order.id.to_string().as_str(), db)
1272 .await
1273 .unwrap()
1274 .unwrap();
1275 let text = render_order_detail_text(&detail, Palette::plain());
1276 assert!(text.contains(&order.id.to_string()));
1277 assert!(text.contains(&authz.id.to_string()));
1278 assert!(text.contains("http-01"));
1279
1280 let painted = render_order_detail_text(&detail, colour());
1283 assert_eq!(
1284 painted.matches("\x1b[33mpending\x1b[0m").count(),
1285 3,
1286 "the order's, the authorization's and the challenge's: {painted}"
1287 );
1288 assert_eq!(strip_ansi(&painted), text);
1289 }
1290
1291 #[test]
1300 fn render_order_detail_text_omits_every_absent_field() {
1301 let account = acme_proxy_store::id::mint();
1302 let mut order = order_fixture(account, OrderStatus::Valid);
1303 order.not_before = Some(1700000000);
1304 order.not_after = Some(1700003600);
1305 order.replaces = Some("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE".to_string());
1306 order.cert_serial = Some("03a7f1c9".to_string());
1307 order.cert_not_after = Some(1700007200);
1308 order.revoked_at = Some(1700010800);
1309 order.revocation_reason = Some(1);
1310 order.error = Some(serde_json::json!({
1311 "type": "urn:ietf:params:acme:error:badCSR",
1312 "detail": "CSR names do not match the order",
1313 }));
1314 let detail = OrderDetail {
1315 order,
1316 authorizations: vec![],
1317 };
1318
1319 let text = render_order_detail_text(&detail, Palette::plain());
1320 assert!(
1321 text.contains(&format!("id {}", detail.order.id)),
1322 "{text}"
1323 );
1324 assert!(text.contains("profile default"), "{text}");
1325 assert!(
1326 text.contains(&format!("account_id {account}")),
1327 "{text}"
1328 );
1329 assert!(text.contains("status valid"), "{text}");
1330 assert!(text.contains("identifiers example.com"), "{text}");
1331 assert!(text.contains("created "), "{text}");
1332 assert!(text.contains("expires "), "{text}");
1333 assert!(
1334 text.contains("not_before 2023-11-14T22:13:20Z"),
1335 "{text}"
1336 );
1337 assert!(
1338 text.contains("not_after 2023-11-14T23:13:20Z"),
1339 "{text}"
1340 );
1341 assert!(
1342 text.contains("replaces aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE"),
1343 "{text}"
1344 );
1345 assert!(text.contains("serial 03a7f1c9"), "{text}");
1346 assert!(
1347 text.contains("cert_not_after 2023-11-15T00:13:20Z"),
1348 "{text}"
1349 );
1350 assert!(
1351 text.contains("revoked 2023-11-15T01:13:20Z"),
1352 "{text}"
1353 );
1354 assert!(text.contains("reason 1"), "{text}");
1355 assert!(
1358 text.contains("error CSR names do not match the order"),
1359 "{text}"
1360 );
1361 for line in text.lines() {
1364 assert_eq!(&line[14..15], " ", "misaligned: {line:?}");
1365 assert_ne!(&line[15..16], " ", "misaligned: {line:?}");
1366 }
1367
1368 let bare = OrderDetail {
1370 order: order_fixture(acme_proxy_store::id::mint(), OrderStatus::Pending),
1371 authorizations: vec![],
1372 };
1373 let text = render_order_detail_text(&bare, Palette::plain());
1374 for absent in [
1375 "not_before",
1376 "not_after",
1377 "replaces",
1378 "serial",
1379 "cert_not_after",
1380 "revoked",
1381 "reason",
1382 "error",
1383 ] {
1384 assert!(!text.contains(absent), "{absent} in {text}");
1385 }
1386 }
1387
1388 #[test]
1400 fn the_text_and_json_order_renderings_describe_the_same_order() {
1401 const FIELDS: &[(&str, &str)] = &[
1403 ("id", "id"),
1404 ("profile", "profile"),
1405 ("account_id", "accountId"),
1406 ("status", "status"),
1407 ("identifiers", "identifiers"),
1408 ("created", "createdAt"),
1409 ("expires", "expires"),
1410 ("not_before", "notBefore"),
1411 ("not_after", "notAfter"),
1412 ("replaces", "replaces"),
1413 ("serial", "certSerial"),
1414 ("cert_not_after", "certNotAfter"),
1415 ("revoked", "revokedAt"),
1416 ("reason", "revocationReason"),
1417 ("error", "error"),
1418 ];
1419 const JSON_ONLY: &[&str] = &[
1421 "authorizations",
1422 "finalize",
1423 "certificate",
1424 "certificatePem",
1425 ];
1426
1427 let account = acme_proxy_store::id::mint();
1430 let mut order = order_fixture(account, OrderStatus::Valid);
1431 order.not_before = Some(1700000000);
1432 order.not_after = Some(1700003600);
1433 order.replaces = Some("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE".to_string());
1434 order.cert_serial = Some("03a7f1c9".to_string());
1435 order.cert_not_after = Some(1700007200);
1436 order.revoked_at = Some(1700010800);
1437 order.revocation_reason = Some(1);
1438 order.error = Some(serde_json::json!({ "detail": "unreachable" }));
1439 order.certificate = Some("-----BEGIN CERTIFICATE-----\n".to_string());
1440 let detail = OrderDetail {
1441 order,
1442 authorizations: vec![],
1443 };
1444
1445 let json = acme_proxy_admin::admin::render::render_order_detail_json(
1446 &detail,
1447 "http://localhost:3000",
1448 );
1449 let members: std::collections::BTreeSet<&str> = json["order"]
1450 .as_object()
1451 .unwrap()
1452 .keys()
1453 .map(String::as_str)
1454 .filter(|member| !JSON_ONLY.contains(member))
1455 .collect();
1456 assert_eq!(
1457 members,
1458 FIELDS.iter().map(|(_, member)| *member).collect(),
1459 "a JSON member the text rendering does not print, or the reverse"
1460 );
1461
1462 let text = render_order_detail_text(&detail, Palette::plain());
1465 let labels: std::collections::BTreeSet<&str> = text
1466 .lines()
1467 .filter(|line| !line.starts_with(' '))
1468 .map(|line| line[..14].trim_end())
1469 .collect();
1470 assert_eq!(labels, FIELDS.iter().map(|(label, _)| *label).collect());
1471 }
1472
1473 #[test]
1477 fn an_unparsable_leaf_expiry_prints_no_line() {
1478 let mut order = order_fixture(acme_proxy_store::id::mint(), OrderStatus::Valid);
1479 order.cert_not_after = Some(acme_proxy_store::order::UNPARSABLE_NOT_AFTER);
1480 let detail = OrderDetail {
1481 order,
1482 authorizations: vec![],
1483 };
1484 assert!(!render_order_detail_text(&detail, Palette::plain()).contains("cert_not_after"),);
1485 }
1486
1487 #[test]
1491 fn the_order_detail_paints_its_revocation_and_nothing_else_moves() {
1492 let mut order = order_fixture(acme_proxy_store::id::mint(), OrderStatus::Valid);
1493 order.revoked_at = Some(1700000000);
1494 order.revocation_reason = Some(4);
1495 let detail = OrderDetail {
1496 order,
1497 authorizations: vec![],
1498 };
1499 let painted = render_order_detail_text(&detail, colour());
1500 assert!(
1501 painted.contains("\x1b[31m2023-11-14T22:13:20Z\x1b[0m"),
1502 "{painted}"
1503 );
1504 assert_eq!(
1505 strip_ansi(&painted),
1506 render_order_detail_text(&detail, Palette::plain())
1507 );
1508 }
1509
1510 #[test]
1511 fn render_order_line_revoked_includes_reason_and_time() {
1512 let mut order = order_fixture(acme_proxy_store::id::mint(), OrderStatus::Valid);
1513 order.revoked_at = Some(1700000000);
1514 order.revocation_reason = Some(1);
1515 let line = render_order_line(&order, Palette::plain());
1516 assert!(line.contains("revoked="));
1517 assert!(line.contains("reason=1"));
1518 }
1519
1520 #[test]
1523 fn a_revoked_order_paints_its_suffix_even_though_its_status_is_valid() {
1524 let mut order = order_fixture(acme_proxy_store::id::mint(), OrderStatus::Valid);
1525 order.revoked_at = Some(1700000000);
1526 order.revocation_reason = Some(1);
1527 let painted = render_order_line(&order, colour());
1528 assert!(painted.contains("\x1b[32mvalid"), "{painted}");
1529 assert!(painted.contains("\x1b[31m revoked="), "{painted}");
1530 assert!(painted.ends_with("reason=1\x1b[0m"), "{painted}");
1531 assert_eq!(
1532 strip_ansi(&painted),
1533 render_order_line(&order, Palette::plain())
1534 );
1535 }
1536
1537 #[tokio::test]
1538 async fn render_eab_line_includes_expected_fields() {
1539 let db = Arc::new(Database::connect_in_memory().await.unwrap());
1540 let eab = Eab::create(Some("team-a".to_string()), None, &db)
1541 .await
1542 .unwrap();
1543 let line = render_eab_line(&eab, Palette::plain());
1544 assert!(line.contains(&eab.kid.to_string()));
1545 assert!(line.contains("active"));
1546 assert!(line.contains("team-a"));
1547
1548 let painted = render_eab_line(&eab, colour());
1549 assert!(painted.contains("\x1b[32mactive \x1b[0m"), "{painted}");
1550 assert_eq!(strip_ansi(&painted), line);
1551 }
1552
1553 #[tokio::test]
1554 async fn render_eab_created_text_includes_kid_and_hmac_key() {
1555 let db = Arc::new(Database::connect_in_memory().await.unwrap());
1556 let eab = Eab::create(None, None, &db).await.unwrap();
1557 let text = render_eab_created_text(&eab, Palette::plain());
1558 assert!(text.contains(&eab.kid.to_string()));
1559 assert!(text.contains(&BASE64_URL_SAFE_NO_PAD.encode(&eab.secret)));
1560 assert!(text.contains("Store the hmacKey now"));
1561 }
1562
1563 #[tokio::test]
1566 async fn the_eab_secret_warning_is_the_only_thing_painted() {
1567 let db = Arc::new(Database::connect_in_memory().await.unwrap());
1568 let eab = Eab::create(None, None, &db).await.unwrap();
1569 let painted = render_eab_created_text(&eab, colour());
1570 assert!(
1571 painted.contains("\x1b[33mStore the hmacKey now: it is shown only this once.\x1b[0m"),
1572 "{painted}"
1573 );
1574 assert_eq!(painted.matches('\x1b').count(), 2, "{painted}");
1575 assert_eq!(
1576 strip_ansi(&painted),
1577 render_eab_created_text(&eab, Palette::plain())
1578 );
1579 }
1580
1581 #[test]
1582 fn render_admin_user_line_never_shows_the_hash_and_says_never_for_no_login() {
1583 let user = admin_user_fixture();
1584 let line = render_admin_user_line(&user, Palette::plain());
1585 assert!(line.contains("alice"));
1586 assert!(line.contains("active"));
1587 assert!(line.contains("totp=off"));
1588 assert!(line.contains("never"));
1589 assert!(
1590 !line.contains("pbkdf2"),
1591 "the stored hash must never reach a terminal: {line}"
1592 );
1593 }
1594
1595 #[test]
1596 fn render_admin_user_line_reflects_totp_and_a_real_last_login() {
1597 let mut user = admin_user_fixture();
1598 user.totp_secret = Some(vec![1, 2, 3]);
1599 user.last_login_at = Some(1_700_000_500);
1600 let line = render_admin_user_line(&user, Palette::plain());
1601 assert!(line.contains("totp=on"));
1602 assert!(!line.contains("never"));
1603 }
1604
1605 #[test]
1608 fn the_operator_renderings_carry_the_role() {
1609 let mut user = admin_user_fixture();
1610 assert_eq!(user.role, None);
1611 assert!(render_admin_user_line(&user, Palette::plain()).contains("admin"));
1612 assert!(
1613 render_admin_user_detail_text(&user, 0, Palette::plain())
1614 .contains("role admin")
1615 );
1616
1617 user.role = Some("viewer".to_string());
1618 let line = render_admin_user_line(&user, colour());
1619 assert!(line.contains("viewer"), "{line}");
1620 assert_eq!(
1621 strip_ansi(&line),
1622 render_admin_user_line(&user, Palette::plain())
1623 );
1624 assert!(
1625 render_admin_user_detail_text(&user, 0, Palette::plain())
1626 .contains("role viewer")
1627 );
1628 }
1629
1630 #[test]
1633 fn an_operator_without_a_second_factor_stands_out() {
1634 let without = render_admin_user_line(&admin_user_fixture(), colour());
1635 assert!(without.contains("totp=\x1b[31moff\x1b[0m"), "{without}");
1636
1637 let mut user = admin_user_fixture();
1638 user.totp_secret = Some(vec![1, 2, 3]);
1639 user.status = "disabled".to_string();
1640 let with = render_admin_user_line(&user, colour());
1641 assert!(with.contains("totp=\x1b[32mon \x1b[0m"), "{with}");
1642 assert!(with.contains("\x1b[31mdisabled\x1b[0m"), "{with}");
1643 assert_eq!(
1644 strip_ansi(&with),
1645 render_admin_user_line(&user, Palette::plain())
1646 );
1647 }
1648
1649 #[test]
1652 fn the_totp_line_paints_each_of_its_three_states() {
1653 let plain = Palette::plain();
1654 let off = admin_user_fixture();
1655 assert!(
1656 render_admin_totp_line(&off, 0, plain).contains("totp=off"),
1657 "plain output unchanged"
1658 );
1659 assert!(render_admin_totp_line(&off, 0, colour()).contains("totp=\x1b[31moff\x1b[0m"));
1660
1661 let mut pending = admin_user_fixture();
1662 pending.totp_pending_secret = Some(vec![1, 2, 3]);
1663 let line = render_admin_totp_line(&pending, 0, colour());
1664 assert!(
1665 line.contains("\x1b[33mpending (enrolment started, never confirmed)\x1b[0m"),
1666 "{line}"
1667 );
1668 assert_eq!(
1669 strip_ansi(&line),
1670 render_admin_totp_line(&pending, 0, plain)
1671 );
1672
1673 let mut enabled = admin_user_fixture();
1674 enabled.totp_secret = Some(vec![1, 2, 3]);
1675 let line = render_admin_totp_line(&enabled, 7, colour());
1676 assert!(line.contains("totp=\x1b[32menabled\x1b[0m"), "{line}");
1677 assert!(line.contains("recovery-codes=7"), "{line}");
1678 }
1679
1680 #[test]
1681 fn render_admin_session_line_shows_a_fingerprint_not_the_token_hash() {
1682 let line = render_admin_session_line(&admin_session_fixture(), Palette::plain());
1683 assert!(line.contains("01234567"));
1684 assert!(
1685 !line.contains("0123456789abcdef0123456789abcdef"),
1686 "printing the hash would put every live session's lookup key on a terminal: {line}"
1687 );
1688 assert!(!line.contains("the-csrf-token"));
1689 assert!(line.contains("192.0.2.1"));
1690 assert!(line.contains("expires="));
1691 }
1692
1693 #[test]
1694 fn render_admin_session_line_dashes_a_missing_address() {
1695 let mut session = admin_session_fixture();
1696 session.created_ip = None;
1697 assert!(render_admin_session_line(&session, Palette::plain()).contains(" -"));
1698 }
1699
1700 #[test]
1703 fn a_pending_mfa_session_is_painted_apart_from_an_active_one() {
1704 let active = render_admin_session_line(&admin_session_fixture(), colour());
1705 assert!(active.contains("\x1b[32mactive \x1b[0m"), "{active}");
1706
1707 let mut session = admin_session_fixture();
1708 session.state = "pending_mfa".to_string();
1709 let painted = render_admin_session_line(&session, colour());
1710 assert!(painted.contains("\x1b[33mpending_mfa\x1b[0m"), "{painted}");
1711 assert_eq!(
1712 strip_ansi(&painted),
1713 render_admin_session_line(&session, Palette::plain())
1714 );
1715 }
1716
1717 #[test]
1720 fn the_expiring_line_bands_the_days_and_annotates_only_what_was_replaced() {
1721 let id = acme_proxy_store::id::mint();
1722 let entry = move |days: i64, superseded: Option<SupersededBy>| {
1723 let mut order = order_fixture(acme_proxy_store::id::mint(), OrderStatus::Valid);
1724 order.id = id;
1725 order.cert_not_after = Some(1_700_000_000);
1726 ExpiringEntry {
1727 order,
1728 days_remaining: days,
1729 superseded_by: superseded,
1730 }
1731 };
1732
1733 let plain = render_expiring_line(&entry(40, None), Palette::plain());
1734 assert!(plain.starts_with(&format!("{id} default")), "{plain}");
1735 assert!(plain.contains(" 40d "), "{plain}");
1736 assert!(plain.contains("2023-11-14"), "{plain}");
1737 assert!(plain.ends_with("example.com"), "{plain}");
1738 assert!(
1739 !plain.contains("replaced-by"),
1740 "the absent annotation is what an operator scans for: {plain}"
1741 );
1742
1743 assert!(render_expiring_line(&entry(3, None), colour()).contains("\x1b[31m"));
1745 assert!(render_expiring_line(&entry(20, None), colour()).contains("\x1b[33m"));
1746 let far = render_expiring_line(&entry(40, None), colour());
1747 assert_eq!(
1748 far,
1749 render_expiring_line(&entry(40, None), Palette::plain())
1750 );
1751
1752 let replaced = entry(
1753 3,
1754 Some(SupersededBy {
1755 order_id: "ord-2".to_string(),
1756 cert_serial: "0a0b".to_string(),
1757 not_after: 1_800_000_000,
1758 via: "replaces".to_string(),
1759 }),
1760 );
1761 let line = render_expiring_line(&replaced, Palette::plain());
1762 assert!(line.ends_with(" replaced-by=ord-2 via=replaces"), "{line}");
1763 }
1764
1765 #[test]
1768 fn colour_never_moves_the_expiring_listings_columns() {
1769 let mut order = order_fixture(acme_proxy_store::id::mint(), OrderStatus::Valid);
1770 order.cert_not_after = Some(1_700_000_000);
1771 let entry = ExpiringEntry {
1772 order,
1773 days_remaining: 3,
1774 superseded_by: Some(SupersededBy {
1775 order_id: "ord-2".to_string(),
1776 cert_serial: "0a0b".to_string(),
1777 not_after: 1_800_000_000,
1778 via: "identifiers".to_string(),
1779 }),
1780 };
1781
1782 let painted = render_expiring_line(&entry, colour());
1783 assert!(painted.contains("\x1b[31m 3d\x1b[0m"), "{painted}");
1784 assert_eq!(
1785 strip_ansi(&painted),
1786 render_expiring_line(&entry, Palette::plain())
1787 );
1788 }
1789
1790 #[test]
1793 fn render_job_line_is_stable_and_colour_never_moves_it() {
1794 let job = job_fixture();
1795 let plain = render_job_line(&job, Palette::plain());
1796 assert!(plain.contains("signer_relay_issue"));
1797 assert!(plain.contains("3/5"));
1798 assert!(plain.contains("error=upstream said no"));
1799 assert_eq!(strip_ansi(&render_job_line(&job, colour())), plain);
1800 }
1801
1802 #[test]
1803 fn render_job_detail_text_omits_absent_fields_and_shows_the_upstream_block() {
1804 let mut job = job_fixture();
1805 job.deadline = None;
1806 job.lease_owner = None;
1807 let detail = JobDetail {
1808 job,
1809 upstream_order: Some(upstream_order_row_fixture()),
1810 };
1811 let text = render_job_detail_text(&detail, Palette::plain());
1812 assert!(!text.contains("deadline"));
1813 assert!(!text.contains("lease_owner"));
1814 assert!(text.contains("last_error upstream said no"));
1815 assert!(text.contains("Upstream order:"));
1816 assert!(text.contains("upstream_order_url"));
1817 assert!(!text.contains("csr"), "no csr bytes ever: {text}");
1818 assert_eq!(strip_ansi(&render_job_detail_text(&detail, colour())), text);
1819 }
1820
1821 #[test]
1822 fn render_upstream_order_line_and_detail_are_stable_under_colour() {
1823 let row = upstream_order_row_fixture();
1824 let plain = render_upstream_order_line(&row, Palette::plain());
1825 assert!(plain.contains("invalid"));
1826 assert!(plain.contains("processing")); assert!(plain.contains("a.example.com"));
1828 assert_eq!(
1829 strip_ansi(&render_upstream_order_line(&row, colour())),
1830 plain
1831 );
1832
1833 let detail = UpstreamOrderDetail {
1834 upstream_order: row,
1835 job: Some(job_fixture()),
1836 };
1837 let text = render_upstream_order_detail_text(&detail, Palette::plain());
1838 assert!(text.contains("Relay job:"));
1839 assert!(!text.contains("csr"), "{text}");
1840 assert_eq!(
1841 strip_ansi(&render_upstream_order_detail_text(&detail, colour())),
1842 text
1843 );
1844 }
1845}