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