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")
);
}
}