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,
)
}