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

nordisk/gunnar

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