Skip to main content

acme_proxy_admin/admin/
render.rs

1//! The JSON renderings, shared by both front ends.
2//!
3//! One shape per admin resource, and **the boundary this module exists to
4//! hold**: everything here is read by the CLI's `--json` branches *and* by
5//! `crates/admin/src/webadmin/`, so a change is a change to a wire format two callers parse.
6//! The human-readable renderings are the CLI's alone and live in
7//! `cli::render`, which is where colour is woven in — none of it can
8//! reach this file, so `--json` output stays byte-identical whatever the
9//! terminal is.
10//!
11//! Each surfaces the admin-only fields the ACME wire format deliberately does
12//! not carry (an order's revocation, an account's traceability columns), and
13//! each **omits** rather than nulls an absent one, so a template asking
14//! `{% if account.createdIp %}` is asking the question it looks like it is
15//! asking.
16
17use base64::prelude::*;
18use serde_json::Value;
19use uuid::Uuid;
20
21use crate::admin::ops::{JobDetail, OrderDetail, UpstreamOrderDetail};
22use acme_proxy_core::datetime::rfc3339;
23use acme_proxy_store::account::Account;
24use acme_proxy_store::account::pubkey_fingerprint;
25use acme_proxy_store::admin_session::AdminSession;
26use acme_proxy_store::admin_user::AdminUser;
27use acme_proxy_store::eab::Eab;
28use acme_proxy_store::expiring::ExpiringEntry;
29use acme_proxy_store::job::Job;
30use acme_proxy_store::order::Order;
31use acme_proxy_store::upstream_order::UpstreamOrderRow;
32
33use acme_proxy_core::routes::profile_base_url;
34
35/// JSON representation for account admin display.
36///
37/// The traceability members are admin-only: [`Account::to_json`] is the RFC 8555
38/// object and deliberately carries none of them. Each is **omitted** rather than
39/// rendered as `null` when the column is unset, so a template asking `{% if
40/// account.createdIp %}` is asking the question it looks like it is asking.
41#[must_use]
42pub fn render_account_json(account: &Account, base_url: &str) -> Value {
43    let mut object = account
44        .to_json(&profile_base_url(base_url, &account.profile))
45        .as_object()
46        .cloned()
47        .unwrap_or_default();
48    object.insert("id".to_string(), Value::String(account.id.to_string()));
49    object.insert(
50        "profile".to_string(),
51        Value::String(account.profile.clone()),
52    );
53    object.insert(
54        "createdAt".to_string(),
55        Value::String(rfc3339(account.created_at)),
56    );
57    object.insert(
58        "pubkeyFingerprint".to_string(),
59        Value::String(pubkey_fingerprint(&account.pubkey)),
60    );
61    // The credential the account registered under, which may since have been
62    // deleted: a kid, not a promise that `GET /api/eab/{kid}` answers.
63    if let Some(kid) = account.eab_kid {
64        object.insert("eabKid".to_string(), Value::String(kid.to_string()));
65    }
66    if let Some(seen) = account.last_seen_at {
67        object.insert("lastSeenAt".to_string(), Value::String(rfc3339(seen)));
68    }
69    for (key, value) in [
70        ("createdIp", account.created_ip.as_ref()),
71        ("createdPtr", account.created_ptr.as_ref()),
72        ("lastSeenIp", account.last_seen_ip.as_ref()),
73        ("lastSeenPtr", account.last_seen_ptr.as_ref()),
74    ] {
75        if let Some(value) = value {
76            object.insert(key.to_string(), Value::String(value.clone()));
77        }
78    }
79    Value::Object(object)
80}
81
82/// JSON representation for order admin display.
83#[must_use]
84pub fn render_order_json(order: &Order, base_url: &str, authz_ids: &[Uuid]) -> Value {
85    let mut object = order
86        .to_json(&profile_base_url(base_url, &order.profile), authz_ids)
87        .as_object()
88        .cloned()
89        .unwrap_or_default();
90    object.insert("id".to_string(), Value::String(order.id.to_string()));
91    object.insert("profile".to_string(), Value::String(order.profile.clone()));
92    // Admin-only, like `id` and `profile`: the ACME order object deliberately
93    // never names its account, but an operator looking at an order almost
94    // always wants to get to the account behind it, and without this neither
95    // front end can offer that link.
96    object.insert(
97        "accountId".to_string(),
98        Value::String(order.account_id.to_string()),
99    );
100    object.insert(
101        "createdAt".to_string(),
102        Value::String(rfc3339(order.created_at)),
103    );
104    // The leaf's serial, admin-only for `accountId`'s reason and absent from
105    // every rendering until now — while `audit list --cert-serial` and
106    // `GET /api/audit?certSerial=` both filter on it, so an operator could
107    // search the trail by a value nothing would tell them. Omitted rather than
108    // nulled: an unissued order has no serial, which is a different statement
109    // from an empty one. `render_expiring_json` below deliberately keeps its
110    // `unwrap_or_default()` — that shape is the digest's own, member for
111    // member, and is not this one.
112    if let Some(serial) = order.cert_serial.as_ref() {
113        object.insert("certSerial".to_string(), Value::String(serial.clone()));
114    }
115    // The leaf's own expiry, and admin-only for `accountId`'s reason: RFC 8555
116    // gives the order object no member for it, and the `notAfter` already in
117    // there from `to_json` is the *requested* §7.4 window, which is a different
118    // question with a confusingly similar name. Omitted rather than nulled,
119    // like every other member here — a row issued before the column existed has
120    // nothing to say yet, and the negative sentinel means the chain would not
121    // parse, which is not a date to render.
122    if let Some(not_after) = order.cert_not_after.filter(|value| *value >= 0) {
123        object.insert(
124            "certNotAfter".to_string(),
125            Value::String(rfc3339(not_after)),
126        );
127    }
128    if let Some(revoked_at) = order.revoked_at {
129        object.insert("revokedAt".to_string(), Value::String(rfc3339(revoked_at)));
130        if let Some(reason) = order.revocation_reason {
131            object.insert("revocationReason".to_string(), Value::from(reason));
132        }
133    }
134    Value::Object(object)
135}
136
137/// `order show --json` JSON output.
138#[must_use]
139pub fn render_order_detail_json(detail: &OrderDetail, base_url: &str) -> Value {
140    let authz_ids: Vec<Uuid> = detail.authorizations.iter().map(|(a, _)| a.id).collect();
141    let profile_base = profile_base_url(base_url, &detail.order.profile);
142    let authorizations: Vec<Value> = detail
143        .authorizations
144        .iter()
145        .map(|(a, c)| a.to_json(&profile_base, c))
146        .collect();
147    let mut order = render_order_json(&detail.order, base_url, &authz_ids);
148    // The issued chain itself, admin-only and **detail-only**.
149    //
150    // `certificate` beside it is the ACME *URL*, reachable only by signed
151    // POST-as-GET — a browser following it gets nothing, so on its own it is a
152    // dead string on the order card. The PEM is the thing an operator actually
153    // wants, and it is already in the row.
154    //
155    // Deliberately not in `render_order_json`, which also renders every row of
156    // every listing: a page of fifty orders would carry fifty chains for a
157    // field no list can show.
158    if let (Some(object), Some(pem)) = (order.as_object_mut(), detail.order.certificate.as_ref()) {
159        object.insert("certificatePem".to_string(), Value::String(pem.clone()));
160    }
161
162    let mut root = serde_json::Map::new();
163    root.insert("order".to_string(), order);
164    root.insert("authorizations".to_string(), Value::Array(authorizations));
165    Value::Object(root)
166}
167
168/// One background job, for `acme-proxy jobs list/show` and `GET /api/jobs`.
169///
170/// `payload` is echoed verbatim — it is the subject's identity (an order id, a
171/// profile name), never a secret, which the migration guarantees so an operator
172/// can read the table with `sqlite3`. Every optional member is **omitted** when
173/// absent, never nulled; `lastError` is text a far end wrote, so a web surface
174/// must escape it (the `.html` templates do).
175#[must_use]
176pub fn render_job_json(job: &Job) -> Value {
177    let mut object = serde_json::Map::new();
178    object.insert("id".to_string(), Value::String(job.id.to_string()));
179    object.insert("kind".to_string(), Value::String(job.kind.clone()));
180    object.insert("dedupKey".to_string(), Value::String(job.dedup_key.clone()));
181    object.insert("payload".to_string(), job.payload.clone());
182    object.insert("status".to_string(), Value::String(job.status.clone()));
183    object.insert("runAt".to_string(), Value::String(rfc3339(job.run_at)));
184    object.insert("attempts".to_string(), Value::from(job.attempts));
185    object.insert("maxAttempts".to_string(), Value::from(job.max_attempts));
186    if let Some(deadline) = job.deadline {
187        object.insert("deadline".to_string(), Value::String(rfc3339(deadline)));
188    }
189    if let Some(lease_until) = job.lease_until {
190        object.insert(
191            "leaseUntil".to_string(),
192            Value::String(rfc3339(lease_until)),
193        );
194    }
195    if let Some(owner) = job.lease_owner.as_ref() {
196        object.insert("leaseOwner".to_string(), Value::String(owner.clone()));
197    }
198    if let Some(error) = job.last_error.as_ref() {
199        object.insert("lastError".to_string(), Value::String(error.clone()));
200    }
201    object.insert(
202        "createdAt".to_string(),
203        Value::String(rfc3339(job.created_at)),
204    );
205    object.insert(
206        "updatedAt".to_string(),
207        Value::String(rfc3339(job.updated_at)),
208    );
209    Value::Object(object)
210}
211
212/// One relay `upstream_orders` row joined to its local order, for
213/// `acme-proxy upstream order list/show` and `GET /api/upstream-orders`.
214///
215/// **Never carries `csrDer`** — `UpstreamOrderRow` has no such field by
216/// design. `error` and `userAgent` are untrusted (the upstream CA and the
217/// finalize client wrote them); every optional member is omitted when absent.
218#[must_use]
219pub fn render_upstream_order_json(row: &UpstreamOrderRow) -> Value {
220    let mut object = serde_json::Map::new();
221    object.insert(
222        "orderId".to_string(),
223        Value::String(row.order_id.to_string()),
224    );
225    object.insert("profile".to_string(), Value::String(row.profile.clone()));
226    object.insert(
227        "accountId".to_string(),
228        Value::String(row.account_id.to_string()),
229    );
230    object.insert("status".to_string(), Value::String(row.status.clone()));
231    object.insert(
232        "localStatus".to_string(),
233        Value::String(row.local_status.as_str().to_string()),
234    );
235    object.insert(
236        "localExpires".to_string(),
237        Value::String(rfc3339(row.local_expires)),
238    );
239    object.insert(
240        "identifiers".to_string(),
241        serde_json::to_value(&row.identifiers).unwrap_or(Value::Null),
242    );
243    object.insert(
244        "upstreamOrderUrl".to_string(),
245        Value::String(row.upstream_order_url.clone()),
246    );
247    if let Some(url) = row.upstream_finalize_url.as_ref() {
248        object.insert(
249            "upstreamFinalizeUrl".to_string(),
250            Value::String(url.clone()),
251        );
252    }
253    if let Some(url) = row.upstream_certificate_url.as_ref() {
254        object.insert(
255            "upstreamCertificateUrl".to_string(),
256            Value::String(url.clone()),
257        );
258    }
259    if let Some(error) = row.error.as_ref() {
260        object.insert("error".to_string(), Value::String(error.clone()));
261    }
262    if let Some(ip) = row.client_ip.as_ref() {
263        object.insert("clientIp".to_string(), Value::String(ip.clone()));
264    }
265    if let Some(ptr) = row.client_ptr.as_ref() {
266        object.insert("clientPtr".to_string(), Value::String(ptr.clone()));
267    }
268    if let Some(ua) = row.user_agent.as_ref() {
269        object.insert("userAgent".to_string(), Value::String(ua.clone()));
270    }
271    if let Some(request_id) = row.request_id.as_ref() {
272        object.insert("requestId".to_string(), Value::String(request_id.clone()));
273    }
274    object.insert(
275        "createdAt".to_string(),
276        Value::String(rfc3339(row.created_at)),
277    );
278    object.insert(
279        "updatedAt".to_string(),
280        Value::String(rfc3339(row.updated_at)),
281    );
282    Value::Object(object)
283}
284
285/// `jobs show --json` / `GET /api/jobs/{id}` — the job plus, for a relay
286/// issuance, the `upstream_orders` row it drives.
287#[must_use]
288pub fn render_job_detail_json(detail: &JobDetail) -> Value {
289    let mut object = render_job_json(&detail.job)
290        .as_object()
291        .cloned()
292        .unwrap_or_default();
293    if let Some(upstream) = detail.upstream_order.as_ref() {
294        object.insert(
295            "upstreamOrder".to_string(),
296            render_upstream_order_json(upstream),
297        );
298    }
299    Value::Object(object)
300}
301
302/// `upstream order show --json` / `GET /api/upstream-orders/{id}` — the row
303/// plus the most recent relay job for it, live or terminal.
304#[must_use]
305pub fn render_upstream_order_detail_json(detail: &UpstreamOrderDetail) -> Value {
306    let mut object = render_upstream_order_json(&detail.upstream_order)
307        .as_object()
308        .cloned()
309        .unwrap_or_default();
310    if let Some(job) = detail.job.as_ref() {
311        object.insert("job".to_string(), render_job_json(job));
312    }
313    Value::Object(object)
314}
315
316/// One row of the expiry list: `GET /api/expiring`, `/ui/expiring` and
317/// `order list --expiring-in --json`.
318///
319/// A shape of its own rather than [`render_order_json`] plus two members, for
320/// two reasons. That renderer takes `authz_ids`, which no expiry view shows and
321/// which would be a query per row to supply; and this shape is deliberately the
322/// digest's own (`acme_proxy_jobs::notify::ExpiringCertificate`), so the mail, the page,
323/// the API and the terminal all describe an expiring certificate the same way.
324///
325/// `supersededBy` is **omitted** when nothing has replaced this certificate,
326/// like every other absent member here — and an operator scanning the list is
327/// looking for exactly the rows where it is absent, so `null` would be a value
328/// where the question is presence.
329#[must_use]
330pub fn render_expiring_json(entry: &ExpiringEntry) -> Value {
331    let order = &entry.order;
332    let mut object = serde_json::Map::new();
333    object.insert("orderId".to_string(), Value::String(order.id.to_string()));
334    object.insert("profile".to_string(), Value::String(order.profile.clone()));
335    object.insert(
336        "accountId".to_string(),
337        Value::String(order.account_id.to_string()),
338    );
339    object.insert(
340        "certSerial".to_string(),
341        Value::String(order.cert_serial.clone().unwrap_or_default()),
342    );
343    object.insert(
344        "identifiers".to_string(),
345        Value::Array(
346            order
347                .identifiers
348                .iter()
349                .map(|identifier| Value::String(identifier.value.clone()))
350                .collect(),
351        ),
352    );
353    object.insert(
354        "notAfter".to_string(),
355        Value::String(rfc3339(order.cert_not_after.unwrap_or_default())),
356    );
357    object.insert(
358        "daysRemaining".to_string(),
359        Value::from(entry.days_remaining),
360    );
361    if let Some(superseded) = &entry.superseded_by {
362        object.insert(
363            "supersededBy".to_string(),
364            serde_json::json!({
365                "orderId": superseded.order_id,
366                "certSerial": superseded.cert_serial,
367                "notAfter": rfc3339(superseded.not_after),
368                "via": superseded.via,
369            }),
370        );
371    }
372    Value::Object(object)
373}
374
375/// `eab list --json` / `eab show --json`.
376#[must_use]
377pub fn render_eab_json(eab: &Eab) -> Value {
378    eab.to_json()
379}
380
381/// `eab create` JSON output (includes secret).
382#[must_use]
383pub fn render_eab_created_json(eab: &Eab) -> Value {
384    let mut object = eab.to_json().as_object().cloned().unwrap_or_default();
385    object.insert(
386        "hmacKey".to_string(),
387        Value::String(BASE64_URL_SAFE_NO_PAD.encode(&eab.secret)),
388    );
389    Value::Object(object)
390}
391
392/// `admin user list --json`.
393///
394/// A straight pass-through: unlike an account or an order, an operator has no
395/// ACME wire form to augment -- [`AdminUser::to_json`] is already the only
396/// representation, and it is the one that omits the password hash.
397#[must_use]
398pub fn render_admin_user_json(user: &AdminUser) -> Value {
399    user.to_json()
400}
401
402/// `admin user show --json`: the listing shape plus the two members that cost a
403/// query.
404///
405/// [`render_order_detail_json`]'s arrangement, and for its reason.
406/// `enrolmentPending` distinguishes the state a listing cannot show --
407/// "enrolment started, never confirmed" behaves exactly like "no factor" at the
408/// login prompt, so an operator who believes they enrolled has no other way to
409/// find out -- and `recoveryCodesRemaining` is a `COUNT` on a second table,
410/// which a page of fifty operators should not pay fifty times.
411#[must_use]
412pub fn render_admin_user_detail_json(user: &AdminUser, recovery_codes_remaining: i64) -> Value {
413    let mut object = render_admin_user_json(user)
414        .as_object()
415        .cloned()
416        .unwrap_or_default();
417    object.insert(
418        "enrolmentPending".to_string(),
419        Value::Bool(user.has_pending_totp()),
420    );
421    object.insert(
422        "recoveryCodesRemaining".to_string(),
423        Value::from(recovery_codes_remaining),
424    );
425    Value::Object(object)
426}
427
428/// `admin session list --json`.
429#[must_use]
430pub fn render_admin_session_json(session: &AdminSession) -> Value {
431    session.to_json()
432}
433
434/// A session listing plus one member no CLI rendering needed: whether this row
435/// *is* the caller's own live session.
436///
437/// [`render_admin_user_detail_json`]'s arrangement -- the listing shape, cloned
438/// and extended, never the reverse. `current_token_hash` is the caller's own
439/// session (never a session being looked *at*, which never has this member's
440/// answer be true of itself in a useful way), so the web panel's own sessions
441/// card can badge or relabel the row that revoking would sign the viewer out
442/// of, without teaching [`AdminSession`] anything about who is asking.
443#[must_use]
444pub fn render_admin_session_detail_json(session: &AdminSession, current_token_hash: &str) -> Value {
445    let mut object = render_admin_session_json(session)
446        .as_object()
447        .cloned()
448        .unwrap_or_default();
449    object.insert(
450        "current".to_string(),
451        Value::Bool(session.token_hash == current_token_hash),
452    );
453    Value::Object(object)
454}
455
456/// `nonce count --json` and `GET /api/nonces`.
457///
458/// A count and nothing else: a nonce is a bearer credential until it is
459/// consumed, so listing values would put live ones on a screen. The count is
460/// the useful part -- it should sit near the request rate times the TTL, and a
461/// number far above that says the reaper is not running, which is why the TTL
462/// travels beside it rather than leaving the reader to go and look it up.
463#[must_use]
464pub fn render_nonce_stats_json(count: i64, ttl_seconds: u64) -> Value {
465    serde_json::json!({
466        "count": count,
467        "ttlSeconds": ttl_seconds,
468    })
469}
470
471/// One ACME endpoint, as every surface describes it.
472///
473/// The two front ends reach this from opposite directions, and the difference
474/// is real rather than an implementation detail. `GET /api/profiles` and
475/// `/ui/profiles` build it from a **mounted** [`acme_proxy_protocol::profile::Profile`], so
476/// they describe what this process is actually serving; `acme-proxy profile
477/// list` builds it from the configuration, because the alternative is
478/// `server::profile::build_all`, which constructs signer backends -- generating a CA
479/// key and contacting a relay upstream for a read-only listing. That is `filter
480/// show`'s split exactly: the panel serves the live thing, the terminal
481/// rebuilds one, and between an edit and its `SIGHUP` the two legitimately
482/// disagree.
483pub struct ProfileSummary {
484    pub name: String,
485    pub base_url: String,
486    pub challenge_bypass: bool,
487    pub eab_enabled: bool,
488}
489
490impl ProfileSummary {
491    /// An endpoint this process is serving.
492    #[must_use]
493    pub fn mounted(profile: &acme_proxy_protocol::profile::Profile) -> Self {
494        Self {
495            name: profile.name.clone(),
496            base_url: profile.base_url.clone(),
497            challenge_bypass: profile.challenges.is_bypassed(),
498            eab_enabled: profile.eab.enabled,
499        }
500    }
501
502    /// An endpoint this configuration would mount.
503    ///
504    /// `Config::resolve_profiles` has already dropped anything `enabled = false`
505    /// on the caller's behalf, so this needs no filter of its own -- the list it
506    /// is mapped over is already the mounted set, minus the fact of being
507    /// mounted.
508    #[must_use]
509    pub fn configured(base_url: &str, profile: &acme_proxy_core::config::ProfileConfig) -> Self {
510        Self {
511            name: profile.name.clone(),
512            base_url: profile_base_url(base_url, &profile.name),
513            challenge_bypass: profile.sections.challenge.bypass,
514            eab_enabled: profile.sections.eab.enabled,
515        }
516    }
517
518    /// Where a client fetches this endpoint's directory (RFC 8555 §7.1.1).
519    #[must_use]
520    pub fn directory_url(&self) -> String {
521        format!("{}{}", self.base_url, acme_proxy_core::routes::DIRECTORY)
522    }
523}
524
525/// `profile list --json`, `GET /api/profiles` and `/ui/profiles`.
526#[must_use]
527pub fn render_profile_json(profile: &ProfileSummary) -> Value {
528    serde_json::json!({
529        "name": profile.name,
530        "baseUrl": profile.base_url,
531        "directory": profile.directory_url(),
532        "challengeBypass": profile.challenge_bypass,
533        "eabEnabled": profile.eab_enabled,
534    })
535}
536
537#[cfg(test)]
538mod tests {
539    use std::sync::Arc;
540
541    use super::*;
542    use crate::admin::ops::load_order_detail;
543    use acme_proxy_core::audit::ClientContext;
544    use acme_proxy_core::identifier::Identifier;
545    use acme_proxy_store::authz::Authorization;
546    use acme_proxy_store::authz::Challenge;
547    use acme_proxy_store::db::Database;
548    use acme_proxy_store::status::OrderStatus;
549    use acme_proxy_store::testutil::account_id;
550    use acme_proxy_store::testutil::account_seen_from;
551    use acme_proxy_store::testutil::admin_session_fixture;
552    use acme_proxy_store::testutil::admin_user_fixture;
553    use acme_proxy_store::testutil::client_context;
554    use acme_proxy_store::testutil::job_fixture;
555    use acme_proxy_store::testutil::order_fixture;
556    use acme_proxy_store::testutil::upstream_order_row_fixture;
557
558    #[tokio::test]
559    async fn render_account_json_includes_id_and_base_fields() {
560        let db = Arc::new(Database::connect_in_memory().await.unwrap());
561        let account = account_seen_from(
562            &[1u8, 2, 3],
563            &client_context(Some("203.0.113.7"), Some("host.example.com")),
564            &db,
565        )
566        .await;
567
568        let json = render_account_json(&account, "http://localhost:3000");
569        assert_eq!(json["id"], account.id.to_string());
570        assert_eq!(json["status"], "valid");
571        assert!(
572            json["orders"]
573                .as_str()
574                .unwrap()
575                .contains(&account.id.to_string())
576        );
577        assert!(json["pubkeyFingerprint"].is_string());
578        assert_eq!(json["createdIp"], "203.0.113.7");
579        assert_eq!(json["createdPtr"], "host.example.com");
580        assert_eq!(json["lastSeenIp"], "203.0.113.7");
581        assert_eq!(json["lastSeenPtr"], "host.example.com");
582        assert!(json["lastSeenAt"].as_str().unwrap().contains('T'));
583    }
584
585    /// Absent, not `null`: a template asking `{% if account.createdIp %}` must
586    /// be asking the question it looks like it is asking.
587    #[tokio::test]
588    async fn render_account_json_omits_the_traceability_members_it_has_no_value_for() {
589        let db = Arc::new(Database::connect_in_memory().await.unwrap());
590        let account = account_seen_from(&[1u8, 2, 3], &ClientContext::default(), &db).await;
591
592        let json = render_account_json(&account, "http://localhost:3000");
593        let object = json.as_object().unwrap();
594        for key in ["createdIp", "createdPtr", "lastSeenIp", "lastSeenPtr"] {
595            assert!(!object.contains_key(key), "{key} in {json}");
596        }
597    }
598
599    #[test]
600    fn render_order_json_includes_id_and_authorizations() {
601        let account = acme_proxy_store::id::mint();
602        let authz = acme_proxy_store::id::mint();
603        let order = order_fixture(account, OrderStatus::Pending);
604        let json = render_order_json(&order, "http://localhost:3000", &[authz]);
605        assert_eq!(json["id"], order.id.to_string());
606        // Admin output is rendered against the *profile's* base URL, not the
607        // server's: that is the URL the client was handed.
608        assert_eq!(json["profile"], "default");
609        // Admin-only, and absent from the ACME order object: without it
610        // neither front end could link an order back to its account.
611        assert_eq!(json["accountId"], account.to_string());
612        assert_eq!(
613            json["authorizations"],
614            serde_json::json!([format!(
615                "http://localhost:3000/profile/default/authz/{authz}"
616            )])
617        );
618    }
619
620    #[tokio::test]
621    async fn render_order_detail_json_nests_authorizations_and_challenges() {
622        let db = Arc::new(Database::connect_in_memory().await.unwrap());
623        let acct = account_id(&db).await;
624        let order = Order::create(
625            "default",
626            acct,
627            vec![Identifier::dns("example.com")],
628            acme_proxy_store::nonce::now_secs() + 3600,
629            None,
630            None,
631            &db,
632        )
633        .await
634        .unwrap();
635        let authz = Authorization::create(
636            order.id,
637            Identifier::dns("example.com"),
638            acme_proxy_store::nonce::now_secs() + 3600,
639            &db,
640        )
641        .await
642        .unwrap();
643        Challenge::create(authz.id, "http-01", &db).await.unwrap();
644
645        let detail = load_order_detail(order.id.to_string().as_str(), db)
646            .await
647            .unwrap()
648            .unwrap();
649        let json = render_order_detail_json(&detail, "http://localhost:3000");
650        assert_eq!(json["order"]["id"], order.id.to_string());
651        assert_eq!(json["authorizations"].as_array().unwrap().len(), 1);
652        assert_eq!(
653            json["authorizations"][0]["challenges"]
654                .as_array()
655                .unwrap()
656                .len(),
657            1
658        );
659    }
660
661    #[tokio::test]
662    async fn render_eab_created_json_includes_the_hmac_key_and_line_json_does_not() {
663        let db = Arc::new(Database::connect_in_memory().await.unwrap());
664        let eab = Eab::create(None, None, &db).await.unwrap();
665
666        let created = render_eab_created_json(&eab);
667        assert!(created["hmacKey"].is_string());
668
669        let listed = render_eab_json(&eab);
670        assert!(listed.get("hmacKey").is_none());
671    }
672
673    #[test]
674    fn render_order_json_revoked_includes_reason_and_time() {
675        let mut order = order_fixture(acme_proxy_store::id::mint(), OrderStatus::Valid);
676        order.revoked_at = Some(1700000000);
677        order.revocation_reason = Some(1);
678        let json = render_order_json(&order, "http://localhost:3000", &[]);
679        assert_eq!(json["revokedAt"].as_str().unwrap().len(), 20);
680        assert_eq!(json["revocationReason"], 1);
681    }
682
683    #[test]
684    fn render_order_json_carries_the_cert_serial_and_omits_it_when_unissued() {
685        // The complaint this member answers: `audit list --cert-serial` and
686        // `GET /api/audit?certSerial=` both filter on this value, and until now
687        // no order rendering would tell an operator what it was.
688        let mut order = order_fixture(acme_proxy_store::id::mint(), OrderStatus::Valid);
689        order.cert_serial = Some("03a7f1c9".to_string());
690        let json = render_order_json(&order, "http://localhost:3000", &[]);
691        assert_eq!(json["certSerial"], "03a7f1c9");
692
693        // Omitted, not nulled: an order that never issued has no serial, which
694        // is a different statement from an empty one.
695        let unissued = order_fixture(acme_proxy_store::id::mint(), OrderStatus::Pending);
696        let json = render_order_json(&unissued, "http://localhost:3000", &[]);
697        assert!(json.get("certSerial").is_none());
698    }
699
700    #[test]
701    fn render_admin_user_json_omits_every_secret() {
702        let mut user = admin_user_fixture();
703        user.totp_secret = Some(vec![9, 9, 9]);
704        let json = render_admin_user_json(&user);
705        let rendered = json.to_string();
706        assert!(!rendered.contains("pbkdf2"));
707        assert!(!rendered.contains("totpSecret"));
708        assert_eq!(json["username"], "alice");
709        assert_eq!(json["totpEnabled"], true);
710    }
711
712    #[test]
713    fn render_admin_session_json_omits_the_hash_and_the_csrf_token() {
714        let json = render_admin_session_json(&admin_session_fixture());
715        let rendered = json.to_string();
716        assert!(!rendered.contains("0123456789abcdef0123456789abcdef"));
717        assert!(!rendered.contains("the-csrf-token"));
718        assert_eq!(json["id"], "01234567");
719        assert_eq!(json["state"], "active");
720    }
721
722    #[test]
723    fn render_admin_session_detail_json_marks_only_the_matching_hash() {
724        let session = admin_session_fixture();
725        let mine = render_admin_session_detail_json(&session, &session.token_hash);
726        assert_eq!(mine["current"], true);
727        // Everything the listing shape carries is still there.
728        assert_eq!(mine["id"], "01234567");
729
730        let someone_elses = render_admin_session_detail_json(&session, "a-different-hash");
731        assert_eq!(someone_elses["current"], false);
732    }
733
734    // --- the job queue surface -------------------------------------------
735
736    #[test]
737    fn render_job_json_carries_the_payload_and_omits_absent_optionals() {
738        let mut job = job_fixture();
739        job.deadline = None;
740        job.lease_until = None;
741        job.lease_owner = None;
742        job.last_error = None;
743
744        let json = render_job_json(&job);
745        assert_eq!(json["kind"], "signer_relay_issue");
746        assert_eq!(json["dedupKey"], "order-1");
747        assert_eq!(json["payload"]["order_id"], "order-1");
748        assert_eq!(json["attempts"], 3);
749        assert_eq!(json["maxAttempts"], 5);
750        let object = json.as_object().unwrap();
751        for absent in ["deadline", "leaseUntil", "leaseOwner", "lastError"] {
752            assert!(!object.contains_key(absent), "{absent} should be omitted");
753        }
754    }
755
756    #[test]
757    fn render_upstream_order_json_never_carries_the_csr_and_omits_absent_optionals() {
758        let mut row = upstream_order_row_fixture();
759        row.upstream_finalize_url = None;
760        row.upstream_certificate_url = None;
761        row.error = None;
762        row.user_agent = None;
763
764        let json = render_upstream_order_json(&row);
765        let rendered = json.to_string();
766        assert!(!rendered.contains("csr"), "no csrDer, ever: {rendered}");
767        assert!(!rendered.contains("csrDer"));
768        assert_eq!(json["orderId"], row.order_id.to_string());
769        assert_eq!(json["localStatus"], "processing");
770        assert_eq!(json["identifiers"][0]["value"], "a.example.com");
771        let object = json.as_object().unwrap();
772        for absent in [
773            "upstreamFinalizeUrl",
774            "upstreamCertificateUrl",
775            "error",
776            "userAgent",
777        ] {
778            assert!(!object.contains_key(absent), "{absent} should be omitted");
779        }
780    }
781
782    #[test]
783    fn render_job_detail_json_attaches_the_upstream_cross_link() {
784        let detail = JobDetail {
785            job: job_fixture(),
786            upstream_order: Some(upstream_order_row_fixture()),
787        };
788        let json = render_job_detail_json(&detail);
789        assert_eq!(json["kind"], "signer_relay_issue");
790        assert_eq!(json["upstreamOrder"]["status"], "invalid");
791
792        let bare = JobDetail {
793            job: job_fixture(),
794            upstream_order: None,
795        };
796        assert!(
797            !render_job_detail_json(&bare)
798                .as_object()
799                .unwrap()
800                .contains_key("upstreamOrder")
801        );
802    }
803
804    #[test]
805    fn render_upstream_order_detail_json_attaches_the_job() {
806        let detail = UpstreamOrderDetail {
807            upstream_order: upstream_order_row_fixture(),
808            job: Some(job_fixture()),
809        };
810        let json = render_upstream_order_detail_json(&detail);
811        assert_eq!(json["status"], "invalid");
812        assert_eq!(json["job"]["kind"], "signer_relay_issue");
813        assert!(!json.to_string().contains("csrDer"));
814    }
815}