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