Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-server/src/api/accounts.rs

//! **The per-USER verbs: lock a user out, and empty their account for good.**
//!
//! `repos.rs` reaches one repository at a time and its delete is deliberately
//! SOFT, because a console's delete button is one click and gunnar's tombstone
//! is what makes that click undoable. These are the opposite kind of verb and
//! the module says so out loud: a purge here reclaims tombstones rather than
//! making them, and there is no undelete afterwards.
//!
//! # Why they are surfaced at all, when `Repos.Purge` deliberately is not
//!
//! `repos.rs` shows orphan stores and offers no button, because `ReclaimOrphan`
//! is an operator's irreversible decision about objects somebody wrote and
//! belongs at the control plane. That argument holds for a *repository*, whose
//! reclaim is one CLI call away.
//!
//! It does not hold for an *account*. There was no per-user verb at all before
//! 2026-09-07: emptying one meant a loop over `repo delete --purge`, which
//! nothing serialised, nothing locked the user out during, and which could not
//! see the two classes of bytes that are not in the catalog — soft-delete
//! tombstones and orphan stores. On a box whose PID 1 is gunnar and which has
//! no shell, "at the control plane" means *nowhere a human can reach*. That is
//! the same gap the gc button closed, and it is why these are here.
//!
//! What keeps them safe is not hiding them. It is the role floor
//! (`/api/accounts` is an ADMIN surface — see `auth::middleware::role_floor`)
//! and a purge that will not run without the account's own name typed back,
//! checked HERE rather than only in the page: a fetch from a console tab is one
//! line, so a guard that lived in the Leptos component would protect the button
//! and not the verb.
//!
//! **Not `require_step_up`**, which `auth/routes.rs` puts on its own sensitive
//! routes. That ceremony re-proves the passkey, and it is the right shape for a
//! change to the credentials themselves — where the threat is a borrowed
//! session. Here the caller already holds `admin`, which on this console means
//! they may add and revoke keys; a purge is not more powerful than that, and a
//! second ceremony in front of it would train operators to click through the
//! ceremony rather than to read the sentence.
//!
//! # The gate is its own surface, not a flag on the purge
//!
//! Both callers need it alone: an operator freezing an account under
//! investigation, monetize locking a non-paying customer out. So `POST
//! /api/accounts/{account}/gate` stands on its own and the purge USES it.

use axum::extract::{Path, State};
use axum::Json;
use serde::{Deserialize, Serialize};

use crate::api::{ApiError, ApiResult};
use crate::app::AppState;
use crate::pb;

/// One account lockout, as the console shows it.
#[derive(Debug, Clone, Serialize)]
pub struct Gate {
    /// The account: a user's name, which is also their namespace.
    pub account: String,
    /// `administrative` | `purge` | `unknown`.
    pub reason: &'static str,
    /// A principal id for a person, a job id for a purge.
    pub holder: String,
    /// Who asked, for the audit trail.
    pub actor: String,
    /// What the operator or the purge wrote. Shown to the locked-out user in
    /// every refusal, so it is shown here too — an operator must be able to see
    /// exactly what their customer is reading.
    pub note: String,
    pub at_unix_ms: Option<i64>,
    /// **`None` means no deadline** — an administrative lockout, in force until
    /// somebody lifts it. `Some` is a purge's lease, which is what stops a purge
    /// that died from locking an account for ever.
    pub expires_unix_ms: Option<i64>,
}

fn gate_reason(reason: i32) -> &'static str {
    match pb::GateReason::try_from(reason) {
        Ok(pb::GateReason::Administrative) => "administrative",
        Ok(pb::GateReason::Purge) => "purge",
        // A value this build does not know is not "administrative". Rendering
        // it as one would be a claim about whether it expires.
        _ => "unknown",
    }
}

fn render_gate(gate: pb::AccountGate) -> Gate {
    Gate {
        account: gate.account,
        reason: gate_reason(gate.reason),
        holder: gate.holder,
        actor: gate.actor,
        note: gate.note,
        at_unix_ms: crate::api::recorded(gate.at_unix_ms),
        expires_unix_ms: crate::api::recorded(gate.expires_unix_ms),
    }
}

/// One account purge, as the console shows it.
#[derive(Debug, Clone, Serialize)]
pub struct PurgeJob {
    pub job_id: String,
    pub account: String,
    /// `queued` | `running` | `done` | `failed` | `unknown`.
    pub state: &'static str,
    pub queue_position: u32,
    /// Stores the walk found: repositories, tombstones and orphan stores
    /// together. **0 until the job starts** — gunnar counts when its turn comes,
    /// not when it is enqueued.
    pub stores_found: u32,
    pub stores_purged: u32,
    /// Bytes that left the filesystem. Reported at every state, unlike a gc's,
    /// because a purge that failed halfway still freed what it freed and an
    /// operator needs to know how much of the account is gone.
    pub bytes_reclaimed: u64,
    pub principal_removed: bool,
    /// **Whether the lockout is off.** The field that decides whether the
    /// customer can use their account, so it is on the wire and on the screen
    /// rather than inferred from `state`.
    pub gate_released: bool,
    pub error: Option<String>,
    /// One line per store still on the disk. The recovery list; never
    /// truncated.
    pub failures: Vec<String>,
    pub enqueued_at_unix_ms: Option<i64>,
    pub started_at_unix_ms: Option<i64>,
    pub finished_at_unix_ms: Option<i64>,
}

fn purge_state(state: i32) -> &'static str {
    match pb::PurgeState::try_from(state) {
        Ok(pb::PurgeState::Queued) => "queued",
        Ok(pb::PurgeState::Running) => "running",
        Ok(pb::PurgeState::Done) => "done",
        Ok(pb::PurgeState::Failed) => "failed",
        // Not `queued`: a state this build does not recognise must not make the
        // page poll for ever.
        _ => "unknown",
    }
}

fn render_job(job: pb::PurgeJob) -> PurgeJob {
    PurgeJob {
        job_id: job.job_id,
        account: job.account,
        state: purge_state(job.state),
        queue_position: job.queue_position,
        stores_found: job.stores_found,
        stores_purged: job.stores_purged,
        bytes_reclaimed: job.bytes_reclaimed,
        principal_removed: job.principal_removed,
        gate_released: job.gate_released,
        error: (!job.error.is_empty()).then(|| job.error.clone()),
        failures: job.failures,
        enqueued_at_unix_ms: crate::api::recorded(job.enqueued_at_unix_ms),
        started_at_unix_ms: crate::api::recorded(job.started_at_unix_ms),
        finished_at_unix_ms: crate::api::recorded(job.finished_at_unix_ms),
    }
}

/// `GET /api/accounts/gates` — who is locked out.
///
/// **Asked as the reader** (thin layer, owner 2026-10-04): gunnar answers
/// `ListAccountGates` to a server-wide admin only, and a lockout note is about
/// a person — a tenant's session is refused by gunnar on their own key, not
/// handed every other customer's note by the console's.
pub async fn list_gates(reader: crate::api::Reader) -> ApiResult<Vec<Gate>> {
    let gates = reader
        .gunnar
        .accounts()
        .list_account_gates(pb::ListAccountGatesRequest {})
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .gates;
    Ok(Json(gates.into_iter().map(render_gate).collect()))
}

/// What the console sends to lock a user out.
#[derive(Debug, Clone, Deserialize)]
pub struct NewGate {
    /// Shown to the locked-out user. gunnar refuses an empty one with
    /// `INVALID_ARGUMENT`, and the console does not paper over that with a
    /// default: a lockout nobody can explain is worse than a form that made
    /// somebody type a sentence.
    #[serde(default)]
    pub note: String,
}

/// What a gate request answered.
#[derive(Debug, Clone, Serialize)]
pub struct Gated {
    pub gate: Gate,
    /// True when the account was ALREADY locked. Not an error, but the console
    /// says so — an operator must not be shown somebody else's older lockout as
    /// if it were the one they just made.
    pub was_already_gated: bool,
}

/// `POST /api/accounts/{account}/gate` — lock a user out of everything.
pub async fn gate(
    actor: crate::api::Actor,
    State(state): State<AppState>,
    Path(account): Path<String>,
    Json(body): Json<NewGate>,
) -> ApiResult<Gated> {
    let answer = actor
        .gunnar
        .accounts()
        .gate(pb::GateAccountRequest {
            account,
            note: body.note,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    // The identity cache holds principals, keys and grants, none of which a
    // gate changes — but every one of them is rendered on the page that shows
    // this, and a stale row beside a fresh lockout is the kind of half-updated
    // screen that gets a second button pressed.
    state.caches.invalidate_access().await;
    Ok(Json(Gated {
        gate: answer.gate.map(render_gate).ok_or_else(|| {
            ApiError(crate::grpc::Fail {
                code: "internal".to_owned(),
                message: "the server locked the account and reported no gate".to_owned(),
            })
        })?,
        was_already_gated: answer.was_already_gated,
    }))
}

/// What an ungate answered.
#[derive(Debug, Clone, Serialize)]
pub struct Ungated {
    /// What was lifted. `None` when the account was not locked, which is a
    /// SUCCESS: the caller asked for the account to be open and it is open.
    pub lifted: Option<Gate>,
}

/// `DELETE /api/accounts/{account}/gate` — let them back in.
pub async fn ungate(
    actor: crate::api::Actor,
    State(state): State<AppState>,
    Path(account): Path<String>,
) -> ApiResult<Ungated> {
    let lifted = actor
        .gunnar
        .accounts()
        .ungate(pb::UngateAccountRequest { account })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .lifted;
    state.caches.invalidate_access().await;
    Ok(Json(Ungated {
        lifted: lifted.map(render_gate),
    }))
}

/// What the console sends to purge an account.
#[derive(Debug, Clone, Deserialize)]
pub struct NewPurge {
    /// Why. Required by gunnar; carried into the lockout the user reads.
    #[serde(default)]
    pub reason: String,
    /// **The account's own name, typed back.** Checked HERE, before the call.
    ///
    /// Not a `window.confirm`, which this console does not use anywhere, and
    /// not a server-side rule of gunnar's — gunnar has `--yes` for the CLI, and
    /// a browser has no CLI. It is a BFF check because the BFF is the only
    /// place a browser cannot skip: a fetch from a console tab is one line, and
    /// a guard that lived only in the Leptos page would be a guard that
    /// protects the button rather than the verb.
    #[serde(default)]
    pub confirm: String,
    /// Also remove the user: keys, grants, tokens, memberships.
    #[serde(default)]
    pub remove_principal: bool,
}

/// What a purge request answered.
#[derive(Debug, Clone, Serialize)]
pub struct Purging {
    pub job: PurgeJob,
    /// **The lockout, already in force.** gunnar takes it before it answers, so
    /// this is a fact by the time the console renders it while `job` is still a
    /// promise. The page shows it first for that reason.
    pub gate: Option<Gate>,
}

/// `POST /api/accounts/{account}/purge` — everything the user owns, gone.
pub async fn purge(
    actor: crate::api::Actor,
    State(state): State<AppState>,
    Path(account): Path<String>,
    Json(body): Json<NewPurge>,
) -> ApiResult<Purging> {
    if body.confirm.trim() != account {
        // 400 and a sentence, not a 403: nothing about the caller's standing is
        // wrong, the request simply does not say what it must say.
        return Err(ApiError(crate::grpc::Fail {
            code: "invalidargument".to_owned(),
            message: format!(
                "type the account's name ({account}) to confirm. This purges every repository, \
                 every soft-deleted one still holding bytes and every orphan store in the \
                 account; there is no retention window and no undelete afterwards."
            ),
        }));
    }
    let answer = actor
        .gunnar
        .accounts()
        .purge(pb::PurgeAccountRequest {
            account,
            reason: body.reason,
            remove_principal: body.remove_principal,
            // Never from the console. `expect_empty` is a caller saying "check
            // my work, destroy nothing", which is a script's shape and not a
            // button's — and a button that sometimes purged and sometimes only
            // reported would be two buttons wearing one label.
            expect_empty: false,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    state.caches.invalidate_repos().await;
    state.caches.invalidate_access().await;
    let job = answer.job.map(render_job).ok_or_else(|| {
        ApiError(crate::grpc::Fail {
            code: "internal".to_owned(),
            message: "the server accepted the purge but reported no job".to_owned(),
        })
    })?;
    Ok(Json(Purging {
        job,
        gate: answer.gate.map(render_gate),
    }))
}

/// What a status read answered.
#[derive(Debug, Clone, Serialize)]
pub struct PurgeStatus {
    /// `None` when the server is holding no job for this account: none was ever
    /// asked for, or the last finished long enough ago to have been dropped.
    /// Distinct from a purge that reclaimed nothing, which comes back as a
    /// `done` job saying so.
    pub job: Option<PurgeJob>,
    /// The lockout right now. `None` means the account is open.
    pub gate: Option<Gate>,
}

/// `GET /api/accounts/{account}/purge` — where it got to, and whether the
/// lockout is off.
///
/// Both in one response, because neither answers the operator's question
/// alone: a `done` job beside a gate still in force means the account is empty
/// and still shut, which is either a suspension the purge correctly refused to
/// lift or a lease nothing released.
///
/// Asked as the reader: whose purge it is and how far it got is gunnar's to
/// tell, to whoever gunnar says may know.
pub async fn purge_status(
    reader: crate::api::Reader,
    Path(account): Path<String>,
) -> ApiResult<PurgeStatus> {
    let answer = reader
        .gunnar
        .accounts()
        .purge_status(pb::PurgeAccountStatusRequest { account })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    Ok(Json(PurgeStatus {
        job: answer.job.map(render_job),
        gate: answer.gate.map(render_gate),
    }))
}

#[cfg(test)]
mod tests {
    use std::sync::{Arc, Mutex};

    use tower::ServiceExt as _;

    use super::*;
    use crate::pb::accounts_server::{Accounts as AccountsService, AccountsServer};

    /// A gunnar that records every purge it was asked for.
    ///
    /// The recording is the point: the guard this file adds refuses **before**
    /// the RPC, and the only way to prove a refusal did not reach gunnar is to
    /// ask gunnar what it heard. An assertion on the 400 alone would be green
    /// against a BFF that purged the account and then returned an error.
    #[derive(Clone, Default)]
    struct FakeGunnar {
        purges: Arc<Mutex<Vec<pb::PurgeAccountRequest>>>,
        gates: Arc<Mutex<Vec<pb::GateAccountRequest>>>,
    }

    #[tonic::async_trait]
    impl AccountsService for FakeGunnar {
        async fn gate(
            &self,
            request: tonic::Request<pb::GateAccountRequest>,
        ) -> Result<tonic::Response<pb::GateAccountResponse>, tonic::Status> {
            let request = request.into_inner();
            if request.note.trim().is_empty() {
                // gunnar's own refusal, reproduced: the console must not paper
                // over it with a default note.
                return Err(tonic::Status::invalid_argument("note is required"));
            }
            self.gates.lock().unwrap().push(request.clone());
            Ok(tonic::Response::new(pb::GateAccountResponse {
                gate: Some(pb::AccountGate {
                    account: request.account,
                    reason: pb::GateReason::Administrative as i32,
                    holder: "administrative".into(),
                    actor: "gunnar-ui".into(),
                    note: request.note,
                    at_unix_ms: 1_700_000_000_000,
                    expires_unix_ms: 0,
                }),
                was_already_gated: false,
            }))
        }

        async fn ungate(
            &self,
            _request: tonic::Request<pb::UngateAccountRequest>,
        ) -> Result<tonic::Response<pb::UngateAccountResponse>, tonic::Status> {
            Ok(tonic::Response::new(pb::UngateAccountResponse {
                lifted: None,
            }))
        }

        async fn list_account_gates(
            &self,
            _request: tonic::Request<pb::ListAccountGatesRequest>,
        ) -> Result<tonic::Response<pb::ListAccountGatesResponse>, tonic::Status> {
            Ok(tonic::Response::new(pb::ListAccountGatesResponse {
                gates: vec![pb::AccountGate {
                    account: "alice".into(),
                    reason: pb::GateReason::Purge as i32,
                    holder: "purge-4".into(),
                    actor: "operator".into(),
                    note: "the customer cancelled".into(),
                    at_unix_ms: 1_700_000_000_000,
                    expires_unix_ms: 1_700_000_600_000,
                }],
            }))
        }

        async fn purge(
            &self,
            request: tonic::Request<pb::PurgeAccountRequest>,
        ) -> Result<tonic::Response<pb::PurgeAccountResponse>, tonic::Status> {
            let request = request.into_inner();
            self.purges.lock().unwrap().push(request.clone());
            Ok(tonic::Response::new(pb::PurgeAccountResponse {
                job: Some(pb::PurgeJob {
                    job_id: "purge-1".into(),
                    account: request.account.clone(),
                    state: pb::PurgeState::Queued as i32,
                    queue_position: 0,
                    stores_found: 0,
                    stores_purged: 0,
                    bytes_reclaimed: 0,
                    principal_removed: false,
                    gate_released: false,
                    error: String::new(),
                    failures: Vec::new(),
                    enqueued_at_unix_ms: 1_700_000_000_000,
                    started_at_unix_ms: 0,
                    finished_at_unix_ms: 0,
                }),
                gate: Some(pb::AccountGate {
                    account: request.account,
                    reason: pb::GateReason::Purge as i32,
                    holder: "purge-1".into(),
                    actor: "gunnar-ui".into(),
                    note: "this account is being permanently deleted".into(),
                    at_unix_ms: 1_700_000_000_000,
                    expires_unix_ms: 1_700_000_600_000,
                }),
            }))
        }

        async fn purge_status(
            &self,
            _request: tonic::Request<pb::PurgeAccountStatusRequest>,
        ) -> Result<tonic::Response<pb::PurgeAccountStatusResponse>, tonic::Status> {
            Ok(tonic::Response::new(pb::PurgeAccountStatusResponse {
                job: None,
                gate: None,
            }))
        }
    }

    /// Stand a gunnar up on loopback and point a console at it.
    async fn console(fake: FakeGunnar) -> axum::Router {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("a loopback port");
        let addr = listener.local_addr().expect("its address");
        tokio::spawn(async move {
            tonic::transport::Server::builder()
                .add_service(AccountsServer::new(fake))
                .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
                .await
        });
        let signer = Arc::new(
            crate::signer::Signer::new(ssh_key::PrivateKey::from(
                ssh_key::private::Ed25519Keypair::from_seed(&[7u8; 32]),
            ))
            .expect("a fixed seed makes a signer"),
        );
        let endpoint = format!("http://{addr}");
        let gunnar = crate::grpc::Gunnar::connect_lazy(&endpoint, signer).expect("a lazy channel");
        let store = crate::state::StateStore::open(
            tempfile::Builder::new()
                .suffix(".redb")
                .tempfile()
                .expect("a temp file")
                .into_temp_path()
                .keep()
                .expect("keep the path"),
        )
        .expect("a state store");
        crate::serve::router(Arc::new(crate::app::App {
            webauthn: None,
            #[cfg(feature = "metered")]
            monetize: None,
            admin_token: None,
            events: crate::sse::EventHub::detached(),
            gunnar,
            // No guest identity: these tests predate the anonymous surface and
            // are about a logged-in console.
            twin: None,
            guest: None,
            reviewers: Arc::new(crate::reviewer::ReviewerKeys::new(
                endpoint.clone(),
                store.clone(),
            )),
            tenants: Arc::new(crate::tenant::TenantKeys::new(endpoint, store)),
            forges: Default::default(),
            imports: Default::default(),
            forge_apps: Default::default(),
            ui_dir: None,
            caches: crate::api::Caches::default(),
            objects: Default::default(),
            readmes: Default::default(),
        }))
    }

    async fn send(
        app: &axum::Router,
        method: &str,
        uri: &str,
        body: serde_json::Value,
    ) -> (http::StatusCode, String) {
        let response = app
            .clone()
            .oneshot(
                http::Request::builder()
                    .method(method)
                    .uri(uri)
                    .header("content-type", "application/json")
                    .body(axum::body::Body::from(body.to_string()))
                    .expect("a request"),
            )
            .await
            .expect("the console answers");
        let status = response.status();
        let bytes = axum::body::to_bytes(response.into_body(), 1 << 20)
            .await
            .expect("a readable body");
        (status, String::from_utf8_lossy(&bytes).into_owned())
    }

    /// ★ **The confirmation is a GUARD, not a form validation.**
    ///
    /// A fetch from a console tab is one line, so a check that lived only in the
    /// Leptos page would protect the button and not the verb. This asserts on
    /// what gunnar HEARD: with the wrong name typed, the purge must not reach
    /// it at all.
    ///
    /// Seen red with the guard removed: the request came back 200 and
    /// `purges.len()` was 1 — an account emptied by a form somebody filled in
    /// wrongly.
    #[tokio::test]
    async fn a_purge_without_the_account_name_typed_back_never_reaches_gunnar() {
        let fake = FakeGunnar::default();
        let app = console(fake.clone()).await;

        let (status, body) = send(
            &app,
            "POST",
            "/api/accounts/alice/purge",
            serde_json::json!({ "reason": "cancelled", "confirm": "" }),
        )
        .await;
        assert_eq!(status, http::StatusCode::BAD_REQUEST, "{body}");
        assert!(
            body.contains("alice"),
            "the refusal must say WHICH name to type: {body}"
        );
        assert!(
            fake.purges.lock().unwrap().is_empty(),
            "the purge reached gunnar despite the confirmation being wrong; the guard protects \
             the button rather than the verb"
        );

        // A name that is merely close is still wrong.
        let (status, _) = send(
            &app,
            "POST",
            "/api/accounts/alice/purge",
            serde_json::json!({ "reason": "cancelled", "confirm": "alicehacks" }),
        )
        .await;
        assert_eq!(status, http::StatusCode::BAD_REQUEST);
        assert!(fake.purges.lock().unwrap().is_empty());

        // …and the right one goes through, or the assertions above would be
        // satisfied by a handler that refuses everything.
        let (status, body) = send(
            &app,
            "POST",
            "/api/accounts/alice/purge",
            serde_json::json!({
                "reason": "the customer cancelled",
                "confirm": "alice",
                "remove_principal": true,
            }),
        )
        .await;
        assert_eq!(status, http::StatusCode::OK, "{body}");
        let heard = fake.purges.lock().unwrap().clone();
        assert_eq!(heard.len(), 1);
        assert_eq!(heard[0].account, "alice");
        assert_eq!(heard[0].reason, "the customer cancelled");
        assert!(heard[0].remove_principal);
        assert!(
            !heard[0].expect_empty,
            "the console never sends expect_empty: a button that sometimes purged and sometimes \
             only reported would be two buttons wearing one label"
        );

        // The response carries the LOCKOUT beside the job, and the lockout is
        // already in force — that is what a caller reads to know the user
        // cannot push while the walk is queued.
        let parsed: serde_json::Value = serde_json::from_str(&body).expect("json");
        assert_eq!(parsed["job"]["state"], "queued", "{body}");
        assert_eq!(parsed["gate"]["reason"], "purge", "{body}");
        assert_eq!(parsed["gate"]["account"], "alice", "{body}");
    }

    /// ★ gunnar's refusal of an empty note reaches the browser as gunnar's own
    /// words, and the console does not invent a default note to get past it.
    #[tokio::test]
    async fn a_lockout_with_no_reason_is_refused_in_gunnars_own_words() {
        let fake = FakeGunnar::default();
        let app = console(fake.clone()).await;

        let (status, body) = send(
            &app,
            "POST",
            "/api/accounts/alice/gate",
            serde_json::json!({ "note": "   " }),
        )
        .await;
        assert_eq!(status, http::StatusCode::BAD_REQUEST, "{body}");
        assert!(
            body.contains("note is required"),
            "gunnar's sentence must survive to the browser: {body}"
        );
        assert!(
            fake.gates.lock().unwrap().is_empty(),
            "nothing was locked, which is the point of the refusal"
        );

        let (status, body) = send(
            &app,
            "POST",
            "/api/accounts/alice/gate",
            serde_json::json!({ "note": "an unpaid invoice" }),
        )
        .await;
        assert_eq!(status, http::StatusCode::OK, "{body}");
        let parsed: serde_json::Value = serde_json::from_str(&body).expect("json");
        // Asserted by FIELD NAME, because the browser sees names.
        assert_eq!(parsed["gate"]["note"], "an unpaid invoice", "{body}");
        assert_eq!(parsed["gate"]["reason"], "administrative", "{body}");
        assert!(
            parsed["gate"]["expires_unix_ms"].is_null(),
            "an administrative lockout has no deadline, and `null` is how this console spells \
             that — a 0 would render as an instant in 1970: {body}"
        );
        assert_eq!(parsed["was_already_gated"], false, "{body}");
    }

    /// An administrative lockout has **no** deadline and a purge lease has one,
    /// and the console must be able to tell them apart — that difference is the
    /// whole reason a crashed purge does not brick an account.
    #[test]
    fn a_gate_with_no_deadline_renders_as_none_and_a_lease_renders_as_some() {
        let administrative = render_gate(pb::AccountGate {
            account: "alice".into(),
            reason: pb::GateReason::Administrative as i32,
            holder: "administrative".into(),
            actor: "operator".into(),
            note: "non-payment".into(),
            at_unix_ms: 1_700_000_000_000,
            expires_unix_ms: 0,
        });
        assert_eq!(administrative.reason, "administrative");
        assert_eq!(
            administrative.expires_unix_ms, None,
            "0 on the wire means no deadline; rendering it as an instant in 1970 would show a \
             suspension as long expired"
        );

        let lease = render_gate(pb::AccountGate {
            account: "alice".into(),
            reason: pb::GateReason::Purge as i32,
            holder: "purge-3".into(),
            actor: "operator".into(),
            note: "the customer cancelled".into(),
            at_unix_ms: 1_700_000_000_000,
            expires_unix_ms: 1_700_000_600_000,
        });
        assert_eq!(lease.reason, "purge");
        assert_eq!(lease.expires_unix_ms, Some(1_700_000_600_000));
    }

    /// An unknown enum value renders as `unknown`, never as a value this build
    /// happens to know. Both directions, because a renderer that answered
    /// `unknown` for everything would pass the half above.
    #[test]
    fn an_unrecognised_reason_or_state_is_not_guessed_at() {
        assert_eq!(gate_reason(97), "unknown");
        assert_eq!(gate_reason(pb::GateReason::Purge as i32), "purge");
        assert_eq!(purge_state(97), "unknown");
        assert_eq!(purge_state(pb::PurgeState::Running as i32), "running");
        assert_eq!(
            purge_state(pb::PurgeState::Unspecified as i32),
            "unknown",
            "UNSPECIFIED is not a state a job is ever in; treating it as `queued` would make a \
             console poll for ever"
        );
    }

    /// A job that is not `done` still reports the bytes it freed — unlike a gc,
    /// whose `bytes_reclaimed` is withheld until DONE.
    ///
    /// The two are different on purpose. A gc's zero on a running job would be
    /// "freed nothing", a measurement it has not made; a purge's non-zero on a
    /// FAILED job is data that is genuinely gone, and hiding it would leave an
    /// operator believing an account they must now clean up by hand is intact.
    #[test]
    fn a_failed_purge_still_reports_what_it_reclaimed() {
        let job = render_job(pb::PurgeJob {
            job_id: "purge-1".into(),
            account: "alice".into(),
            state: pb::PurgeState::Failed as i32,
            queue_position: 0,
            stores_found: 3,
            stores_purged: 2,
            bytes_reclaimed: 4096,
            principal_removed: false,
            gate_released: true,
            error: "1 of 3 stores could not be reclaimed".into(),
            failures: vec!["alice/two: Permission denied".into()],
            enqueued_at_unix_ms: 1,
            started_at_unix_ms: 2,
            finished_at_unix_ms: 3,
        });
        assert_eq!(job.state, "failed");
        assert_eq!(job.bytes_reclaimed, 4096);
        assert_eq!(job.stores_purged, 2);
        assert!(
            job.gate_released,
            "a failed purge that released the lockout must say so; that is the field that says \
             whether the customer can use the account"
        );
        assert_eq!(job.failures.len(), 1, "the recovery list is never dropped");
        assert_eq!(
            job.error.as_deref(),
            Some("1 of 3 stores could not be reclaimed")
        );
    }
}