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_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());
}