Skip to main content

acme_proxy/cli/
render.rs

1//! The human-readable renderings, and the only place colour is woven in.
2//!
3//! These lived in [`crate::admin::render`] beside the JSON ones until colour
4//! arrived. The split is where the sharing actually is: every `render_*_json`
5//! is read by both front ends (`src/webadmin/pages/`, `src/webadmin/handlers/`)
6//! and must stay byte-identical for a script parsing `--json`, while **every
7//! renderer here has exactly one consumer, the terminal**. Keeping them
8//! together would have meant either a [`Palette`] argument threaded through
9//! `src/admin/`, which is the front-end-agnostic layer, or colouring whole
10//! lines from the print site, which is all a finished padded string allows.
11//!
12//! Two conventions hold throughout:
13//!
14//! - **Pad first, then colour** — `palette.status(&format!("{:<11}", status))`.
15//!   A format width counts bytes, so wrapping before padding counts the escape
16//!   and collapses the column. See [`super::style`].
17//! - **Colour is semantic, never decorative.** Statuses, refusals and standing
18//!   warnings; not labels, not timestamps, not identifiers. A listing should
19//!   read as data with a few things standing out, and `Palette::plain()` must
20//!   stay the shape an operator's `awk` was written against.
21
22use 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
33/// An address and the reverse name it had, as `ip (ptr)`.
34///
35/// Collapses to the address alone when there is no name, and to `-` when there
36/// was no address at all. Three states in one column rather than two columns
37/// that are empty together, which is what a `-` under a `PTR` heading would have
38/// been. A name without an address is not a state that exists, so the pair is
39/// only ever read in this order.
40fn 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/// One line: `id  profile  status  last_seen_from  contact  created_at`.
49///
50/// The address where the key was last seen takes the column a public-key
51/// fingerprint used to hold: a fingerprint identifies nothing an operator
52/// scanning a list is looking for, and it is one `account show` away.
53#[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/// `account show <id>`, one field per line.
74///
75/// The traceability columns take the line count past what
76/// [`render_account_line`] can carry, which is the same split `audit` makes
77/// between its listing and [`render_audit_detail_text`].
78#[must_use]
79pub fn render_account_detail_text(account: &Account, palette: Palette) -> String {
80    // One column wider than `render_audit_detail_text`'s, because
81    // `last_seen_ptr` is thirteen characters and would otherwise be the one
82    // label that pushes its value out of line.
83    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
114/// The event name padded to `width`, painted when it names a refusal.
115///
116/// Driven off the `_failed` suffix rather than off `AuditEntry::outcome`,
117/// because the listing does not carry `outcome` and the two are derived from
118/// one definition (`AuditEvent::outcome`) anyway. An event this build has never
119/// seen still renders — the column is a stored string on purpose.
120///
121/// Takes the width rather than a padded string, because the suffix test has to
122/// run on the *unpadded* name and the escape has to wrap the *padded* one.
123fn 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/// One line: `id  created_at  event  profile  actor  client  identifiers`.
133///
134/// The client column is [`render_client`]'s three shapes; a row with neither
135/// address nor name is a CLI or relay action, and reads as `-`.
136#[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/// `audit show <id>`, one field per line — the row carries thirteen possible
160/// fields and a single line of them would wrap on any terminal.
161#[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/// One line: `id  status  identifiers (comma-joined)  created_at`.
197#[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        // Painted whole: an order's `status` stays `valid` after revocation
215        // (RFC 8555 defines no revoked status), so this suffix is the only
216        // thing on the line that says the certificate is withdrawn.
217        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/// `order show` text output.
230#[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/// One line: `kid  status  label  created_at (RFC3339)`.
267#[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/// `eab create` text output.
279#[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/// One line: `username  status  totp  created_at  last_login`.
291#[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/// `admin user totp status`, in words.
307///
308/// Says which of the three states the operator is in, since "enrolment pending"
309/// and "no factor" behave identically at the login prompt and only this line
310/// tells them apart -- an operator who believes they enrolled and did not
311/// confirm has no other way to find out.
312#[must_use]
313pub fn render_admin_totp_line(
314    user: &AdminUser,
315    recovery_codes_remaining: i64,
316    palette: Palette,
317) -> String {
318    // The pending word carries its explanation, so it is painted whole rather
319    // than through `status` -- which would leave the parenthetical plain and
320    // read as two different pieces of information.
321    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/// One line: `id  user  state  created_at  expires_at  ip`.
336///
337/// `id` is a fingerprint of the token hash, not the hash: see
338/// [`AdminSession::to_json`].
339#[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
352/// Prints a listing the way every `--json`-capable list command prints one:
353/// one JSON array, or one human-readable line per row.
354///
355/// Six commands had written out the same `if json { … map(to_json).collect()
356/// … } else { for row in rows { println!(to_line) } }`. The shape is the
357/// contract — a JSON listing is an *array*, never a stream of objects, so a
358/// caller can pipe it into `jq` — and it should exist once.
359///
360/// Takes no [`Palette`]: the `to_line` closure captures one at the call site,
361/// which is also what keeps the `json` branch structurally unable to reach it.
362pub 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    /// Colour forced on, whatever the stream — the only way these assertions
396    /// can see an escape at all, since a test binary's stdout is not a
397    /// terminal.
398    fn colour() -> Palette {
399        Palette::resolve(ColorChoice::Always, false, None)
400    }
401
402    /// What a coloured rendering must reduce to: strip every SGR sequence and
403    /// the plain rendering has to come back byte for byte. This is what pins
404    /// "colour never changes the layout" for every renderer below.
405    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    /// The client column has three states in one place — address with a name,
420    /// address alone, and no client at all — because the two fields are empty
421    /// together and a second column would just be a second blank.
422    #[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        // No reason on a plain issuance, so no trailing `reason=`.
432        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        // A CLI row: no actor id, no client, and a reason that does show.
441        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    /// A refusal is the row an operator is scanning for, and the `_failed`
459    /// suffix is the only thing on the listing that says so — `outcome` is a
460    /// detail-view field.
461    #[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    /// The detail view renders one field per line and **omits** the absent
479    /// ones, so a blank never reads as "unknown".
480    #[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    /// The listing's client column has the same three states as the audit
525    /// line's, and for the same reason — it is the same renderer.
526    #[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        // The fingerprint gave this column up; the detail view still has it.
542        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    /// The status column keeps its eleven characters under colour — the
559    /// regression for wrapping a field before padding it.
560    #[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        // Every label lands its value in the same column, `last_seen_ptr`
604        // included — it is thirteen characters, and the field is wide for it.
605        for line in text.lines() {
606            assert_eq!(&line[13..14], " ", "misaligned: {line:?}");
607            assert_ne!(&line[14..15], " ", "misaligned: {line:?}");
608        }
609        // Never recorded, so never a line — not a line reading "none".
610        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        // Seeded at creation, so this one is present even on a fresh account.
623        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    /// Three states, three colours, and the layout unchanged in each.
636    ///
637    /// The `{:<9}` this column is built with has **never** padded anything:
638    /// `OrderStatus`'s `Display` is a bare `write_str`, which ignores the
639    /// width, so the field arrives here already ragged. That is pre-existing
640    /// and deliberately left alone — the contract colour has to keep is
641    /// "identical bytes with the palette off", not "the layout the format
642    /// string looks like it asks for".
643    #[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        // The nested statuses are painted too: an order is read here precisely
695        // when one of its authorizations is not what it should be.
696        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    /// A revoked order's `status` stays `valid`, so the suffix is the only
712    /// thing that can carry the news — and it is painted whole.
713    #[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    /// The one line an operator must not scroll past — a lost secret is
755    /// replaced, never recovered.
756    #[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    /// An operator with no second factor is a state worth noticing in a
797    /// listing, which is why `off` is painted like any other bad status.
798    #[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    /// The three TOTP states, the middle one painted whole because its
816    /// parenthetical is the half that explains it.
817    #[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    /// A session still owing its second factor is the one an operator is
867    /// looking for in `admin session list`.
868    #[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}