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 super::window::Window;
26use crate::admin::ProfileSummary;
27use crate::admin::ops::{ExpiringEntry, OrderDetail};
28use crate::sqlite::account::{Account, pubkey_fingerprint};
29use crate::sqlite::admin_session::AdminSession;
30use crate::sqlite::admin_user::AdminUser;
31use crate::sqlite::audit::AuditEntry;
32use crate::sqlite::eab::Eab;
33use crate::sqlite::order::{Order, rfc3339};
34
35/// An address and the reverse name it had, as `ip (ptr)`.
36///
37/// Collapses to the address alone when there is no name, and to `-` when there
38/// was no address at all. Three states in one column rather than two columns
39/// that are empty together, which is what a `-` under a `PTR` heading would have
40/// been. A name without an address is not a state that exists, so the pair is
41/// only ever read in this order.
42fn render_client(ip: Option<&String>, ptr: Option<&String>) -> String {
43    match (ip, ptr) {
44        (Some(ip), Some(ptr)) => format!("{ip} ({ptr})"),
45        (Some(ip), None) => ip.clone(),
46        _ => "-".to_string(),
47    }
48}
49
50/// One line: `id  profile  status  last_seen_from  contact  created_at`.
51///
52/// The address where the key was last seen takes the column a public-key
53/// fingerprint used to hold: a fingerprint identifies nothing an operator
54/// scanning a list is looking for, and it is one `account show` away.
55#[must_use]
56pub fn render_account_line(account: &Account, palette: Palette) -> String {
57    format!(
58        "{}  {:<12}  {}  {:<40}  {}  {}",
59        account.id,
60        account.profile,
61        palette.status(&format!("{:<11}", account.status)),
62        render_client(
63            account.last_seen_ip.as_ref(),
64            account.last_seen_ptr.as_ref()
65        ),
66        if account.contact.is_empty() {
67            "-".to_string()
68        } else {
69            account.contact.join(",")
70        },
71        rfc3339(account.created_at),
72    )
73}
74
75/// `account show <id>`, one field per line.
76///
77/// The traceability columns take the line count past what
78/// [`render_account_line`] can carry, which is the same split `audit` makes
79/// between its listing and [`render_audit_detail_text`].
80#[must_use]
81pub fn render_account_detail_text(account: &Account, palette: Palette) -> String {
82    // One column wider than `render_audit_detail_text`'s, because
83    // `last_seen_ptr` is thirteen characters and would otherwise be the one
84    // label that pushes its value out of line.
85    let mut out = format!(
86        "id            {}\nprofile       {}\nstatus        {}\npubkey        {}\ncreated       {}\n",
87        account.id,
88        account.profile,
89        palette.status(&account.status),
90        pubkey_fingerprint(&account.pubkey),
91        rfc3339(account.created_at),
92    );
93    if !account.contact.is_empty() {
94        out.push_str(&format!("contact       {}\n", account.contact.join(",")));
95    }
96    if let Some(agreed) = account.terms_of_service_agreed {
97        out.push_str(&format!("terms         {agreed}\n"));
98    }
99    if let Some(seen) = account.last_seen_at {
100        out.push_str(&format!("last_seen     {}\n", rfc3339(seen)));
101    }
102    if let Some(kid) = account.eab_kid {
103        out.push_str(&format!("eab_kid       {kid}\n"));
104    }
105    for (label, value) in [
106        ("created_ip", account.created_ip.as_ref()),
107        ("created_ptr", account.created_ptr.as_ref()),
108        ("last_seen_ip", account.last_seen_ip.as_ref()),
109        ("last_seen_ptr", account.last_seen_ptr.as_ref()),
110    ] {
111        if let Some(value) = value {
112            out.push_str(&format!("{label:<13} {value}\n"));
113        }
114    }
115    out
116}
117
118/// The event name padded to `width`, painted when it names a refusal.
119///
120/// Driven off the `_failed` suffix rather than off `AuditEntry::outcome`,
121/// because the listing does not carry `outcome` and the two are derived from
122/// one definition (`AuditEvent::outcome`) anyway. An event this build has never
123/// seen still renders — the column is a stored string on purpose.
124///
125/// Takes the width rather than a padded string, because the suffix test has to
126/// run on the *unpadded* name and the escape has to wrap the *padded* one.
127fn paint_event(event: &str, width: usize, palette: Palette) -> String {
128    let padded = format!("{event:<width$}");
129    if event.ends_with("_failed") {
130        palette.bad(&padded)
131    } else {
132        padded
133    }
134}
135
136/// One line: `id  created_at  event  profile  actor  client  identifiers`.
137///
138/// The client column is [`render_client`]'s three shapes; a row with neither
139/// address nor name is a CLI or relay action, and reads as `-`.
140#[must_use]
141pub fn render_audit_line(entry: &AuditEntry, palette: Palette) -> String {
142    let actor = match &entry.actor_id {
143        Some(id) => format!("{}:{id}", entry.actor_kind),
144        None => entry.actor_kind.clone(),
145    };
146    let client = render_client(entry.client_ip.as_ref(), entry.client_ptr.as_ref());
147    let mut line = format!(
148        "{:<8}  {}  {}  {:<12}  {:<24}  {:<40}  {}",
149        entry.id,
150        rfc3339(entry.created_at),
151        paint_event(&entry.event, 26, palette),
152        entry.profile,
153        actor,
154        client,
155        entry.identifiers.join(","),
156    );
157    if let Some(reason) = &entry.reason {
158        line.push_str(&format!("  reason={reason}"));
159    }
160    line
161}
162
163/// `audit show <id>`, one field per line — the row carries thirteen possible
164/// fields and a single line of them would wrap on any terminal.
165#[must_use]
166pub fn render_audit_detail_text(entry: &AuditEntry, palette: Palette) -> String {
167    let mut out = format!(
168        "id           {}\ncreated      {}\nevent        {}\noutcome      {}\nprofile      {}\nactor        {}\n",
169        entry.id,
170        rfc3339(entry.created_at),
171        paint_event(&entry.event, 0, palette),
172        palette.status(&entry.outcome),
173        entry.profile,
174        match &entry.actor_id {
175            Some(id) => format!("{}:{id}", entry.actor_kind),
176            None => entry.actor_kind.clone(),
177        },
178    );
179    for (label, value) in [
180        ("account", entry.account_id.as_ref()),
181        ("order", entry.order_id.as_ref()),
182        ("serial", entry.cert_serial.as_ref()),
183        ("client_ip", entry.client_ip.as_ref()),
184        ("client_ptr", entry.client_ptr.as_ref()),
185        ("user_agent", entry.user_agent.as_ref()),
186        ("request_id", entry.request_id.as_ref()),
187        ("reason", entry.reason.as_ref()),
188        ("detail", entry.detail.as_ref()),
189    ] {
190        if let Some(value) = value {
191            out.push_str(&format!("{label:<12} {value}\n"));
192        }
193    }
194    if !entry.identifiers.is_empty() {
195        out.push_str(&format!("identifiers  {}\n", entry.identifiers.join(",")));
196    }
197    out
198}
199
200/// One line: `id  status  identifiers (comma-joined)  created_at`.
201#[must_use]
202pub fn render_order_line(order: &Order, palette: Palette) -> String {
203    let identifiers = order
204        .identifiers
205        .iter()
206        .map(|i| i.value.as_str())
207        .collect::<Vec<_>>()
208        .join(",");
209    let mut line = format!(
210        "{}  {:<12}  {}  {}  {}",
211        order.id,
212        order.profile,
213        palette.status(&format!("{:<9}", order.status)),
214        identifiers,
215        rfc3339(order.created_at)
216    );
217    if let Some(revoked_at) = order.revoked_at {
218        // Painted whole: an order's `status` stays `valid` after revocation
219        // (RFC 8555 defines no revoked status), so this suffix is the only
220        // thing on the line that says the certificate is withdrawn.
221        line.push_str(&palette.bad(&format!(
222            "  revoked={}{}",
223            rfc3339(revoked_at),
224            order
225                .revocation_reason
226                .map(|r| format!(" reason={r}"))
227                .unwrap_or_default()
228        )));
229    }
230    line
231}
232
233/// One line of `order list --expiring-in`: `order_id  profile  Nd  not_after
234/// identifiers`, plus a `replaced-by=` suffix where something has.
235///
236/// Two things are painted, both semantic. The days-left column, because "act
237/// now" versus "soon" is the one thing an operator scans this listing for; and
238/// the supersession suffix, because its *presence* is the good news — which is
239/// also why the rows with no suffix are the ones left plain. The thresholds are
240/// the terminal's own and match `/ui/expiring`'s badges.
241#[must_use]
242pub fn render_expiring_line(entry: &ExpiringEntry, palette: Palette) -> String {
243    let order = &entry.order;
244    let identifiers = order
245        .identifiers
246        .iter()
247        .map(|i| i.value.as_str())
248        .collect::<Vec<_>>()
249        .join(",");
250    // Padded first, then painted: a format width counts bytes.
251    let days = format!("{:>5}", format!("{}d", entry.days_remaining));
252    let days = match entry.days_remaining {
253        0..=7 => palette.bad(&days),
254        8..=30 => palette.warn(&days),
255        _ => days,
256    };
257    let mut line = format!(
258        "{}  {:<12}  {}  {}  {}",
259        order.id,
260        order.profile,
261        days,
262        rfc3339(order.cert_not_after.unwrap_or_default()),
263        identifiers
264    );
265    if let Some(superseded) = &entry.superseded_by {
266        line.push_str(&palette.ok(&format!(
267            "  replaced-by={} via={}",
268            superseded.order_id, superseded.via
269        )));
270    }
271    line
272}
273
274/// The one-line summary of an order's stored problem document: its `detail`,
275/// else its `type`, else the document itself. The same fallback the order card
276/// renders (`orders/_card.html`), so the two never describe one failure
277/// differently.
278fn problem_summary(error: &serde_json::Value) -> String {
279    for member in ["detail", "type"] {
280        if let Some(text) = error.get(member).and_then(serde_json::Value::as_str) {
281            return text.to_string();
282        }
283    }
284    error.to_string()
285}
286
287/// `order show`, one field per line, then the authorization tree.
288///
289/// Tracks [`crate::admin::render::render_order_detail_json`] member for member,
290/// omitting every field that was not recorded rather than rendering it empty —
291/// the shape [`render_account_detail_text`] and [`render_audit_detail_text`]
292/// already have. It printed six fields until now, while its own `--json`
293/// carried the serial, the leaf's expiry and the revocation state, and the book
294/// documented the *JSON* spelling of the last two as something `order show`
295/// surfaced.
296///
297/// Four JSON members are deliberately not here:
298///
299/// - `authorizations` and `finalize` are **URLs**. The indented tree below
300///   answers the same question for a terminal, and carries the ids.
301/// - `certificate` is the ACME URL, reachable only by signed POST-as-GET, so
302///   printing it is a dead string — the reason the order card refuses it too.
303/// - `certificatePem` is the chain itself, several KB of it, and this is a
304///   command an operator runs to orient themselves. `--json` and the panel's
305///   `chain.pem` download are where the bytes live; `cli.md` says so.
306#[must_use]
307pub fn render_order_detail_text(detail: &OrderDetail, palette: Palette) -> String {
308    let order = &detail.order;
309    // Two columns wider than `render_account_detail_text`'s, because
310    // `cert_not_after` is fourteen characters — and it keeps that spelling
311    // rather than a shorter one precisely because `not_after` is a *different*
312    // field one line above it (the requested §7.4 window, not the leaf's).
313    let mut out = format!(
314        "id             {}\nprofile        {}\naccount_id     {}\nstatus         {}\nidentifiers    {}\ncreated        {}\nexpires        {}\n",
315        order.id,
316        order.profile,
317        order.account_id,
318        palette.status(&order.status.to_string()),
319        order
320            .identifiers
321            .iter()
322            .map(|i| i.value.as_str())
323            .collect::<Vec<_>>()
324            .join(","),
325        rfc3339(order.created_at),
326        rfc3339(order.expires),
327    );
328    for (label, value) in [
329        ("not_before", order.not_before.map(rfc3339)),
330        ("not_after", order.not_after.map(rfc3339)),
331        ("replaces", order.replaces.clone()),
332        ("serial", order.cert_serial.clone()),
333        // The negative sentinel means the chain would not parse, which is not a
334        // date to render — `render_order_json`'s guard, for its reason.
335        (
336            "cert_not_after",
337            order
338                .cert_not_after
339                .filter(|value| *value >= 0)
340                .map(rfc3339),
341        ),
342        // Painted whole, like `render_order_line`'s suffix: an order's `status`
343        // stays `valid` after revocation (RFC 8555 defines no revoked status),
344        // so these two lines are the only thing saying the certificate is
345        // withdrawn. `reason` hangs off `revoked_at` as it does in the JSON — a
346        // reason with no revocation would be a column read out of context.
347        (
348            "revoked",
349            order.revoked_at.map(|at| palette.bad(&rfc3339(at))),
350        ),
351        (
352            "reason",
353            order
354                .revoked_at
355                .and(order.revocation_reason)
356                .map(|reason| reason.to_string()),
357        ),
358        ("error", order.error.as_ref().map(problem_summary)),
359    ] {
360        if let Some(value) = value {
361            out.push_str(&format!("{label:<14} {value}\n"));
362        }
363    }
364    for (authz, challenges) in &detail.authorizations {
365        out.push_str(&format!(
366            "  authz {} [{}] {}\n",
367            authz.id,
368            palette.status(&authz.status.to_string()),
369            authz.identifier.value
370        ));
371        for challenge in challenges {
372            out.push_str(&format!(
373                "    challenge {} [{}] type={}\n",
374                challenge.id,
375                palette.status(&challenge.status.to_string()),
376                challenge.typ
377            ));
378        }
379    }
380    out
381}
382
383/// One line: `kid  status  label  created_at (RFC3339)`.
384#[must_use]
385pub fn render_eab_line(eab: &Eab, palette: Palette) -> String {
386    format!(
387        "{}  {}  {}  {}",
388        eab.kid,
389        palette.status(&format!("{:<8}", eab.status)),
390        eab.label.as_deref().unwrap_or("-"),
391        rfc3339(eab.created_at),
392    )
393}
394
395/// `eab create` text output.
396#[must_use]
397pub fn render_eab_created_text(eab: &Eab, palette: Palette) -> String {
398    format!(
399        "kid: {}\nhmacKey: {}\nlabel: {}\n\n{}\n",
400        eab.kid,
401        BASE64_URL_SAFE_NO_PAD.encode(&eab.secret),
402        eab.label.as_deref().unwrap_or("-"),
403        palette.warn("Store the hmacKey now: it is shown only this once."),
404    )
405}
406
407/// One line: `username  status  totp  created_at  last_login`.
408#[must_use]
409pub fn render_admin_user_line(user: &AdminUser, palette: Palette) -> String {
410    format!(
411        "{:<20}  {}  totp={}  {}  {}",
412        user.username,
413        palette.status(&format!("{:<8}", user.status)),
414        palette.status(&format!(
415            "{:<3}",
416            if user.has_totp() { "on" } else { "off" }
417        )),
418        rfc3339(user.created_at),
419        user.last_login_at.map_or("never".to_string(), rfc3339),
420    )
421}
422
423/// `admin user totp status`, in words.
424///
425/// Says which of the three states the operator is in, since "enrolment pending"
426/// and "no factor" behave identically at the login prompt and only this line
427/// tells them apart -- an operator who believes they enrolled and did not
428/// confirm has no other way to find out.
429#[must_use]
430pub fn render_admin_totp_line(
431    user: &AdminUser,
432    recovery_codes_remaining: i64,
433    palette: Palette,
434) -> String {
435    // The pending word carries its explanation, so it is painted whole rather
436    // than through `status` -- which would leave the parenthetical plain and
437    // read as two different pieces of information.
438    let state = if user.has_totp() {
439        palette.status("enabled")
440    } else if user.has_pending_totp() {
441        palette.warn("pending (enrolment started, never confirmed)")
442    } else {
443        palette.status("off")
444    };
445
446    format!(
447        "{:<20}  totp={}  recovery-codes={}",
448        user.username, state, recovery_codes_remaining
449    )
450}
451
452/// `admin user show <username>`, one field per line.
453///
454/// The listing's twin, split for [`render_account_detail_text`]'s reason: the
455/// second factor takes the line count past what
456/// [`render_admin_user_line`] can carry. The factor wording is
457/// [`render_admin_totp_line`]'s own three states, reused rather than
458/// re-spelled -- `admin user show` and `admin user totp status` describing one
459/// factor differently is exactly the confusion the pending state already
460/// invites.
461#[must_use]
462pub fn render_admin_user_detail_text(
463    user: &AdminUser,
464    recovery_codes_remaining: i64,
465    palette: Palette,
466) -> String {
467    let mut out = format!(
468        "id             {}\nusername       {}\nstatus         {}\ntotp           {}\n\
469         recovery_codes {}\ncreated        {}\nupdated        {}\n",
470        user.id,
471        user.username,
472        palette.status(&user.status),
473        totp_state(user, palette),
474        recovery_codes_remaining,
475        rfc3339(user.created_at),
476        rfc3339(user.updated_at),
477    );
478    // Omitted rather than rendered as "never" when unset, the rule every
479    // `render_*_json` here follows: an operator who has never signed in is a
480    // different fact from one whose last sign-in this build cannot name.
481    if let Some(last) = user.last_login_at {
482        out.push_str(&format!("last_login     {}\n", rfc3339(last)));
483    }
484    out
485}
486
487/// The three states an operator's second factor can be in.
488///
489/// "Enrolment pending" and "no factor" behave identically at the login prompt,
490/// and this is the only line that tells them apart -- an operator who believes
491/// they enrolled and never confirmed has no other way to find out. The pending
492/// word carries its own explanation, so it is painted whole rather than through
493/// `status`, which would leave the parenthetical plain and read as two separate
494/// pieces of information.
495fn totp_state(user: &AdminUser, palette: Palette) -> String {
496    if user.has_totp() {
497        palette.status("enabled")
498    } else if user.has_pending_totp() {
499        palette.warn("pending (enrolment started, never confirmed)")
500    } else {
501        palette.status("off")
502    }
503}
504
505/// One line: `name  challenges  eab  directory`.
506///
507/// `profile list`'s row. The directory URL comes **last** despite being the
508/// field an operator most often copies, because it is the only one here with no
509/// bounded vocabulary: a base URL varies by tens of characters, and any column
510/// after it would be ragged. The two flags in front of it are padded and so
511/// line up down the listing.
512///
513/// The bypass state is painted through [`Palette::warn`] because it is the one
514/// field here that is a warning rather than a value: an endpoint that marks a
515/// challenge `valid` without checking anything is an open CA wherever
516/// `[filter]` is empty, and `/ui/profiles` flags it for the same reason.
517#[must_use]
518pub fn render_profile_line(profile: &ProfileSummary, palette: Palette) -> String {
519    // Padded, then painted -- a format width counts bytes, so the other order
520    // counts the escape and collapses the column.
521    let challenges = if profile.challenge_bypass {
522        palette.warn(&format!("{:<9}", "bypassed"))
523    } else {
524        palette.status(&format!("{:<9}", "validated"))
525    };
526    format!(
527        "{:<20}  challenges={}  eab={:<3}  {}",
528        profile.name,
529        challenges,
530        if profile.eab_enabled { "on" } else { "off" },
531        profile.directory_url(),
532    )
533}
534
535/// One line: `id  user  state  created_at  expires_at  ip`.
536///
537/// `id` is a fingerprint of the token hash, not the hash: see
538/// [`AdminSession::to_json`].
539#[must_use]
540pub fn render_admin_session_line(session: &AdminSession, palette: Palette) -> String {
541    format!(
542        "{}  {}  {}  {}  expires={}  {}",
543        crate::sqlite::nonce::fingerprint(&session.token_hash),
544        session.user_id,
545        palette.status(&format!("{:<11}", session.state)),
546        rfc3339(session.created_at),
547        rfc3339(session.expires_at),
548        session.created_ip.as_deref().unwrap_or("-"),
549    )
550}
551
552/// The envelope a paged `--json` listing answers with.
553///
554/// Deliberately the same four members, spelled the same way, as
555/// [`crate::webadmin::handlers::paging::page_envelope`]: `total` is what the
556/// same filters match **unpaged**, which is the whole difference between having
557/// read the table and having read a page of it, and a script should not have to
558/// learn one shape for the API and another for the shell.
559///
560/// The **only** listing shape the binary answers with. Three listings — `eab
561/// list`, `admin user list`, `admin session list` — used to print a bare array
562/// instead, on the argument that an operator mints those rows by hand a few at
563/// a time and so has no page to report. That was true of the tables and false
564/// of the scripts reading them, which had to learn one shape for the shell and
565/// another for `/api`; the bare-array renderer beside this one went with them.
566#[must_use]
567pub fn json_page(items: Vec<serde_json::Value>, total: i64, window: Window) -> serde_json::Value {
568    serde_json::json!({
569        "items": items,
570        "total": total,
571        "limit": window.limit,
572        "offset": window.offset,
573    })
574}
575
576/// The line under a paged listing.
577///
578/// Printed **always**, not only when the page is short: "42 of 1877" is the
579/// difference between having read the trail and having read a page of it, and
580/// the count is already computed. Carries no [`Palette`] — a count is data, and
581/// colour here is decorative.
582fn footer_line(shown: usize, total: i64) -> String {
583    format!("{shown} of {total} row(s).")
584}
585
586/// The same line where supersession has dropped rows from the page.
587///
588/// `total` counts the **window**, not the rows below it: `admin::list_expiring`
589/// filters superseded certificates in Rust, because the annotation cannot
590/// become a SQL predicate. A bare "1 of 4" over a page that quietly dropped two
591/// is arithmetic an operator cannot reproduce, so the third number is said out
592/// loud — the terminal's spelling of the `hidden` member `GET /api/expiring`
593/// adds to its envelope for the same reason.
594fn expiring_footer_line(shown: usize, total: i64, hidden: i64) -> String {
595    if hidden > 0 {
596        format!("{shown} of {total} row(s), {hidden} superseded hidden.")
597    } else {
598        footer_line(shown, total)
599    }
600}
601
602/// Prints [`footer_line`].
603pub fn print_footer(shown: usize, total: i64) {
604    println!("{}", footer_line(shown, total));
605}
606
607/// Prints [`expiring_footer_line`].
608pub fn print_expiring_footer(shown: usize, total: i64, hidden: i64) {
609    println!("{}", expiring_footer_line(shown, total, hidden));
610}
611
612/// Prints one page, in whichever of the two shapes was asked for.
613///
614/// The `to_line` closure carries the [`Palette`], which is what keeps the
615/// `json` branch structurally unable to reach one. `order list --json` is the
616/// one caller that does not go through here — it batches an authorization
617/// lookup its `to_json` needs, and folding that in would make the text path pay
618/// for a query it never reads — so it calls [`json_page`] and [`print_footer`]
619/// directly instead.
620pub fn print_page<T>(
621    rows: &[T],
622    total: i64,
623    window: Window,
624    json: bool,
625    to_json: impl Fn(&T) -> serde_json::Value,
626    to_line: impl Fn(&T) -> String,
627) {
628    if json {
629        let rendered: Vec<_> = rows.iter().map(to_json).collect();
630        println!("{}", json_page(rendered, total, window));
631    } else {
632        for row in rows {
633            println!("{}", to_line(row));
634        }
635        print_footer(rows.len(), total);
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    use std::sync::Arc;
642
643    use super::*;
644    use crate::admin::ops::SupersededBy;
645    use crate::admin::ops::load_order_detail;
646    use crate::audit::ClientContext;
647    use crate::cli::style::ColorChoice;
648    use crate::sqlite::authz::{Authorization, Challenge};
649    use crate::sqlite::db::Database;
650    use crate::sqlite::order::Identifier;
651    use crate::sqlite::status::OrderStatus;
652    use crate::testutil::{
653        account_id, account_seen_from, admin_session_fixture, admin_user_fixture, audit_entry,
654        client_context, order_fixture,
655    };
656
657    /// Colour forced on, whatever the stream — the only way these assertions
658    /// can see an escape at all, since a test binary's stdout is not a
659    /// terminal.
660    fn colour() -> Palette {
661        Palette::resolve(ColorChoice::Always, false, None)
662    }
663
664    /// What a coloured rendering must reduce to: strip every SGR sequence and
665    /// the plain rendering has to come back byte for byte. This is what pins
666    /// "colour never changes the layout" for every renderer below.
667    fn strip_ansi(text: &str) -> String {
668        let mut out = String::with_capacity(text.len());
669        let mut rest = text;
670        while let Some(start) = rest.find('\x1b') {
671            out.push_str(&rest[..start]);
672            let Some(end) = rest[start..].find('m') else {
673                break;
674            };
675            rest = &rest[start + end + 1..];
676        }
677        out.push_str(rest);
678        out
679    }
680
681    /// The detail an operator's row could not carry, and the layout rule every
682    /// renderer here keeps: strip the escapes and the plain rendering comes
683    /// back byte for byte.
684    #[test]
685    fn the_operator_detail_names_the_factor_and_survives_stripping() {
686        let mut user = admin_user_fixture();
687
688        let plain = render_admin_user_detail_text(&user, 0, Palette::plain());
689        assert!(plain.contains("username       alice"));
690        assert!(plain.contains("totp           off"));
691        assert!(plain.contains("recovery_codes 0"));
692        // Omitted rather than rendered as "never": an operator who has not
693        // signed in is a different fact from one whose last sign-in is unknown.
694        assert!(!plain.contains("last_login"), "{plain}");
695
696        assert_eq!(
697            strip_ansi(&render_admin_user_detail_text(&user, 0, colour())),
698            plain
699        );
700
701        // The state a listing cannot show. `totp status` says the same words,
702        // through the same helper, so the two commands cannot describe one
703        // factor differently.
704        user.totp_pending_secret = Some(vec![1, 2, 3]);
705        let pending = render_admin_user_detail_text(&user, 0, Palette::plain());
706        assert!(
707            pending.contains("enrolment started, never confirmed"),
708            "{pending}"
709        );
710        assert!(
711            render_admin_totp_line(&user, 0, Palette::plain())
712                .contains("enrolment started, never confirmed")
713        );
714
715        user.totp_secret = Some(vec![4, 5, 6]);
716        user.last_login_at = Some(1_700_000_500);
717        let enabled = render_admin_user_detail_text(&user, 7, Palette::plain());
718        assert!(enabled.contains("totp           enabled"), "{enabled}");
719        assert!(enabled.contains("recovery_codes 7"));
720        assert!(enabled.contains("last_login     "), "{enabled}");
721    }
722
723    /// `profile list`'s row, and the one field on it that is a warning rather
724    /// than a value.
725    #[test]
726    fn the_profile_line_paints_a_bypassing_endpoint_and_nothing_else() {
727        let mut profile = ProfileSummary {
728            name: "le".to_string(),
729            base_url: "https://ca.example.com/profile/le".to_string(),
730            challenge_bypass: false,
731            eab_enabled: true,
732        };
733
734        let plain = render_profile_line(&profile, Palette::plain());
735        assert!(plain.contains("https://ca.example.com/profile/le/directory"));
736        assert!(plain.contains("challenges=validated"));
737        assert!(plain.contains("eab=on"));
738        assert_eq!(strip_ansi(&render_profile_line(&profile, colour())), plain);
739
740        profile.challenge_bypass = true;
741        profile.eab_enabled = false;
742        let bypassed = render_profile_line(&profile, Palette::plain());
743        assert!(bypassed.contains("challenges=bypassed"));
744        assert!(bypassed.contains("eab=off"));
745        assert_eq!(
746            strip_ansi(&render_profile_line(&profile, colour())),
747            bypassed
748        );
749    }
750
751    /// The footer under every paged listing, asserted on its exact bytes: four
752    /// commands print it now, and an operator's `awk` counts on the shape.
753    #[test]
754    fn the_footer_reports_the_page_against_the_unpaged_total() {
755        assert_eq!(footer_line(2, 137), "2 of 137 row(s).");
756        // A short page and an empty one are still a page, and still say so.
757        assert_eq!(footer_line(0, 0), "0 of 0 row(s).");
758    }
759
760    /// The expiry footer says the third number out loud, and is byte-identical
761    /// to the ordinary one when there is nothing to say.
762    #[test]
763    fn the_expiry_footer_names_the_rows_supersession_removed() {
764        assert_eq!(
765            expiring_footer_line(1, 4, 2),
766            "1 of 4 row(s), 2 superseded hidden."
767        );
768        assert_eq!(expiring_footer_line(4, 4, 0), footer_line(4, 4));
769    }
770
771    /// The CLI envelope is the API's, member for member — the whole point of
772    /// having it. A caller should not learn one shape for `--json` and another
773    /// for `/api`.
774    #[test]
775    fn the_json_envelope_matches_the_apis() {
776        let window = Window::resolve(2, 4);
777        let envelope = json_page(vec![serde_json::json!({"id": "a"})], 17, window);
778
779        assert_eq!(envelope["total"], 17);
780        assert_eq!(envelope["limit"], 2);
781        assert_eq!(envelope["offset"], 4);
782        assert_eq!(envelope["items"].as_array().unwrap().len(), 1);
783
784        let page = crate::webadmin::handlers::paging::Page {
785            limit: 2,
786            offset: 4,
787        };
788        assert_eq!(
789            envelope,
790            crate::webadmin::handlers::paging::page_envelope(
791                vec![serde_json::json!({"id": "a"})],
792                17,
793                page
794            )
795        );
796    }
797
798    /// The client column has three states in one place — address with a name,
799    /// address alone, and no client at all — because the two fields are empty
800    /// together and a second column would just be a second blank.
801    #[test]
802    fn the_audit_line_renders_all_three_shapes_of_client() {
803        let entry = audit_entry();
804        let line = render_audit_line(&entry, Palette::plain());
805        assert!(line.contains("41812"), "{line}");
806        assert!(line.contains("certificate_issued"), "{line}");
807        assert!(line.contains("acme:acct-1"), "{line}");
808        assert!(line.contains("203.0.113.7 (host.example.com)"), "{line}");
809        assert!(line.contains("a.example.com,b.example.com"), "{line}");
810        // No reason on a plain issuance, so no trailing `reason=`.
811        assert!(!line.contains("reason="), "{line}");
812
813        let mut no_ptr = audit_entry();
814        no_ptr.client_ptr = None;
815        let line = render_audit_line(&no_ptr, Palette::plain());
816        assert!(line.contains("203.0.113.7"), "{line}");
817        assert!(!line.contains('('), "{line}");
818
819        // A CLI row: no actor id, no client, and a reason that does show.
820        let mut cli = audit_entry();
821        cli.actor_kind = "cli".to_string();
822        cli.actor_id = None;
823        cli.client_ip = None;
824        cli.client_ptr = None;
825        cli.event = "certificate_revoked".to_string();
826        cli.reason = Some("1".to_string());
827        let line = render_audit_line(&cli, Palette::plain());
828        assert!(line.contains(" cli "), "{line}");
829        assert!(
830            !line.contains("cli:"),
831            "an actor with no id must not render a trailing colon: {line}"
832        );
833        assert!(line.contains(" - "), "{line}");
834        assert!(line.ends_with("reason=1"), "{line}");
835    }
836
837    /// A refusal is the row an operator is scanning for, and the `_failed`
838    /// suffix is the only thing on the listing that says so — `outcome` is a
839    /// detail-view field.
840    #[test]
841    fn only_a_failed_audit_event_is_painted() {
842        let succeeded = render_audit_line(&audit_entry(), colour());
843        assert!(!succeeded.contains('\x1b'), "{succeeded}");
844
845        let mut refused = audit_entry();
846        refused.event = "certificate_issue_failed".to_string();
847        refused.outcome = "failure".to_string();
848        let line = render_audit_line(&refused, colour());
849        assert!(line.contains("\x1b[31mcertificate_issue_failed"), "{line}");
850        assert_eq!(
851            strip_ansi(&line),
852            render_audit_line(&refused, Palette::plain()),
853            "colour must not move a column"
854        );
855    }
856
857    /// The detail view renders one field per line and **omits** the absent
858    /// ones, so a blank never reads as "unknown".
859    #[test]
860    fn the_audit_detail_omits_every_field_that_has_no_value() {
861        let full = render_audit_detail_text(&audit_entry(), Palette::plain());
862        for expected in [
863            "id           41812",
864            "event        certificate_issued",
865            "outcome      success",
866            "profile      le",
867            "actor        acme:acct-1",
868            "order        order-1",
869            "serial       0a0b",
870            "client_ip    203.0.113.7",
871            "client_ptr   host.example.com",
872            "user_agent   certbot/2.9.0",
873            "request_id   req-1",
874            "identifiers  a.example.com,b.example.com",
875        ] {
876            assert!(full.contains(expected), "missing `{expected}` in:\n{full}");
877        }
878        assert!(!full.contains("reason"), "{full}");
879        assert!(!full.contains("detail"), "{full}");
880
881        let bare = AuditEntry {
882            actor_id: None,
883            account_id: None,
884            order_id: None,
885            cert_serial: None,
886            identifiers: vec![],
887            client_ip: None,
888            client_ptr: None,
889            user_agent: None,
890            request_id: None,
891            ..audit_entry()
892        };
893        let text = render_audit_detail_text(&bare, Palette::plain());
894        assert!(text.contains("actor        acme\n"), "{text}");
895        for absent in ["account", "order", "serial", "client_ip", "identifiers"] {
896            assert!(
897                !text.contains(absent),
898                "`{absent}` should be absent from:\n{text}"
899            );
900        }
901    }
902
903    /// The listing's client column has the same three states as the audit
904    /// line's, and for the same reason — it is the same renderer.
905    #[tokio::test]
906    async fn render_account_line_renders_all_three_shapes_of_client() {
907        let db = Arc::new(Database::connect_in_memory().await.unwrap());
908
909        let both = account_seen_from(
910            &[1u8, 2, 3],
911            &client_context(Some("203.0.113.7"), Some("host.example.com")),
912            &db,
913        )
914        .await;
915        let line = render_account_line(&both, Palette::plain());
916        assert!(line.contains(&both.id.to_string()), "{line}");
917        assert!(line.contains("valid"), "{line}");
918        assert!(line.contains("mailto:a@example.com"), "{line}");
919        assert!(line.contains("203.0.113.7 (host.example.com)"), "{line}");
920        // The fingerprint gave this column up; the detail view still has it.
921        assert!(!line.contains(&pubkey_fingerprint(&both.pubkey)), "{line}");
922
923        let address_only = account_seen_from(
924            &[4u8, 5, 6],
925            &client_context(Some("203.0.113.7"), None),
926            &db,
927        )
928        .await;
929        let line = render_account_line(&address_only, Palette::plain());
930        assert!(line.contains("203.0.113.7"), "{line}");
931        assert!(!line.contains('('), "{line}");
932
933        let neither = account_seen_from(&[7u8, 8, 9], &ClientContext::default(), &db).await;
934        assert!(render_account_line(&neither, Palette::plain()).contains("  -  "));
935    }
936
937    /// The status column keeps its eleven characters under colour — the
938    /// regression for wrapping a field before padding it.
939    #[tokio::test]
940    async fn colour_never_moves_the_account_listings_columns() {
941        let db = Arc::new(Database::connect_in_memory().await.unwrap());
942        let account = account_seen_from(&[1u8, 2, 3], &ClientContext::default(), &db).await;
943
944        let painted = render_account_line(&account, colour());
945        assert!(painted.contains("\x1b[32mvalid      \x1b[0m"), "{painted}");
946        assert_eq!(
947            strip_ansi(&painted),
948            render_account_line(&account, Palette::plain())
949        );
950    }
951
952    #[tokio::test]
953    async fn render_account_detail_text_omits_every_absent_field() {
954        let db = Arc::new(Database::connect_in_memory().await.unwrap());
955
956        let seen = account_seen_from(
957            &[1u8, 2, 3],
958            &client_context(Some("203.0.113.7"), Some("host.example.com")),
959            &db,
960        )
961        .await;
962        let text = render_account_detail_text(&seen, Palette::plain());
963        assert!(
964            text.contains(&format!("id            {}", seen.id)),
965            "{text}"
966        );
967        assert!(text.contains("profile       default"), "{text}");
968        assert!(text.contains("status        valid"), "{text}");
969        assert!(
970            text.contains(&pubkey_fingerprint(&seen.pubkey)),
971            "the fingerprint the listing gave up must be here: {text}"
972        );
973        assert!(
974            text.contains("contact       mailto:a@example.com"),
975            "{text}"
976        );
977        assert!(text.contains("created_ip    203.0.113.7"), "{text}");
978        assert!(text.contains("created_ptr   host.example.com"), "{text}");
979        assert!(text.contains("last_seen     "), "{text}");
980        assert!(text.contains("last_seen_ip  203.0.113.7"), "{text}");
981        assert!(text.contains("last_seen_ptr host.example.com"), "{text}");
982        // Every label lands its value in the same column, `last_seen_ptr`
983        // included — it is thirteen characters, and the field is wide for it.
984        for line in text.lines() {
985            assert_eq!(&line[13..14], " ", "misaligned: {line:?}");
986            assert_ne!(&line[14..15], " ", "misaligned: {line:?}");
987        }
988        // Never recorded, so never a line — not a line reading "none".
989        assert!(!text.contains("eab_kid"), "{text}");
990        assert!(!text.contains("terms"), "{text}");
991
992        let bare =
993            Account::find_or_create("default", &[9u8], vec![], &ClientContext::default(), &db)
994                .await
995                .unwrap()
996                .0;
997        let text = render_account_detail_text(&bare, Palette::plain());
998        for absent in ["contact", "created_ip", "created_ptr", "last_seen_ip"] {
999            assert!(!text.contains(absent), "{absent} in {text}");
1000        }
1001        // Seeded at creation, so this one is present even on a fresh account.
1002        assert!(text.contains("last_seen     "), "{text}");
1003    }
1004
1005    #[test]
1006    fn render_order_line_includes_expected_fields() {
1007        let order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Pending);
1008        let line = render_order_line(&order, Palette::plain());
1009        assert!(line.contains(&order.id.to_string()));
1010        assert!(line.contains("pending"));
1011        assert!(line.contains("example.com"));
1012    }
1013
1014    /// Three states, three colours, and the layout unchanged in each.
1015    ///
1016    /// The `{:<9}` this column is built with has **never** padded anything:
1017    /// `OrderStatus`'s `Display` is a bare `write_str`, which ignores the
1018    /// width, so the field arrives here already ragged. That is pre-existing
1019    /// and deliberately left alone — the contract colour has to keep is
1020    /// "identical bytes with the palette off", not "the layout the format
1021    /// string looks like it asks for".
1022    #[test]
1023    fn the_order_status_column_is_painted_by_what_it_means() {
1024        for (status, code) in [
1025            (OrderStatus::Valid, "32"),
1026            (OrderStatus::Pending, "33"),
1027            (OrderStatus::Invalid, "31"),
1028        ] {
1029            let order = order_fixture(crate::sqlite::id::mint(), status);
1030            let painted = render_order_line(&order, colour());
1031            assert!(
1032                painted.contains(&format!("\x1b[{code}m{}\x1b[0m", status.as_str())),
1033                "{painted}"
1034            );
1035            assert_eq!(
1036                strip_ansi(&painted),
1037                render_order_line(&order, Palette::plain())
1038            );
1039        }
1040    }
1041
1042    #[tokio::test]
1043    async fn render_order_detail_text_surfaces_authorizations_and_challenges() {
1044        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1045        let acct = account_id(&db).await;
1046        let order = Order::create(
1047            "default",
1048            acct,
1049            vec![Identifier::dns("example.com")],
1050            crate::sqlite::nonce::now_secs() + 3600,
1051            None,
1052            None,
1053            &db,
1054        )
1055        .await
1056        .unwrap();
1057        let authz = Authorization::create(
1058            order.id,
1059            Identifier::dns("example.com"),
1060            crate::sqlite::nonce::now_secs() + 3600,
1061            &db,
1062        )
1063        .await
1064        .unwrap();
1065        Challenge::create(authz.id, "http-01", &db).await.unwrap();
1066
1067        let detail = load_order_detail(order.id.to_string().as_str(), db)
1068            .await
1069            .unwrap()
1070            .unwrap();
1071        let text = render_order_detail_text(&detail, Palette::plain());
1072        assert!(text.contains(&order.id.to_string()));
1073        assert!(text.contains(&authz.id.to_string()));
1074        assert!(text.contains("http-01"));
1075
1076        // The nested statuses are painted too: an order is read here precisely
1077        // when one of its authorizations is not what it should be.
1078        let painted = render_order_detail_text(&detail, colour());
1079        assert_eq!(
1080            painted.matches("\x1b[33mpending\x1b[0m").count(),
1081            3,
1082            "the order's, the authorization's and the challenge's: {painted}"
1083        );
1084        assert_eq!(strip_ansi(&painted), text);
1085    }
1086
1087    /// The field set `order show` prints, and the shape it prints it in.
1088    ///
1089    /// It carried six fields until now while its own `--json` carried the
1090    /// serial, the leaf's expiry and the revocation state — so this is the
1091    /// assertion that the two describe one order. The absent half matters as
1092    /// much: a field that was never recorded gets no line at all, rather than
1093    /// a label with nothing after it, which is `render_account_detail_text`'s
1094    /// contract and `audit show`'s.
1095    #[test]
1096    fn render_order_detail_text_omits_every_absent_field() {
1097        let account = crate::sqlite::id::mint();
1098        let mut order = order_fixture(account, OrderStatus::Valid);
1099        order.not_before = Some(1700000000);
1100        order.not_after = Some(1700003600);
1101        order.replaces = Some("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE".to_string());
1102        order.cert_serial = Some("03a7f1c9".to_string());
1103        order.cert_not_after = Some(1700007200);
1104        order.revoked_at = Some(1700010800);
1105        order.revocation_reason = Some(1);
1106        order.error = Some(serde_json::json!({
1107            "type": "urn:ietf:params:acme:error:badCSR",
1108            "detail": "CSR names do not match the order",
1109        }));
1110        let detail = OrderDetail {
1111            order,
1112            authorizations: vec![],
1113        };
1114
1115        let text = render_order_detail_text(&detail, Palette::plain());
1116        assert!(
1117            text.contains(&format!("id             {}", detail.order.id)),
1118            "{text}"
1119        );
1120        assert!(text.contains("profile        default"), "{text}");
1121        assert!(
1122            text.contains(&format!("account_id     {account}")),
1123            "{text}"
1124        );
1125        assert!(text.contains("status         valid"), "{text}");
1126        assert!(text.contains("identifiers    example.com"), "{text}");
1127        assert!(text.contains("created        "), "{text}");
1128        assert!(text.contains("expires        "), "{text}");
1129        assert!(
1130            text.contains("not_before     2023-11-14T22:13:20Z"),
1131            "{text}"
1132        );
1133        assert!(
1134            text.contains("not_after      2023-11-14T23:13:20Z"),
1135            "{text}"
1136        );
1137        assert!(
1138            text.contains("replaces       aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE"),
1139            "{text}"
1140        );
1141        assert!(text.contains("serial         03a7f1c9"), "{text}");
1142        assert!(
1143            text.contains("cert_not_after 2023-11-15T00:13:20Z"),
1144            "{text}"
1145        );
1146        assert!(
1147            text.contains("revoked        2023-11-15T01:13:20Z"),
1148            "{text}"
1149        );
1150        assert!(text.contains("reason         1"), "{text}");
1151        // The problem document's `detail`, the one line of it an operator
1152        // wants — the same fallback the order card renders.
1153        assert!(
1154            text.contains("error          CSR names do not match the order"),
1155            "{text}"
1156        );
1157        // Every label lands its value in the same column, `cert_not_after`
1158        // included — it is fourteen characters, and the field is wide for it.
1159        for line in text.lines() {
1160            assert_eq!(&line[14..15], " ", "misaligned: {line:?}");
1161            assert_ne!(&line[15..16], " ", "misaligned: {line:?}");
1162        }
1163
1164        // Never recorded, so never a line.
1165        let bare = OrderDetail {
1166            order: order_fixture(crate::sqlite::id::mint(), OrderStatus::Pending),
1167            authorizations: vec![],
1168        };
1169        let text = render_order_detail_text(&bare, Palette::plain());
1170        for absent in [
1171            "not_before",
1172            "not_after",
1173            "replaces",
1174            "serial",
1175            "cert_not_after",
1176            "revoked",
1177            "reason",
1178            "error",
1179        ] {
1180            assert!(!text.contains(absent), "{absent} in {text}");
1181        }
1182    }
1183
1184    /// The whole point of the change, asserted as a set: `order show` and
1185    /// `order show --json` describe the same order.
1186    ///
1187    /// The table below is the mapping, and it is checked in **both**
1188    /// directions — a JSON member gaining no text line fails here, and so does
1189    /// a text line naming nothing in the JSON. Four members are excluded by
1190    /// name and the exclusion is the decision, not an oversight: three URLs a
1191    /// terminal cannot use (the authorization list and `finalize` are answered
1192    /// by the indented tree below the fields; the ACME `certificate` URL is
1193    /// reachable only by signed POST-as-GET) and `certificatePem`, the chain
1194    /// itself, which `cli.md` documents as `--json`'s alone.
1195    #[test]
1196    fn the_text_and_json_order_renderings_describe_the_same_order() {
1197        /// `(text label, JSON member)`.
1198        const FIELDS: &[(&str, &str)] = &[
1199            ("id", "id"),
1200            ("profile", "profile"),
1201            ("account_id", "accountId"),
1202            ("status", "status"),
1203            ("identifiers", "identifiers"),
1204            ("created", "createdAt"),
1205            ("expires", "expires"),
1206            ("not_before", "notBefore"),
1207            ("not_after", "notAfter"),
1208            ("replaces", "replaces"),
1209            ("serial", "certSerial"),
1210            ("cert_not_after", "certNotAfter"),
1211            ("revoked", "revokedAt"),
1212            ("reason", "revocationReason"),
1213            ("error", "error"),
1214        ];
1215        /// Carried by `--json` and deliberately not printed.
1216        const JSON_ONLY: &[&str] = &[
1217            "authorizations",
1218            "finalize",
1219            "certificate",
1220            "certificatePem",
1221        ];
1222
1223        // Every optional column populated, or an absent one would read as an
1224        // agreed omission rather than as a member nobody renders.
1225        let account = crate::sqlite::id::mint();
1226        let mut order = order_fixture(account, OrderStatus::Valid);
1227        order.not_before = Some(1700000000);
1228        order.not_after = Some(1700003600);
1229        order.replaces = Some("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE".to_string());
1230        order.cert_serial = Some("03a7f1c9".to_string());
1231        order.cert_not_after = Some(1700007200);
1232        order.revoked_at = Some(1700010800);
1233        order.revocation_reason = Some(1);
1234        order.error = Some(serde_json::json!({ "detail": "unreachable" }));
1235        order.certificate = Some("-----BEGIN CERTIFICATE-----\n".to_string());
1236        let detail = OrderDetail {
1237            order,
1238            authorizations: vec![],
1239        };
1240
1241        let json = crate::admin::render::render_order_detail_json(&detail, "http://localhost:3000");
1242        let members: std::collections::BTreeSet<&str> = json["order"]
1243            .as_object()
1244            .unwrap()
1245            .keys()
1246            .map(String::as_str)
1247            .filter(|member| !JSON_ONLY.contains(member))
1248            .collect();
1249        assert_eq!(
1250            members,
1251            FIELDS.iter().map(|(_, member)| *member).collect(),
1252            "a JSON member the text rendering does not print, or the reverse"
1253        );
1254
1255        // A field line is one that starts in column zero; the tree below is
1256        // indented, and no value here wraps.
1257        let text = render_order_detail_text(&detail, Palette::plain());
1258        let labels: std::collections::BTreeSet<&str> = text
1259            .lines()
1260            .filter(|line| !line.starts_with(' '))
1261            .map(|line| line[..14].trim_end())
1262            .collect();
1263        assert_eq!(labels, FIELDS.iter().map(|(label, _)| *label).collect());
1264    }
1265
1266    /// The negative sentinel is not a date. A row the expiry backfill looked at
1267    /// and could not parse prints no `cert_not_after` line, exactly as
1268    /// `render_order_json` emits no member for it.
1269    #[test]
1270    fn an_unparsable_leaf_expiry_prints_no_line() {
1271        let mut order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Valid);
1272        order.cert_not_after = Some(crate::sqlite::order::UNPARSABLE_NOT_AFTER);
1273        let detail = OrderDetail {
1274            order,
1275            authorizations: vec![],
1276        };
1277        assert!(!render_order_detail_text(&detail, Palette::plain()).contains("cert_not_after"),);
1278    }
1279
1280    /// A revoked order's `status` stays `valid` here too, so the two revocation
1281    /// lines are the only news — and the timestamp is painted, like the
1282    /// listing's suffix.
1283    #[test]
1284    fn the_order_detail_paints_its_revocation_and_nothing_else_moves() {
1285        let mut order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Valid);
1286        order.revoked_at = Some(1700000000);
1287        order.revocation_reason = Some(4);
1288        let detail = OrderDetail {
1289            order,
1290            authorizations: vec![],
1291        };
1292        let painted = render_order_detail_text(&detail, colour());
1293        assert!(
1294            painted.contains("\x1b[31m2023-11-14T22:13:20Z\x1b[0m"),
1295            "{painted}"
1296        );
1297        assert_eq!(
1298            strip_ansi(&painted),
1299            render_order_detail_text(&detail, Palette::plain())
1300        );
1301    }
1302
1303    #[test]
1304    fn render_order_line_revoked_includes_reason_and_time() {
1305        let mut order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Valid);
1306        order.revoked_at = Some(1700000000);
1307        order.revocation_reason = Some(1);
1308        let line = render_order_line(&order, Palette::plain());
1309        assert!(line.contains("revoked="));
1310        assert!(line.contains("reason=1"));
1311    }
1312
1313    /// A revoked order's `status` stays `valid`, so the suffix is the only
1314    /// thing that can carry the news — and it is painted whole.
1315    #[test]
1316    fn a_revoked_order_paints_its_suffix_even_though_its_status_is_valid() {
1317        let mut order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Valid);
1318        order.revoked_at = Some(1700000000);
1319        order.revocation_reason = Some(1);
1320        let painted = render_order_line(&order, colour());
1321        assert!(painted.contains("\x1b[32mvalid"), "{painted}");
1322        assert!(painted.contains("\x1b[31m  revoked="), "{painted}");
1323        assert!(painted.ends_with("reason=1\x1b[0m"), "{painted}");
1324        assert_eq!(
1325            strip_ansi(&painted),
1326            render_order_line(&order, Palette::plain())
1327        );
1328    }
1329
1330    #[tokio::test]
1331    async fn render_eab_line_includes_expected_fields() {
1332        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1333        let eab = Eab::create(Some("team-a".to_string()), None, &db)
1334            .await
1335            .unwrap();
1336        let line = render_eab_line(&eab, Palette::plain());
1337        assert!(line.contains(&eab.kid.to_string()));
1338        assert!(line.contains("active"));
1339        assert!(line.contains("team-a"));
1340
1341        let painted = render_eab_line(&eab, colour());
1342        assert!(painted.contains("\x1b[32mactive  \x1b[0m"), "{painted}");
1343        assert_eq!(strip_ansi(&painted), line);
1344    }
1345
1346    #[tokio::test]
1347    async fn render_eab_created_text_includes_kid_and_hmac_key() {
1348        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1349        let eab = Eab::create(None, None, &db).await.unwrap();
1350        let text = render_eab_created_text(&eab, Palette::plain());
1351        assert!(text.contains(&eab.kid.to_string()));
1352        assert!(text.contains(&BASE64_URL_SAFE_NO_PAD.encode(&eab.secret)));
1353        assert!(text.contains("Store the hmacKey now"));
1354    }
1355
1356    /// The one line an operator must not scroll past — a lost secret is
1357    /// replaced, never recovered.
1358    #[tokio::test]
1359    async fn the_eab_secret_warning_is_the_only_thing_painted() {
1360        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1361        let eab = Eab::create(None, None, &db).await.unwrap();
1362        let painted = render_eab_created_text(&eab, colour());
1363        assert!(
1364            painted.contains("\x1b[33mStore the hmacKey now: it is shown only this once.\x1b[0m"),
1365            "{painted}"
1366        );
1367        assert_eq!(painted.matches('\x1b').count(), 2, "{painted}");
1368        assert_eq!(
1369            strip_ansi(&painted),
1370            render_eab_created_text(&eab, Palette::plain())
1371        );
1372    }
1373
1374    #[test]
1375    fn render_admin_user_line_never_shows_the_hash_and_says_never_for_no_login() {
1376        let user = admin_user_fixture();
1377        let line = render_admin_user_line(&user, Palette::plain());
1378        assert!(line.contains("alice"));
1379        assert!(line.contains("active"));
1380        assert!(line.contains("totp=off"));
1381        assert!(line.contains("never"));
1382        assert!(
1383            !line.contains("pbkdf2"),
1384            "the stored hash must never reach a terminal: {line}"
1385        );
1386    }
1387
1388    #[test]
1389    fn render_admin_user_line_reflects_totp_and_a_real_last_login() {
1390        let mut user = admin_user_fixture();
1391        user.totp_secret = Some(vec![1, 2, 3]);
1392        user.last_login_at = Some(1_700_000_500);
1393        let line = render_admin_user_line(&user, Palette::plain());
1394        assert!(line.contains("totp=on"));
1395        assert!(!line.contains("never"));
1396    }
1397
1398    /// An operator with no second factor is a state worth noticing in a
1399    /// listing, which is why `off` is painted like any other bad status.
1400    #[test]
1401    fn an_operator_without_a_second_factor_stands_out() {
1402        let without = render_admin_user_line(&admin_user_fixture(), colour());
1403        assert!(without.contains("totp=\x1b[31moff\x1b[0m"), "{without}");
1404
1405        let mut user = admin_user_fixture();
1406        user.totp_secret = Some(vec![1, 2, 3]);
1407        user.status = "disabled".to_string();
1408        let with = render_admin_user_line(&user, colour());
1409        assert!(with.contains("totp=\x1b[32mon \x1b[0m"), "{with}");
1410        assert!(with.contains("\x1b[31mdisabled\x1b[0m"), "{with}");
1411        assert_eq!(
1412            strip_ansi(&with),
1413            render_admin_user_line(&user, Palette::plain())
1414        );
1415    }
1416
1417    /// The three TOTP states, the middle one painted whole because its
1418    /// parenthetical is the half that explains it.
1419    #[test]
1420    fn the_totp_line_paints_each_of_its_three_states() {
1421        let plain = Palette::plain();
1422        let off = admin_user_fixture();
1423        assert!(
1424            render_admin_totp_line(&off, 0, plain).contains("totp=off"),
1425            "plain output unchanged"
1426        );
1427        assert!(render_admin_totp_line(&off, 0, colour()).contains("totp=\x1b[31moff\x1b[0m"));
1428
1429        let mut pending = admin_user_fixture();
1430        pending.totp_pending_secret = Some(vec![1, 2, 3]);
1431        let line = render_admin_totp_line(&pending, 0, colour());
1432        assert!(
1433            line.contains("\x1b[33mpending (enrolment started, never confirmed)\x1b[0m"),
1434            "{line}"
1435        );
1436        assert_eq!(
1437            strip_ansi(&line),
1438            render_admin_totp_line(&pending, 0, plain)
1439        );
1440
1441        let mut enabled = admin_user_fixture();
1442        enabled.totp_secret = Some(vec![1, 2, 3]);
1443        let line = render_admin_totp_line(&enabled, 7, colour());
1444        assert!(line.contains("totp=\x1b[32menabled\x1b[0m"), "{line}");
1445        assert!(line.contains("recovery-codes=7"), "{line}");
1446    }
1447
1448    #[test]
1449    fn render_admin_session_line_shows_a_fingerprint_not_the_token_hash() {
1450        let line = render_admin_session_line(&admin_session_fixture(), Palette::plain());
1451        assert!(line.contains("01234567"));
1452        assert!(
1453            !line.contains("0123456789abcdef0123456789abcdef"),
1454            "printing the hash would put every live session's lookup key on a terminal: {line}"
1455        );
1456        assert!(!line.contains("the-csrf-token"));
1457        assert!(line.contains("192.0.2.1"));
1458        assert!(line.contains("expires="));
1459    }
1460
1461    #[test]
1462    fn render_admin_session_line_dashes_a_missing_address() {
1463        let mut session = admin_session_fixture();
1464        session.created_ip = None;
1465        assert!(render_admin_session_line(&session, Palette::plain()).contains(" -"));
1466    }
1467
1468    /// A session still owing its second factor is the one an operator is
1469    /// looking for in `admin session list`.
1470    #[test]
1471    fn a_pending_mfa_session_is_painted_apart_from_an_active_one() {
1472        let active = render_admin_session_line(&admin_session_fixture(), colour());
1473        assert!(active.contains("\x1b[32mactive     \x1b[0m"), "{active}");
1474
1475        let mut session = admin_session_fixture();
1476        session.state = "pending_mfa".to_string();
1477        let painted = render_admin_session_line(&session, colour());
1478        assert!(painted.contains("\x1b[33mpending_mfa\x1b[0m"), "{painted}");
1479        assert_eq!(
1480            strip_ansi(&painted),
1481            render_admin_session_line(&session, Palette::plain())
1482        );
1483    }
1484
1485    /// The expiry line's own shape, its three urgency bands, and the suffix
1486    /// that only appears where something has replaced the certificate.
1487    #[test]
1488    fn the_expiring_line_bands_the_days_and_annotates_only_what_was_replaced() {
1489        let id = crate::sqlite::id::mint();
1490        let entry = move |days: i64, superseded: Option<SupersededBy>| {
1491            let mut order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Valid);
1492            order.id = id;
1493            order.cert_not_after = Some(1_700_000_000);
1494            ExpiringEntry {
1495                order,
1496                days_remaining: days,
1497                superseded_by: superseded,
1498            }
1499        };
1500
1501        let plain = render_expiring_line(&entry(40, None), Palette::plain());
1502        assert!(plain.starts_with(&format!("{id}  default")), "{plain}");
1503        assert!(plain.contains("  40d  "), "{plain}");
1504        assert!(plain.contains("2023-11-14"), "{plain}");
1505        assert!(plain.ends_with("example.com"), "{plain}");
1506        assert!(
1507            !plain.contains("replaced-by"),
1508            "the absent annotation is what an operator scans for: {plain}"
1509        );
1510
1511        // Inside a week is red, inside a month amber, beyond that plain.
1512        assert!(render_expiring_line(&entry(3, None), colour()).contains("\x1b[31m"));
1513        assert!(render_expiring_line(&entry(20, None), colour()).contains("\x1b[33m"));
1514        let far = render_expiring_line(&entry(40, None), colour());
1515        assert_eq!(
1516            far,
1517            render_expiring_line(&entry(40, None), Palette::plain())
1518        );
1519
1520        let replaced = entry(
1521            3,
1522            Some(SupersededBy {
1523                order_id: "ord-2".to_string(),
1524                cert_serial: "0a0b".to_string(),
1525                not_after: 1_800_000_000,
1526                via: "replaces".to_string(),
1527            }),
1528        );
1529        let line = render_expiring_line(&replaced, Palette::plain());
1530        assert!(line.ends_with("  replaced-by=ord-2 via=replaces"), "{line}");
1531    }
1532
1533    /// The days column keeps its width under colour — the same regression the
1534    /// account listing pins, for a field this renderer pads itself.
1535    #[test]
1536    fn colour_never_moves_the_expiring_listings_columns() {
1537        let mut order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Valid);
1538        order.cert_not_after = Some(1_700_000_000);
1539        let entry = ExpiringEntry {
1540            order,
1541            days_remaining: 3,
1542            superseded_by: Some(SupersededBy {
1543                order_id: "ord-2".to_string(),
1544                cert_serial: "0a0b".to_string(),
1545                not_after: 1_800_000_000,
1546                via: "identifiers".to_string(),
1547            }),
1548        };
1549
1550        let painted = render_expiring_line(&entry, colour());
1551        assert!(painted.contains("\x1b[31m   3d\x1b[0m"), "{painted}");
1552        assert_eq!(
1553            strip_ansi(&painted),
1554            render_expiring_line(&entry, Palette::plain())
1555        );
1556    }
1557}