crates/gunnar-server/tests/account_purge_rpc.rs
//! **The whole `Accounts` surface against a real `CoreControlPlane`.**
//!
//! `account_purge.rs` proves the walk empties a disk and `purge_queue.rs`
//! proves the queue always calls the unlock. Neither of them touches the
//! identity table, so neither can see the property the two callers actually
//! depend on: **that the user is locked out before `Purge` returns, and let
//! back in when it is over** — with the real `AuthTable::may` answering, not a
//! closure standing in for it.
//!
//! Every assertion here is therefore on `may()` — the same function the SSH
//! listener asks on every push and the gRPC gate asks on every RPC — rather
//! than on a gate row being present. A row that existed and did not refuse
//! anybody would pass a presence check and fail a customer.
use std::sync::Arc;
use std::time::{Duration, Instant};
use gunnar_core::{Level, NewRepo, Principal, PrincipalKind, RepoRef};
use gunnar_grpc::seam::{ControlPlane, PurgeAccountOptions, PurgeState};
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
/// A plane over a fresh disk, with `alice` registered and holding one
/// repository with real bytes in it.
fn fixture() -> (tempfile::TempDir, Arc<ServerStores>, CoreControlPlane) {
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
stores
.auth()
.add_principal(Principal::new("alice", "alice", PrincipalKind::User).expect("legal id"))
.expect("principal");
stores.catalog().create_namespace("alice").expect("ns");
let repo = stores
.catalog()
.create(NewRepo::new(
RepoRef::new("alice", "widget").expect("legal"),
))
.expect("create");
let handle = stores.repo_store(&repo).expect("open");
let git_dir = handle.git_dir().to_path_buf();
drop(handle);
std::fs::write(git_dir.join("fixture.bin"), vec![7u8; 1 << 20]).expect("fixture");
let plane = CoreControlPlane::new(Arc::clone(&stores));
(dir, stores, plane)
}
/// May alice touch her own repository right now, as the SSH listener would ask?
fn alice_may(stores: &ServerStores, level: Level) -> bool {
let repo = stores
.catalog()
.get(&RepoRef::new("alice", "widget").expect("legal"))
.expect("read");
match repo {
Some(repo) => stores.auth().may("alice", &repo, level).expect("acl"),
// The repository is gone, which after a purge is the point. The ACL
// question then has no subject; answered `false` so a caller of this
// helper cannot read "the repo is gone" as "she is allowed".
None => false,
}
}
fn wait_until(what: &str, mut cond: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if cond() {
return;
}
std::thread::sleep(Duration::from_millis(5));
}
panic!("timed out waiting for {what}");
}
/// **Gate, then ungate, through the plane — asserted on `may`.**
#[test]
fn gate_and_ungate_change_what_the_acl_answers() {
let (_dir, stores, plane) = fixture();
assert!(alice_may(&stores, Level::Write), "alice owns her account");
let (gate, already) = plane
.gate_account("alice", "operator", "an unpaid invoice")
.expect("gate");
assert!(!already, "the account was not gated before");
assert_eq!(gate.account, "alice");
assert_eq!(gate.note, "an unpaid invoice");
assert_eq!(
gate.expires_unix_ms, 0,
"an administrative gate must carry no deadline; a suspension that timed out on its own \
would be a non-paying account quietly coming back"
);
for level in [Level::Read, Level::Write, Level::Admin] {
assert!(
!alice_may(&stores, level),
"a gated principal must be refused {level:?} — including READ, because a fetch \
explodes the read cache and writes an index"
);
}
// Idempotent, and it says the account was already shut.
let (_, already) = plane
.gate_account("alice", "operator", "still unpaid")
.expect("gate twice");
assert!(
already,
"the second gate must report that it was already gated"
);
assert_eq!(
plane.account_gates().expect("list").len(),
1,
"the listing must show the one account that is locked"
);
let lifted = plane.ungate_account("alice").expect("ungate");
assert!(lifted.is_some());
assert!(
alice_may(&stores, Level::Write),
"lifting the gate must give the account back"
);
// Ungating an account that is not gated is a success and not a not-found:
// the caller asked for it to be open and it is open.
assert!(plane
.ungate_account("alice")
.expect("ungate twice")
.is_none());
assert!(plane.account_gates().expect("list").is_empty());
}
/// **The headline, end to end: locked before `Purge` returns, and let back in
/// when it is done.**
///
/// The first assertion is made *immediately* after the call returns and before
/// anything is waited on. That ordering is the test: a purge that gated from
/// inside the worker would have a window — however short — in which the request
/// had been accepted and the user could still push into the account it was
/// about to walk.
#[test]
fn a_purge_locks_the_account_before_it_returns_and_unlocks_it_when_it_is_done() {
let (dir, stores, plane) = fixture();
assert!(alice_may(&stores, Level::Write));
let (job, gate) = plane
.purge_account(
"alice",
PurgeAccountOptions {
principal_id: "operator".into(),
reason: "the customer cancelled".into(),
remove_principal: false,
expect_empty: false,
},
)
.expect("purge accepted");
assert!(
!alice_may(&stores, Level::Write),
"the account must be locked by the time Purge RETURNS; gating inside the worker would \
leave a window in which an accepted purge is still taking pushes"
);
assert!(
!alice_may(&stores, Level::Read),
"and reads are locked too — they are what re-explodes the cache behind the walk"
);
assert_eq!(
gate.holder, job.job_id,
"the lease must be held by this job"
);
assert!(
gate.expires_unix_ms > gunnar_core::now_ms(),
"a purge's gate must be a LEASE with a live deadline, so a crash hands the account back"
);
wait_until("the purge to finish", || {
plane
.purge_account_status("alice")
.expect("status")
.0
.is_some_and(|job| !matches!(job.state, PurgeState::Queued | PurgeState::Running))
});
let (settled, gate) = plane.purge_account_status("alice").expect("status");
let settled = settled.expect("the job is still held");
assert_eq!(settled.state, PurgeState::Done, "{settled:?}");
assert_eq!(settled.stores_found, 1, "{settled:?}");
assert_eq!(settled.stores_purged, 1, "{settled:?}");
assert!(settled.bytes_reclaimed >= 1 << 20, "{settled:?}");
assert!(
settled.gate_released,
"the lockout must be released when the purge is over: {settled:?}"
);
assert!(
gate.is_none(),
"and the ACL must agree — a job saying released beside a gate still in force would be \
the worst of both: {gate:?}"
);
// The disk, measured. The job's own numbers are the queue's word for it.
let account_dir = dir.path().join("repos").join("alice");
assert_eq!(
gunnar_server::stores::dir_bytes(&account_dir),
0,
"the account's directory still holds bytes"
);
// The principal was NOT removed, because it was not asked for — so she can
// be let back in to an empty account.
assert!(
stores.auth().principal("alice").expect("read").is_some(),
"remove_principal defaulted to false, so the account shell must survive"
);
assert!(
stores
.auth()
.may_namespace("alice", "alice", Level::Admin)
.expect("acl"),
"with the gate lifted and the principal kept, alice owns her (empty) account again"
);
}
/// **`remove_principal` takes the identity too — and its gate with it.**
///
/// The gate has to go in the same breath or the row would key a name nobody
/// holds, and the next principal created under it would be locked out of an
/// account it had never used.
#[test]
fn removing_the_principal_takes_the_lockout_with_it() {
let (_dir, stores, plane) = fixture();
plane
.purge_account(
"alice",
PurgeAccountOptions {
principal_id: "operator".into(),
reason: "the customer is gone".into(),
remove_principal: true,
expect_empty: false,
},
)
.expect("purge accepted");
wait_until("the purge to finish", || {
plane
.purge_account_status("alice")
.expect("status")
.0
.is_some_and(|job| !matches!(job.state, PurgeState::Queued | PurgeState::Running))
});
let (settled, gate) = plane.purge_account_status("alice").expect("status");
let settled = settled.expect("held");
assert_eq!(settled.state, PurgeState::Done, "{settled:?}");
assert!(settled.principal_removed, "{settled:?}");
assert!(
stores.auth().principal("alice").expect("read").is_none(),
"the principal must be gone"
);
assert!(gate.is_none(), "and so must its gate: {gate:?}");
// The name is reusable, and whoever takes it is NOT born locked out.
stores
.auth()
.add_principal(Principal::new("alice", "alice", PrincipalKind::User).expect("legal"))
.expect("the name is free again");
assert!(
stores
.auth()
.may_namespace("alice", "alice", Level::Admin)
.expect("acl"),
"a fresh principal must not inherit the purged one's lockout"
);
}
/// **A purge under an operator's suspension does not lift it.**
///
/// The composition rule. An administrative gate is never overwritten with a
/// lease — that would give a suspension an expiry it was deliberately created
/// without — and the job reports `gate_released: false` rather than claiming an
/// unlock it had no standing to make.
#[test]
fn a_purge_never_lifts_a_suspension_it_did_not_take() {
let (_dir, stores, plane) = fixture();
plane
.gate_account("alice", "operator", "under investigation")
.expect("suspend");
let (_job, gate) = plane
.purge_account(
"alice",
PurgeAccountOptions {
principal_id: "operator".into(),
reason: "and now delete the data".into(),
remove_principal: false,
expect_empty: false,
},
)
.expect("purge accepted");
assert_eq!(
gate.reason,
gunnar_grpc::seam::GateReason::Administrative,
"the gate in force is the operator's, not the purge's lease: {gate:?}"
);
assert_eq!(
gate.expires_unix_ms, 0,
"and it must NOT have acquired a deadline; a suspension that expired ten minutes after a \
purge finished would be an account silently coming back: {gate:?}"
);
wait_until("the purge to finish", || {
plane
.purge_account_status("alice")
.expect("status")
.0
.is_some_and(|job| !matches!(job.state, PurgeState::Queued | PurgeState::Running))
});
let (settled, gate) = plane.purge_account_status("alice").expect("status");
let settled = settled.expect("held");
assert_eq!(
settled.state,
PurgeState::Done,
"the purge did what it was asked; the suspension is not its failure: {settled:?}"
);
assert!(
!settled.gate_released,
"it must not claim an unlock it did not make: {settled:?}"
);
assert!(
gate.is_some(),
"the operator's suspension must still be standing"
);
assert!(
!alice_may(&stores, Level::Read),
"and the account must still be shut"
);
}
/// **The crash: a lease left behind by a purge that never finished is released
/// at start-up.**
///
/// The scenario a running process cannot test any other way — the purge is not
/// interrupted here, it is *simulated* by writing the row a dead one would have
/// left, because the alternative is killing the test binary. What is asserted
/// is the recovery, which is the half that matters: an operator restarting a
/// box finds the account open.
#[test]
fn a_lease_left_by_a_purge_that_died_is_released_at_start_up() {
let (_dir, stores, _plane) = fixture();
// What a SIGKILLed purge leaves: a live lease, held by a job id belonging
// to a process that is gone.
stores
.auth()
.gate(gunnar_core::AccountGate::purge_lease(
"alice",
"purge-999",
gunnar_core::now_ms() + gunnar_core::PURGE_LEASE_MS,
))
.expect("gate");
assert!(
!alice_may(&stores, Level::Read),
"the fixture must actually lock the account, or the recovery below proves nothing"
);
let released = CoreControlPlane::release_stale_purge_leases(&stores);
assert_eq!(released, 1, "the stale lease must have been released");
assert!(
alice_may(&stores, Level::Write),
"a restart must hand the account back rather than leave a customer waiting out a lease \
held by a purge that is not happening"
);
// And an ADMINISTRATIVE gate survives the same sweep. A restart must not
// un-suspend an account somebody suspended on purpose.
stores
.auth()
.gate(gunnar_core::AccountGate::administrative(
"alice",
"operator",
"non-payment",
))
.expect("suspend");
assert_eq!(
CoreControlPlane::release_stale_purge_leases(&stores),
0,
"the sweep must not touch an administrative gate"
);
assert!(
!alice_may(&stores, Level::Read),
"the suspension must survive the restart"
);
}
/// A purge that leaves stores behind still unlocks the account — the same
/// property `purge_queue.rs` asserts against a closure, here against the real
/// identity table, because that is where an account gets bricked.
#[test]
#[cfg(unix)]
fn a_partial_purge_still_hands_the_account_back() {
use std::os::unix::fs::PermissionsExt;
let (dir, stores, plane) = fixture();
// A second repository in a nested namespace, whose parent directory is
// frozen so its store cannot be unlinked.
stores.catalog().create_namespace("alice/sub").expect("ns");
let repo = stores
.catalog()
.create(NewRepo::new(
RepoRef::new("alice/sub", "stuck").expect("legal"),
))
.expect("create");
let handle = stores.repo_store(&repo).expect("open");
let git_dir = handle.git_dir().to_path_buf();
drop(handle);
let guard = dir.path().join("repos").join("alice").join("sub");
let restore = std::fs::metadata(&guard).expect("metadata").permissions();
std::fs::set_permissions(&guard, std::fs::Permissions::from_mode(0o555)).expect("freeze");
plane
.purge_account(
"alice",
PurgeAccountOptions {
principal_id: "operator".into(),
reason: "a purge that will trip".into(),
remove_principal: false,
expect_empty: false,
},
)
.expect("purge accepted");
wait_until("the purge to finish", || {
plane
.purge_account_status("alice")
.expect("status")
.0
.is_some_and(|job| !matches!(job.state, PurgeState::Queued | PurgeState::Running))
});
let (settled, gate) = plane.purge_account_status("alice").expect("status");
std::fs::set_permissions(&guard, restore).expect("restore");
let settled = settled.expect("held");
if git_dir.exists() {
assert_eq!(
settled.state,
PurgeState::Failed,
"a purge that left a store on the disk must not read as done: {settled:?}"
);
assert!(
!settled.failures.is_empty(),
"and it must name what is left: {settled:?}"
);
assert!(
settled.gate_released,
"a FAILED purge must still hand the account back; anything else is a customer locked \
out of an account that still has their data in it: {settled:?}"
);
assert!(gate.is_none(), "the ACL must agree: {gate:?}");
assert!(
alice_may(&stores, Level::Admin)
|| stores
.auth()
.may_namespace("alice", "alice", Level::Admin)
.expect("acl"),
"alice must be able to use her account again"
);
} else {
eprintln!(
"skipped the partial-purge assertions: this process removed a store under a \
read-only parent, which means it is running as root"
);
}
}
/// ★ **g-nonpay: a MACHINE principal's account is never purged** (owner ruling
/// 2026-09-29: "machine principals ... are never touched"). monetize's nonpay
/// engine only ever asks about paying customers; this is the appliance's own
/// refusal behind it, so a wrong question cannot empty the twin's or the
/// billing principal's namespace. Nothing is locked and nothing is queued.
#[test]
fn a_machine_principals_account_is_refused_and_nothing_is_locked() {
let (_dir, stores, plane) = fixture();
for (id, kind) in [
("billing", PrincipalKind::Machine),
("deploybot", PrincipalKind::Deploy),
] {
stores
.auth()
.add_principal(Principal::new(id, id, kind).expect("legal id"))
.expect("principal");
let refused = plane
.purge_account(
id,
PurgeAccountOptions {
principal_id: "monetize".into(),
reason: "nonpay: lapsed".into(),
remove_principal: false,
expect_empty: false,
},
)
.expect_err("a machine account must not be purged");
let text = format!("{refused:?}");
assert!(
text.contains("machine") || text.contains("not a person"),
"the refusal names why: {text}"
);
assert!(
stores.auth().gate_covering(id).expect("read").is_none(),
"{id}: nothing was locked"
);
}
// A person is still purged exactly as before.
let (job, _gate) = plane
.purge_account(
"alice",
PurgeAccountOptions {
principal_id: "monetize".into(),
reason: "nonpay".into(),
remove_principal: false,
expect_empty: false,
},
)
.expect("a customer's account is purged");
assert!(!job.job_id.is_empty());
}