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

nordisk/gunnar

crates/gunnar-cli/tests/soft_delete_on_the_estate.rs

#![cfg(feature = "server")]

//! **A soft delete on the estate's shape: a primary that serves, a twin that follows its log.**
//!
//! gunnar.rs 2026-10-28: a repository the owner deleted still answered `git ls-remote` over
//! SSH. `soft_delete_stops_serving.rs` holds the single-server half (green). This is the
//! estate half, in-process with the code the boxes run: the primary's real SSH (and smart
//! HTTP) listener and control plane, and a twin that is a [`Follower`] of the primary's log
//! — the twin of `gunnar-front`'s estate. The front only forwards tcp/2222 to the primary
//! (`gunnar-front::forward`), so "through the front" is the primary's listener here.
//!
//! The twin is not a server: it serves no git (`Dispatch::authorise_service` refuses a twin
//! first) and holds the primary's frames in its log. So its view of a delete is what a box
//! restored from it holds — the recharge, a main rebuilt from the twin: the name must be as
//! deleted there as on the primary (not in the catalog, listed by `repo list-deleted`, and
//! `repo undelete` brings it back), and never served again because the twin was away when
//! the delete happened.

mod common;

use std::sync::Arc;

use common::{local_repo, tools_present, Harness, HarnessOptions};
use gunnar_cli::server::twin::{restore, Follower, Progress, TwinConfig};
use gunnar_cli::server::ServerStores;
use gunnar_core::{Grant, HashKind, Level, RepoRef};

const NS: &str = "t";
const REPO: &str = "del-probe";

fn control(
    harness: &Harness,
) -> gunnar_proto::repos_client::ReposClient<tonic::transport::Channel> {
    let channel = harness
        .runtime
        .block_on(
            tonic::transport::Endpoint::from_shared(harness.grpc_url())
                .expect("a gRPC endpoint")
                .connect(),
        )
        .expect("connect to the control plane");
    gunnar_proto::repos_client::ReposClient::new(channel)
}

/// `Repos.Delete` exactly as `gunnar-ui-server`'s `api::repos::delete` sends it: soft, the
/// server's own retention window.
fn console_delete(harness: &Harness) {
    let mut repos = control(harness);
    harness
        .runtime
        .block_on(repos.delete(harness.authed(
            "Repos.Delete",
            gunnar_proto::DeleteRepoRequest {
                path: Some(gunnar_proto::RepoPath::new(NS, REPO)),
                purge: false,
                retention_days: 0,
                zero_retention: false,
            },
        )))
        .expect("Repos.Delete (soft) over the real control plane");
}

fn console_undelete(harness: &Harness) {
    let mut repos = control(harness);
    harness
        .runtime
        .block_on(repos.undelete(harness.authed(
            "Repos.Undelete",
            gunnar_proto::UndeleteRepoRequest {
                path: Some(gunnar_proto::RepoPath::new(NS, REPO)),
            },
        )))
        .expect("Repos.Undelete over the real control plane");
}

/// `repo list-deleted` on the primary: `Repos.ListDeleted` for the namespace.
fn listed_deleted(harness: &Harness) -> Vec<String> {
    let mut repos = control(harness);
    harness
        .runtime
        .block_on(repos.list_deleted(harness.authed(
            "Repos.ListDeleted",
            gunnar_proto::ListDeletedRequest {
                namespace: NS.to_owned(),
            },
        )))
        .expect("Repos.ListDeleted")
        .into_inner()
        .repos
        .into_iter()
        .filter_map(|row| row.repo.map(|p| format!("{}/{}", p.namespace, p.repo)))
        .collect()
}

fn path() -> RepoRef {
    RepoRef::new(NS, REPO).expect("repo path")
}

/// The twin: a store-only follower of the primary's log, as `become_twin` makes one.
fn twin() -> (tempfile::TempDir, Arc<ServerStores>) {
    let dir = tempfile::tempdir().expect("a twin dir");
    TwinConfig {
        primary: "harness".into(),
        joined_at_unix_ms: 0,
    }
    .save(dir.path())
    .expect("twin.json");
    let stores = Arc::new(ServerStores::open(dir.path()).expect("the twin's stores"));
    (dir, stores)
}

/// The twin (re)connects and follows until it is at the primary's head.
fn follow(harness: &Harness, twin: &Arc<ServerStores>) {
    let follower = Follower::new(
        Arc::clone(twin),
        Box::new(Arc::clone(harness.stores.txlog())),
    );
    for _ in 0..10_000 {
        match follower.step().expect("the twin follows") {
            Progress::Applied { .. } => continue,
            Progress::CaughtUp { .. } => break,
            other => panic!("the twin could not follow: {other:?}"),
        }
    }
    assert_eq!(
        twin.txlog().head_seq(),
        harness.stores.txlog().head_seq(),
        "the twin is at the primary's head"
    );
}

/// A box restored from the twin's log (the recharge, a main rebuilt from its twin).
fn restored_from(twin: &Arc<ServerStores>) -> (tempfile::TempDir, ServerStores) {
    let dir = tempfile::tempdir().expect("a fresh data root");
    {
        let fresh = Arc::new(ServerStores::open(dir.path()).expect("a fresh box"));
        restore(Arc::clone(twin.txlog()), fresh, "").expect("restore from the twin");
    }
    let stores = ServerStores::open(dir.path()).expect("the restored box");
    (dir, stores)
}

fn assert_deleted_on(stores: &ServerStores, said: &str) {
    assert!(
        stores.catalog().get(&path()).expect("catalog").is_none(),
        "{said}: the deleted repository is back in the catalog — it would be served"
    );
    let listed: Vec<String> = stores
        .tombstones()
        .expect("tombstones")
        .into_iter()
        .map(|t| t.path.key())
        .collect();
    assert!(
        listed.contains(&format!("{NS}/{REPO}")),
        "{said}: `repo list-deleted` does not list the deleted repository ({listed:?}); orphans: {:?} — \
         the delete's tombstone did not reach this box, so its undo is gone",
        stores.orphans().map(|o| o.into_iter().map(|o| o.path.key()).collect::<Vec<_>>())
    );
    stores
        .undelete_spec(&path())
        .unwrap_or_else(|e| panic!("{said}: `repo undelete` is refused: {e:#}"));
}

fn assert_refused(out: &common::Output, said: &str) {
    assert!(
        !out.ok,
        "THE DEFECT ({said}): the deleted repository still answers: {}",
        out.stdout
    );
    assert!(
        out.stderr
            .contains("repository not found, or you do not have access")
            || out.stderr.contains("not found"),
        "{said}: a deleted repository is the fused not-found: {}",
        out.stderr
    );
}

#[test]
fn a_soft_delete_stops_serving_on_the_primary_and_the_twin_keeps_it_deleted() {
    if !tools_present() {
        eprintln!("skipping: git or ssh not installed");
        return;
    }
    let harness = Harness::start_with_options(
        NS,
        REPO,
        HashKind::Sha1,
        HarnessOptions {
            grpc: true,
            #[cfg(feature = "http")]
            http: true,
            ..HarnessOptions::default()
        },
    );
    // The account admin, as the console's session for the owner: Admin on the namespace.
    harness
        .stores
        .auth()
        .grant(Grant::namespace("tester", NS, Level::Admin))
        .expect("grant admin");
    let (_twin_dir, twin) = twin();

    // 1. Pushed over SSH; the twin has it.
    let work = harness.dir.path().join("work");
    let head = local_repo(&harness, &work, HashKind::Sha1, "served, then deleted\n");
    let url = harness.url(NS, REPO);
    harness
        .git(&work, &["push", &url, "HEAD:refs/heads/main"])
        .expect_ok("push t/del-probe");
    follow(&harness, &twin);

    // 2. Deleted as the console does.
    console_delete(&harness);

    // 3. Refused at once, every way.
    assert_refused(
        &harness.git(&work, &["ls-remote", &url]),
        "ls-remote over ssh after the delete",
    );
    #[cfg(feature = "http")]
    {
        let (st, _) = http_info_refs(&harness);
        assert_eq!(
            st, 404,
            "smart HTTP info/refs of a deleted repository is not found"
        );
    }
    assert_eq!(
        listed_deleted(&harness),
        vec![format!("{NS}/{REPO}")],
        "repo list-deleted on the primary"
    );
    follow(&harness, &twin);
    let (_d, from_twin) = restored_from(&twin);
    assert_deleted_on(&from_twin, "a box restored from the twin after the delete");

    // A new push to the same path does not bring the old repository back.
    let again = harness.git(&work, &["push", &url, "HEAD:refs/heads/main"]);
    let after = harness.git(&work, &["ls-remote", &url]);
    assert!(
        !(again.ok && after.ok && after.stdout.contains(&head)),
        "a push to a deleted path silently brought the old repository back: {}",
        after.stdout
    );
    assert!(
        !again.ok,
        "the code's rule: a push over a soft-deleted name is refused (undelete it): {}",
        again.stderr
    );
    assert_refused(&after, "ls-remote after a refused push to the deleted path");

    // 4. Undelete brings it back, and it serves again — on the primary and from the twin.
    console_undelete(&harness);
    let back = harness.git(&work, &["ls-remote", &url]);
    back.expect_ok("ls-remote after the undelete");
    assert!(
        back.stdout.contains(&head),
        "the undeleted repository serves its old main: {}",
        back.stdout
    );
    assert!(
        listed_deleted(&harness).is_empty(),
        "an undeleted repository is not listed as deleted"
    );
    follow(&harness, &twin);
    let (_d2, from_twin) = restored_from(&twin);
    assert!(
        from_twin.catalog().get(&path()).expect("catalog").is_some(),
        "the undelete reached the twin"
    );
    assert!(
        from_twin.tombstones().expect("tombstones").is_empty(),
        "the twin's copy is not still marked deleted"
    );

    // 5. Deleted while the twin is away; the twin reconnects and must not keep it live.
    console_delete(&harness);
    assert_refused(
        &harness.git(&work, &["ls-remote", &url]),
        "ls-remote after the second delete",
    );
    follow(&harness, &twin);
    let (_d3, from_twin) = restored_from(&twin);
    assert_deleted_on(
        &from_twin,
        "a box restored from a twin that was away during the delete",
    );
}

/// `GET /<ns>/<repo>.git/info/refs?service=git-upload-pack` with the owner's token.
#[cfg(feature = "http")]
fn http_info_refs(harness: &Harness) -> (u16, String) {
    let token = harness.mint_token("tester");
    let host = harness.http_addr.expect("the HTTP listener").to_string();
    common::http_request(
        "GET",
        &host,
        &format!("/{NS}/{REPO}.git/info/refs?service=git-upload-pack"),
        Some(&format!("x-access-token:{token}")),
        None,
    )
}