crates/gunnar-server/tests/account_purge.rs
//! **`Accounts.Purge`: everything a user owns, gone — and measured on disk.**
//!
//! `repo_purge.rs` proves one repository's bytes leave the filesystem. This is
//! the account-wide walk, and the reason it needs its own test is that the
//! per-repository verb was never the hard part: **finding everything** was.
//!
//! An account holds four classes of bytes, and each one was invisible to at
//! least one of gunnar's existing listings before 2026-08-30:
//!
//! 1. **live repositories** — in the catalog, `repo list` shows them;
//! 2. **soft-delete tombstones** — out of the catalog, still on the disk, still
//! on the bill; only `repo list-deleted` shows them;
//! 3. **orphan stores** — no catalog row and no tombstone. `repo list` cannot
//! see them, `list-deleted` cannot see them, and on the appliance
//! 2026-08-30 that was ~25-39 GB standing at `alice/linux` that no verb
//! could reach while `create` refused the name;
//! 4. **nested namespaces** — `alice/team` is a different namespace from
//! `alice` and lives inside `repos/alice/`.
//!
//! A purge that walked only (1) would report success with the account's disk
//! still full, which is the failure this file exists to make impossible. Every
//! assertion below is on **bytes measured off the filesystem** and on the
//! directory being gone, never on a return code.
use gunnar_core::{NewRepo, RepoRef};
use gunnar_server::account_purge::{purge_account, PurgeRequest};
use gunnar_server::stores::{dir_bytes, ServerStores};
/// A repository with `bytes` of fixture in it, opened so the engine lays its
/// files down. Returns its directory.
fn repo_with_bytes(
stores: &ServerStores,
ns: &str,
name: &str,
bytes: usize,
) -> std::path::PathBuf {
if stores.catalog().namespace(ns).expect("read").is_none() {
stores.catalog().create_namespace(ns).expect("namespace");
}
let path = RepoRef::new(ns, name).expect("legal");
let repo = stores.catalog().create(NewRepo::new(path)).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; bytes]).expect("fixture");
git_dir
}
/// Collect what a purge did, with no queue in the way.
fn run(stores: &ServerStores, account: &str) -> gunnar_server::account_purge::PurgeReport {
purge_account(
stores,
PurgeRequest {
account: account.to_owned(),
principal_id: "operator".to_owned(),
reason: "the customer cancelled".to_owned(),
remove_principal: false,
expect_empty: false,
},
&mut |_| {},
)
.expect("the purge ran")
}
/// **The headline.** Live repositories, a tombstone, an orphan and a nested
/// namespace all go, and the account's directory subtree is empty afterwards.
#[test]
fn a_purge_takes_live_repositories_tombstones_orphans_and_nested_namespaces() {
let dir = tempfile::tempdir().expect("tempdir");
let stores = ServerStores::open(dir.path()).expect("stores");
// (1) two live repositories.
let live_one = repo_with_bytes(&stores, "alice", "widget", 1 << 20);
let live_two = repo_with_bytes(&stores, "alice", "gadget", 1 << 20);
// (2) one soft-deleted: out of the catalog, bytes still on the disk.
let doomed = repo_with_bytes(&stores, "alice", "old", 1 << 20);
let doomed_path = RepoRef::new("alice", "old").expect("legal");
let was = stores.catalog().get(&doomed_path).expect("read");
stores
.catalog()
.delete(&doomed_path)
.expect("catalog delete");
stores
.soft_delete(
&doomed_path,
was.as_ref(),
"alice",
std::time::Duration::from_secs(30 * 86_400),
)
.expect("tombstone");
// (3) one orphan: a store directory with no row and no tombstone, exactly
// the class found stranded on the appliance on 2026-08-30.
let orphan = dir.path().join("repos").join("alice").join("stray.git");
std::fs::create_dir_all(&orphan).expect("orphan dir");
std::fs::write(orphan.join("fixture.bin"), vec![7u8; 1 << 20]).expect("orphan fixture");
// (4) a nested namespace inside the account's own directory.
let nested = repo_with_bytes(&stores, "alice/team", "shared", 1 << 20);
// Somebody else's account, right next door, and a NAME THAT SHARES A
// PREFIX: `alicehacks` is not `alice` and must survive untouched.
let neighbour = repo_with_bytes(&stores, "bob", "untouched", 1 << 20);
let prefix_twin = repo_with_bytes(&stores, "alicehacks", "untouched", 1 << 20);
let account_dir = dir.path().join("repos").join("alice");
let before = dir_bytes(&account_dir);
assert!(
before >= 5 << 20,
"measured {before} bytes before; five fixtures alone are 5 MiB"
);
let report = run(&stores, "alice");
assert_eq!(
report.stores_found, 5,
"the walk must find all five: two live, one tombstoned, one orphan, one nested — \
{report:?}"
);
assert_eq!(report.stores_purged, 5, "{report:?}");
assert!(report.failures.is_empty(), "{report:?}");
assert!(
report.bytes_reclaimed >= 5 << 20,
"reported {} bytes; the fixtures alone are 5 MiB — {report:?}",
report.bytes_reclaimed
);
for gone in [&live_one, &live_two, &doomed, &orphan, &nested] {
assert!(!gone.exists(), "{} is still on the disk", gone.display());
}
assert_eq!(
dir_bytes(&account_dir),
0,
"the account's directory subtree still holds bytes: {}",
account_dir.display()
);
// The catalog agrees with the disk.
assert!(
stores
.catalog()
.list(Some("alice"))
.expect("list")
.is_empty(),
"catalog rows survived the purge"
);
assert!(
stores.catalog().namespace("alice").expect("read").is_none(),
"the namespace row survived"
);
assert!(
stores
.catalog()
.namespace("alice/team")
.expect("read")
.is_none(),
"the nested namespace row survived, so its name is taken and its bytes are unaccounted"
);
// The neighbours are untouched. Without this the test is passed by a purge
// that deletes the whole disk.
assert!(neighbour.exists(), "bob's repository was taken too");
assert!(
prefix_twin.exists(),
"`alicehacks` merely starts with `alice` and must not be caught by a purge of it"
);
assert_eq!(stores.catalog().list(Some("bob")).expect("list").len(), 1);
assert_eq!(
stores
.catalog()
.list(Some("alicehacks"))
.expect("list")
.len(),
1
);
}
/// **A purge of an account with nothing in it is a success, not a not-found.**
///
/// monetize retries. An account already emptied by a previous run — or one that
/// never had anything — must answer "there is nothing here" rather than an
/// error a caller has to special-case, or the retry that finishes a partial
/// purge is indistinguishable from a mistake.
#[test]
fn purging_an_empty_account_is_a_success_that_reclaimed_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let stores = ServerStores::open(dir.path()).expect("stores");
stores.catalog().create_namespace("alice").expect("ns");
let report = run(&stores, "alice");
assert_eq!(report.stores_found, 0, "{report:?}");
assert_eq!(report.bytes_reclaimed, 0, "{report:?}");
assert!(report.failures.is_empty(), "{report:?}");
// And a purge of an account that does not exist at all is the same answer.
let report = run(&stores, "nobody");
assert_eq!(report.stores_found, 0, "{report:?}");
assert!(report.failures.is_empty(), "{report:?}");
}
/// **`expect_empty` refuses rather than destroys.** The read-only half, for a
/// caller that believes it has already emptied an account and wants the server
/// to check.
#[test]
fn expect_empty_refuses_an_account_that_still_holds_repositories() {
let dir = tempfile::tempdir().expect("tempdir");
let stores = ServerStores::open(dir.path()).expect("stores");
let kept = repo_with_bytes(&stores, "alice", "widget", 1 << 20);
let err = purge_account(
&stores,
PurgeRequest {
account: "alice".to_owned(),
principal_id: "operator".to_owned(),
reason: "a check, not a purge".to_owned(),
remove_principal: false,
expect_empty: true,
},
&mut |_| {},
)
.expect_err("an account with a repository in it must be refused");
let text = format!("{err:#}");
assert!(
text.contains("alice/widget"),
"the refusal must NAME what is in the way, or the operator has to go looking: {text}"
);
assert!(
kept.exists(),
"a refused purge must not have destroyed anything: {}",
kept.display()
);
assert!(stores
.catalog()
.get(&RepoRef::new("alice", "widget").unwrap())
.expect("read")
.is_some());
}
/// **A store that cannot be reclaimed is reported, and the walk goes on.**
///
/// One unreadable directory in an account of two hundred must not leave the
/// other hundred and ninety-nine on the disk. The failure is named so an
/// operator knows exactly what is left, and the purge as a whole FAILS so
/// nothing upstream records the account as finished.
#[test]
#[cfg(unix)]
fn one_unreclaimable_store_does_not_stop_the_others() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let stores = ServerStores::open(dir.path()).expect("stores");
let good = repo_with_bytes(&stores, "alice", "good", 1 << 20);
// In a NESTED namespace, so freezing its parent directory does not also
// freeze `good`'s. A read-only parent is what stops an unlink; a read-only
// directory can still have its own contents removed by its owner.
let bad = repo_with_bytes(&stores, "alice/sub", "bad", 1 << 20);
let guard = bad.parent().expect("parent").to_path_buf();
let restore = std::fs::metadata(&guard).expect("metadata").permissions();
std::fs::set_permissions(&guard, std::fs::Permissions::from_mode(0o555))
.expect("make the parent read-only");
let report = purge_account(
&stores,
PurgeRequest {
account: "alice".to_owned(),
principal_id: "operator".to_owned(),
reason: "a purge that will trip".to_owned(),
remove_principal: false,
expect_empty: false,
},
&mut |_| {},
);
std::fs::set_permissions(&guard, restore).expect("restore permissions");
// Running as root defeats the fixture — the unlink succeeds and there is
// nothing to observe. Say so rather than assert a green that means nothing.
if bad.exists() {
let report = report.expect("a purge with a failure still returns its report");
assert!(
!report.failures.is_empty(),
"the store could not be removed but nothing was reported: {report:?}"
);
assert!(
report.failures.iter().any(|f| f.contains("bad")),
"the failure must name the store that is still on the disk: {report:?}"
);
assert!(
!good.exists(),
"the walk stopped at the first failure and left {} behind",
good.display()
);
assert!(
report.stores_purged >= 1,
"the stores that DID go must be counted; reporting 0 would send an operator looking \
for bytes that are not there: {report:?}"
);
} else {
eprintln!(
"skipped the unreclaimable-store assertions: this process could remove a store under \
a read-only parent, which means it is running as root"
);
}
}