crates/gunnar-cli/tests/policy_mirror.rs
#![cfg(all(feature = "server", feature = "control"))]
//! **`Policy.*` and `Mirror.*` against a real server**, over the gRPC control plane.
//!
//! Both services answered `Unimplemented` on every RPC, for two reasons that
//! were recorded in `gunnar-server`'s adapter and were each right about
//! something different:
//!
//! > `Policy.*`: gunnar-core has no store for branch protection or ref-name
//! > policy … Serving this over gRPC needs a policy document in the catalog
//!
//! There is one now (`gunnar_core::PolicyStore`, `refs/gunnar/policy`).
//!
//! > `Mirror.*`: mirroring runs over gunnar-client … which an S crate must
//! > never link
//!
//! True of **`Sync`** and of nothing else. `Add`, `Status` and `Remove` are a
//! document write, a document read and a document delete; they are served, and
//! `Sync` is still refused with the reason narrowed to the verb that has it.
//!
//! # The assertion that matters
//!
//! `a_protection_rule_written_over_grpc_refuses_a_real_push` is the one that
//! makes the rest mean anything. A policy store that round-trips perfectly and
//! never reaches `PolicyGate` is a database, not a policy — so the test that
//! carries this file drives **stock git over real SSH** and asserts the push is
//! refused. Everything else here is shape.
mod common;
use common::Harness;
use gunnar_core::HashKind;
use gunnar_grpc::{ControlError, ControlPlane, ControlResult};
use gunnar_proto::{BranchProtection, RefNamePolicy, RepoPath};
fn tools_present() -> bool {
["git", "ssh"].iter().all(|tool| {
std::process::Command::new(tool)
.arg("--version")
.output()
.is_ok()
})
}
fn plane(harness: &Harness) -> gunnar_cli::server::CoreControlPlane {
// These rows are about the REGISTRY — add, list, remove, prune — and their
// upstream is `ssh://git@upstream/…`, a name nothing resolves. Since
// GUN-031 `Mirror.Add` checks the destination before it touches the
// registry, and a name that resolves to nothing is refused like one that
// resolves to the server's own network. The opt-out is the shipped one, a
// file under the data root, written here for the same reason
// `mirror_sync.rs`'s `Downstream` writes it: the destination is not what
// these tests measure.
std::fs::write(
harness
.stores
.root()
.join(gunnar_cli::server::mirror_fetch::MIRROR_ALLOW_PRIVATE),
b"",
)
.expect("the mirror destination opt-out");
gunnar_cli::server::CoreControlPlane::new(std::sync::Arc::clone(&harness.stores))
}
fn protection(pattern: &str) -> BranchProtection {
BranchProtection {
ref_pattern: pattern.to_owned(),
allow_force_push: false,
allow_delete: false,
require_signature: false,
allowed_principal_ids: Vec::new(),
max_object_bytes: 0,
max_objects_per_push: 0,
forbid_self_approval: false,
required_approvals: 0,
}
}
// ── policy ──────────────────────────────────────────────────────────────────
/// **The link from the store to the gate**, over real ssh with stock git.
///
/// A rule is written through `ControlPlane::set_protection` and nothing else;
/// then a force-push that the rule forbids is attempted with the `git` binary.
/// If the push succeeds, the policy store is a place values go to be read back
/// and nothing on the push path consults it.
///
/// The control comes first and is not decoration: the same force-push must
/// **succeed** before the rule exists, or "refused after" would be satisfied by
/// a repository that refuses force-pushes anyway.
#[test]
fn a_protection_rule_written_over_grpc_refuses_a_real_push() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let mut harness = Harness::start("team", "guarded", HashKind::Sha1);
let work = harness.dir.path().join("work");
let url = harness.url("team", "guarded");
harness
.git(
harness.dir.path(),
&["init", "--quiet", "-b", "main", work.to_str().unwrap()],
)
.expect_ok("git init");
std::fs::write(work.join("a.txt"), b"one\n").expect("write");
harness.git(&work, &["add", "a.txt"]).expect_ok("git add");
harness
.git(&work, &["commit", "--quiet", "-m", "one"])
.expect_ok("commit");
harness
.git(&work, &["push", "--quiet", &url, "main"])
.expect_ok("the first push");
// A second, unrelated commit, then rewind: this is what a force-push is.
std::fs::write(work.join("a.txt"), b"two\n").expect("write");
harness.git(&work, &["add", "a.txt"]).expect_ok("git add");
harness
.git(&work, &["commit", "--quiet", "--amend", "-m", "rewritten"])
.expect_ok("amend");
// ── the control: with no rule, the force-push lands ────────────────────
harness
.git(&work, &["push", "--force", "--quiet", &url, "main"])
.expect_ok("a force-push must be servable before a rule forbidding it means anything");
// ── the rule, written over the control plane and nowhere else ──────────
let path = RepoPath::new("team", "guarded");
plane(&harness)
.set_protection(&path, protection("refs/heads/main"))
.expect("writing a protection rule");
// The gate reads the policy when it opens a repository, and the handle is
// cached; drop it so the next push opens afresh.
harness
.stores
.close_repo(&gunnar_core::RepoRef::new("team", "guarded").expect("a legal path"));
std::fs::write(work.join("a.txt"), b"three\n").expect("write");
harness.git(&work, &["add", "a.txt"]).expect_ok("git add");
harness
.git(
&work,
&["commit", "--quiet", "--amend", "-m", "rewritten again"],
)
.expect_ok("amend");
let refused = harness.git(&work, &["push", "--force", &url, "main"]);
assert!(
!refused.ok,
"a protection rule written over gRPC did not reach the push gate; the policy store is \
a database and not a policy. git said:\n{}",
refused.stderr
);
harness.stop();
}
/// **A protected branch must refuse a DELETION**, over real ssh with stock git.
///
/// Its own test and not a leg of the force-push one, because a deletion takes a
/// different path through the server: `git push --delete` sends **no
/// packfile**. If the pre-receive gate is only opened when there are objects to
/// index, every protection rule in gunnar is bypassable by the one push that
/// removes the branch outright — which is a worse outcome than the force-push
/// this suite already guards, and would have been invisible behind it.
///
/// Found by `gunnar-ui/uitest`'s `a-protected-branch-refuses-a-deletion`, which
/// went red while the force-push row beside it passed.
///
/// The control comes first for the same reason it does above: the deletion must
/// be seen to SUCCEED with no rule, or "refused after" is satisfied by a
/// repository that refuses deletions anyway.
#[test]
fn a_protection_rule_refuses_a_real_branch_deletion() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let mut harness = Harness::start("team", "nodelete", HashKind::Sha1);
let work = harness.dir.path().join("work");
let url = harness.url("team", "nodelete");
harness
.git(
harness.dir.path(),
&["init", "--quiet", "-b", "main", work.to_str().unwrap()],
)
.expect_ok("git init");
std::fs::write(work.join("a.txt"), b"one\n").expect("write");
harness.git(&work, &["add", "a.txt"]).expect_ok("git add");
harness
.git(&work, &["commit", "--quiet", "-m", "one"])
.expect_ok("commit");
harness
.git(&work, &["push", "--quiet", &url, "main"])
.expect_ok("the first push");
// ── the control: a branch with no rule can be deleted ──────────────────
harness
.git(&work, &["push", "--quiet", &url, "main:doomed"])
.expect_ok("a second branch to delete");
harness
.git(&work, &["push", "--quiet", &url, "--delete", "doomed"])
.expect_ok("a deletion must be SERVABLE before a rule forbidding it means anything");
// ── the rule ───────────────────────────────────────────────────────────
let path = RepoPath::new("team", "nodelete");
plane(&harness)
.set_protection(&path, protection("refs/heads/main"))
.expect("writing a protection rule");
harness
.stores
.close_repo(&gunnar_core::RepoRef::new("team", "nodelete").expect("a legal path"));
let refused = harness.git(&work, &["push", &url, "--delete", "main"]);
assert!(
!refused.ok,
"a protected branch was DELETED. `allow_delete` is false and the pre-receive gate did \
not stop it — a deletion sends no packfile, so a gate that only opens when there are \
objects to index never runs on the one push that removes the branch outright. git \
said:\n{}",
refused.stderr
);
harness.stop();
}
/// The round trip, and the two fields the protocol cannot express.
///
/// `allow_create` and `ancestry_budget` have no proto field, so a rule written
/// over gRPC on top of an existing one must **keep** them. Otherwise editing a
/// rule through the console silently widens it to whatever the defaults are —
/// a change nobody made, to a security control, invisible in the thing that
/// made it.
#[test]
fn a_protection_round_trip_keeps_the_fields_the_protocol_cannot_carry() {
let harness = Harness::start("team", "rt", HashKind::Sha1);
let plane = plane(&harness);
let path = RepoPath::new("team", "rt");
// A rule with a non-default `allow_create`, written the only way that can
// express it: through the policy document itself.
let repo_ref = gunnar_core::RepoRef::new("team", "rt").expect("a legal path");
let mut config = gunnar_policy::PolicyConfig::default();
let mut rule = gunnar_policy::ProtectionRule::protect("refs/heads/main").expect("a rule");
rule.allow_create = false;
rule.ancestry_budget = 4242;
config.protection.push(rule);
harness
.stores
.policies()
.set(&repo_ref, &config.to_json().expect("render"))
.expect("store the policy");
// Now edit it over gRPC, which carries neither field.
plane
.set_protection(
&path,
BranchProtection {
allow_force_push: true,
..protection("refs/heads/main")
},
)
.expect("set_protection");
let stored = harness
.stores
.policies()
.get(&repo_ref)
.expect("read")
.expect("a policy exists");
let after = gunnar_policy::PolicyConfig::from_json(&stored).expect("parse");
let rule = &after.protection[0];
assert!(
rule.allow_force_push,
"the field the protocol DOES carry was not applied"
);
assert!(
!rule.allow_create,
"`allow_create` was reset by a gRPC edit that cannot express it, so the console \
silently widened a rule it never displayed"
);
assert_eq!(
rule.ancestry_budget, 4242,
"`ancestry_budget` was reset by a gRPC edit that cannot express it"
);
}
/// The per-rule byte limits are **refused**, not silently dropped.
///
/// `BranchProtection` carries them per pattern and `gunnar-policy` enforces
/// them per repository. Applying one to the whole repository would bind
/// references the caller never named; ignoring it would report success for a
/// limit nobody enforces. Both are worse than a refusal that says which.
#[test]
fn a_per_rule_byte_limit_is_refused_rather_than_applied_to_the_repository() {
let harness = Harness::start("team", "limits", HashKind::Sha1);
let path = RepoPath::new("team", "limits");
let err = plane(&harness)
.set_protection(
&path,
BranchProtection {
max_object_bytes: 4096,
..protection("refs/heads/main")
},
)
.expect_err("a per-rule limit this engine cannot express must be refused");
assert!(
matches!(err, ControlError::InvalidArgument(_)),
"got {err:?}"
);
assert!(
format!("{err}").contains("repository-wide"),
"the refusal must say why, so an operator knows where to set the limit instead: {err}"
);
}
/// Reading a repository nobody configured is an empty policy, and deleting a
/// rule that is not there is `NotFound` — not a cheerful success.
#[test]
fn an_unconfigured_repository_has_no_rules_and_deleting_one_says_so() {
let harness = Harness::start("team", "bare", HashKind::Sha1);
let plane = plane(&harness);
let path = RepoPath::new("team", "bare");
assert!(plane
.get_protection(&path, None)
.expect("reading an unconfigured repository is not an error")
.is_empty());
let err = plane
.delete_protection(&path, "refs/heads/main")
.expect_err("deleting a rule that does not exist");
assert!(matches!(err, ControlError::NotFound(_)), "got {err:?}");
}
/// A ref-name policy round-trips, and an unusable pattern is refused at the
/// door rather than stored and discovered on the next push.
#[test]
fn a_ref_name_policy_round_trips_and_a_bad_pattern_is_refused() {
let harness = Harness::start("team", "names", HashKind::Sha1);
let plane = plane(&harness);
let path = RepoPath::new("team", "names");
let written = plane
.set_ref_name_policy(
&path,
RefNamePolicy {
allow_patterns: vec!["refs/heads/**".into()],
deny_patterns: vec!["refs/heads/wip/**".into()],
},
)
.expect("set_ref_name_policy");
assert_eq!(written.allow_patterns, vec!["refs/heads/**".to_owned()]);
assert_eq!(
plane.get_ref_name_policy(&path).expect("get"),
written,
"what was read back is not what was written"
);
let err = plane
.set_ref_name_policy(
&path,
RefNamePolicy {
allow_patterns: vec!["***".into()],
deny_patterns: Vec::new(),
},
)
.expect_err("`***` is not a pattern");
assert!(
matches!(err, ControlError::InvalidArgument(_)),
"got {err:?}"
);
}
/// **The refusal census: every `Policy.*` and `Mirror.*` verb — and
/// `Refs.Diff` — called for real, against the list that claims to name the
/// refused ones.**
///
/// # The failure this exists for
///
/// A refusal outlives its reason silently. `PROTECTION_GAP` sat in
/// `gunnar-server/src/control_plane.rs` beside `MIRROR_GAP` saying all six
/// `Policy.*` verbs were unimplemented — long after `gunnar_core::PolicyStore`
/// gave the policy document a home and every one of them started being served.
/// `cargo check` called the constant `never used` and nothing else noticed. The
/// console's coverage matrix went on quoting it to render six panels as
/// disabled, so for weeks a merge needed a verified signed approval and a
/// **direct push to the same branch** was refused by nothing at all.
///
/// Prose could not have caught that, and neither could a test per verb: the
/// bug was not that a verb misbehaved, it was that the SET changed and the two
/// documents describing it did not. So this asserts the set, both ways:
///
/// * a verb that answers `Unimplemented` and is not in
/// [`gunnar_grpc::UNIMPLEMENTED_METHODS`] fails **by name** — a refusal
/// nobody wrote down;
/// * a verb named there that answers anything else fails **by name** — a
/// refusal that quietly stopped refusing, which is this bug exactly.
///
/// `gunnar-ui/crates/gunnar-ui-server/tests/contract.rs` reads the same
/// constant and holds the console's coverage matrix to it, so the doc cannot go
/// stale again without a test saying which row lies.
///
/// # Every call here is expected to FAIL
///
/// Each verb is driven against a repository that exists but is configured for
/// nothing, so the interesting answers are `NotFound`, `InvalidArgument` and
/// `Ok` — anything except `Unimplemented`. The census does not care which of
/// those it gets; the other tests in this file care, one verb at a time. What
/// it cares about is that `Unimplemented` appears exactly where it is declared.
#[test]
fn the_only_refusal_left_on_these_two_services_is_the_one_that_is_written_down() {
let mut harness = Harness::start("team", "census", HashKind::Sha1);
let path = RepoPath::new("team", "census");
// **The plane is built the way `Server::start` builds it**, with a live
// replication slot, and that is not ceremony. `CoreControlPlane` refuses
// all four replication verbs with `Unimplemented` when it has no slot —
// deliberately, and its own doc comment says why: "a plane built without a
// slot is a plane with no server around it (a unit test)". Measured here on
// the first run of this census, which reported `Mirror.RemoveReplication`,
// `Mirror.ReplicationStatus` and `Mirror.ResyncReplication` as undeclared
// refusals. They are not architectural gaps; they are this harness. A
// census taken against a bare plane would write that accident into the
// constant and from there into the console's coverage matrix, which is the
// exact class of untrue documentation this test exists to prevent.
let slot = gunnar_cli::server::mirror::ReplicationSlot::new(
std::sync::Arc::clone(&harness.stores),
std::sync::Arc::new(gunnar_grpc::EventBus::new(64)),
harness.runtime.handle().clone(),
);
let plane = plane(&harness).with_replication(slot);
/// `Unimplemented` or not, for one verb. The error's *kind* is the whole
/// question; its message is what an operator reads and is deliberately not
/// asserted on here.
fn refused<T>(outcome: ControlResult<T>) -> bool {
matches!(outcome, Err(ControlError::Unimplemented(_)))
}
let mut observed: Vec<&'static str> = Vec::new();
let mut record = |name: &'static str, is_refused: bool| {
if is_refused {
observed.push(name);
}
};
// ── Policy: all six ────────────────────────────────────────────────────
record(
"Policy.GetProtection",
refused(plane.get_protection(&path, None)),
);
record(
"Policy.SetProtection",
refused(plane.set_protection(&path, protection("refs/heads/census"))),
);
record(
"Policy.DeleteProtection",
refused(plane.delete_protection(&path, "refs/heads/census")),
);
record(
"Policy.GetRefNamePolicy",
refused(plane.get_ref_name_policy(&path)),
);
record(
"Policy.SetRefNamePolicy",
refused(plane.set_ref_name_policy(
&path,
RefNamePolicy {
allow_patterns: vec!["refs/heads/**".into()],
deny_patterns: Vec::new(),
},
)),
);
// `ListGates` has no control-plane verb of its own — the service composes
// it out of `get_protection` and `get_ref_name_policy`, both of which are
// driven above. Naming it here anyway keeps the census the shape of the
// proto's service and not the shape of this trait.
record(
"Policy.ListGates",
refused(plane.get_protection(&path, None)) || refused(plane.get_ref_name_policy(&path)),
);
// ── Mirror: the four registration verbs ────────────────────────────────
record(
"Mirror.Add",
refused(plane.mirror_add(&path, "ssh://git@upstream/x.git", false, false)),
);
record("Mirror.Status", refused(plane.mirror_status(None, None)));
record("Mirror.Sync", refused(plane.mirror_sync(&path)));
record("Mirror.Remove", refused(plane.mirror_remove(&path)));
// ── Mirror: the four replication verbs ─────────────────────────────────
//
// The trait's DEFAULTS refuse these, with a message that says "this server
// was built without `--features replication`" — a build option rather than
// an architectural gap. That distinction is why they are driven here rather
// than assumed: `gunnar-cli`'s `server` feature turns
// `gunnar-server/replication` on (decided 2026-08-30, and the
// appliance ships it), so this binary runs the real implementations and an
// `Unimplemented` from any of them means the feature silently fell out of
// the graph — which the census would then name.
record(
"Mirror.ReplicationStatus",
refused(plane.replication_status(false)),
);
record(
"Mirror.RemoveReplication",
refused(plane.replication_remove("no-such-standby")),
);
record(
"Mirror.ResyncReplication",
refused(plane.replication_resync("no-such-standby", None, &[])),
);
// ── Refs.Diff, the third verb a coverage row has claimed is refused ────
//
// Not a `Policy` or a `Mirror` verb, and driven here anyway, because
// `gunnar-ui/docs/coverage.md` carried it as `refused by gunnar, replaced`
// — a fourth stale row beside the six `Policy.*` ones, and found the same
// way. `CoreControlPlane::diff` has no `Unimplemented` in it; the console
// serves `/api/repos/…/diff` off this RPC. `from`/`to` are deliberately
// nonsense, so the honest answer is `NotFound` or `InvalidArgument`.
record(
"Refs.Diff",
refused(plane.diff(&path, "HEAD~1", "HEAD", &[])),
);
observed.sort_unstable();
observed.dedup();
let mut declared: Vec<&str> = gunnar_grpc::UNIMPLEMENTED_METHODS
.iter()
.copied()
.filter(|m| {
m.starts_with("Policy.") || m.starts_with("Mirror.") || m.starts_with("Refs.Diff")
})
.collect();
declared.sort_unstable();
let undeclared: Vec<&str> = observed
.iter()
.copied()
.filter(|m| !declared.contains(m))
.collect();
assert!(
undeclared.is_empty(),
"these verbs answered `Unimplemented` and are NOT in \
`gunnar_grpc::UNIMPLEMENTED_METHODS`: {undeclared:?}. A refusal nobody wrote down is \
how the console ends up rendering a working feature as a disabled panel — add them \
there, with the reason, or serve them."
);
let stale: Vec<&str> = declared
.iter()
.copied()
.filter(|m| !observed.contains(m))
.collect();
assert!(
stale.is_empty(),
"`gunnar_grpc::UNIMPLEMENTED_METHODS` still names {stale:?}, and a real server SERVES \
them. This is `PROTECTION_GAP` happening again: the constant is now a lie, \
gunnar-ui/docs/coverage.md is quoting it, and the console is hiding a feature that \
works. Remove them from that list."
);
assert_eq!(
observed, declared,
"the refusal set drifted from what is declared"
);
harness.stop();
}
// ── mirror ──────────────────────────────────────────────────────────────────
/// **A client push into a pull mirror is refused, by name** (GUN-FIELD-TEST, 2026-10-31).
///
/// Field test, stock git 2.47 over smart HTTP: a person cloned a pull mirror, committed and
/// pushed, and the push was ACCEPTED; the next `control mirror sync` moved the branch back to
/// the upstream's tip (`refs/heads/d1 1f99d0f69ebe -> 5a49863f5c35`) and the pushed commit
/// was gone, with nothing said to anybody. A pull mirror's references are the upstream's, so a
/// person's push into one is refused at the door and told where to push instead. The
/// control comes first: the same push lands before the registration exists.
#[test]
fn a_client_push_into_a_pull_mirror_is_refused_by_name() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let mut harness = Harness::start("team", "mirrored", HashKind::Sha1);
let work = harness.dir.path().join("work");
let url = harness.url("team", "mirrored");
harness
.git(
harness.dir.path(),
&["init", "--quiet", "-b", "main", work.to_str().unwrap()],
)
.expect_ok("git init");
std::fs::write(work.join("a.txt"), b"one\n").expect("write");
harness.git(&work, &["add", "a.txt"]).expect_ok("git add");
harness
.git(&work, &["commit", "--quiet", "-m", "one"])
.expect_ok("commit");
// ── the control: not a mirror yet, the push lands ──────────────────────
harness
.git(&work, &["push", "--quiet", &url, "main"])
.expect_ok("a push into a repository that mirrors nothing");
plane(&harness)
.mirror_add(
&RepoPath::new("team", "mirrored"),
"ssh://git@upstream/team/mirrored.git",
false,
false,
)
.expect("registering the pull mirror");
std::fs::write(work.join("a.txt"), b"two\n").expect("write");
harness.git(&work, &["add", "a.txt"]).expect_ok("git add");
harness
.git(&work, &["commit", "--quiet", "-m", "two"])
.expect_ok("commit");
let refused = harness.git(&work, &["push", &url, "main"]);
assert!(
!refused.ok,
"a person's push into a pull mirror was accepted, and the next sync overwrites it \
without a word. git said:\n{}",
refused.stderr
);
assert!(
refused
.stderr
.contains("pull mirror of ssh://git@upstream/team/mirrored.git"),
"the refusal must say it is a mirror and of what, so the pusher knows where to push: \
{}",
refused.stderr
);
harness.stop();
}
/// Add, list, remove — and the narrowed refusal on the one verb that fetches.
#[test]
fn a_mirror_registration_is_kept_listed_and_removed() {
let harness = Harness::start("team", "mirrored", HashKind::Sha1);
let plane = plane(&harness);
let path = RepoPath::new("team", "mirrored");
assert!(plane
.mirror_status(None, None)
.expect("listing an empty registry is not an error")
.is_empty());
let added = plane
.mirror_add(&path, "ssh://git@upstream/team/mirrored.git", true, false)
.expect("registering a mirror");
assert_eq!(added.remote_url, "ssh://git@upstream/team/mirrored.git");
assert!(added.prune);
assert_eq!(
added.last_sync_at_unix_ms, None,
"a mirror that has never synced must not carry a timestamp; a zero here reads as 1970"
);
assert_eq!(added.sync_count, 0);
let listed = plane.mirror_status(None, None).expect("listing");
assert_eq!(listed.len(), 1);
assert_eq!(
listed[0].repo.as_ref().expect("a repo").to_string(),
"team/mirrored"
);
// The filters narrow rather than widen.
assert_eq!(
plane
.mirror_status(Some("team"), None)
.expect("by ns")
.len(),
1
);
assert!(plane
.mirror_status(Some("other"), None)
.expect("by another ns")
.is_empty());
// `Sync` used to be the one verb still refused — for fetching, not for the
// registration being missing. Since 2026-09-08 it ENQUEUES: a real
// `MirrorJob` comes back, Queued, and the fetch runs on the one runner.
// (This registration's remote is a path that does not exist, so the job
// will FAIL when the worker reaches it — but enqueueing is the answer
// `Sync` owes, and that answer is no longer a refusal.)
// The seam answers with the mirror's config carrying the enqueued job; the
// mirror's own state flips to Syncing the moment a job is queued for it.
let config = plane
.mirror_sync(&path)
.expect("sync enqueues now that fetch_pack exists");
assert!(
matches!(
config.state(),
gunnar_proto::MirrorState::Syncing | gunnar_proto::MirrorState::Idle
),
"a fresh sync is syncing or (already settled) idle, never a refusal, got {:?}",
config.state()
);
plane.mirror_remove(&path).expect("removing a mirror");
assert!(plane.mirror_status(None, None).expect("listing").is_empty());
let err = plane
.mirror_remove(&path)
.expect_err("removing a registration that is not there");
assert!(matches!(err, ControlError::NotFound(_)), "got {err:?}");
}
/// **A sync of an unregistered repository is `NotFound`, not "unimplemented".**
///
/// The two answers send an operator to different places: one to `mirror add`,
/// the other to a different build. Collapsing them was what the single
/// `MIRROR_GAP` refusal did to all four verbs.
#[test]
fn syncing_a_repository_with_no_mirror_says_there_is_no_mirror() {
let harness = Harness::start("team", "nomirror", HashKind::Sha1);
let err = plane(&harness)
.mirror_sync(&RepoPath::new("team", "nomirror"))
.expect_err("nothing to sync");
assert!(matches!(err, ControlError::NotFound(_)), "got {err:?}");
}
/// Registering against a repository that does not exist is `NotFound` unless
/// `create_repo` was asked for — and then the first sync makes it.
#[test]
fn a_mirror_needs_a_repository_unless_it_is_told_to_make_one() {
let harness = Harness::start("team", "present", HashKind::Sha1);
let plane = plane(&harness);
let err = plane
.mirror_add(
&RepoPath::new("team", "absent"),
"ssh://git@upstream/x.git",
false,
false,
)
.expect_err("no such repository");
assert!(matches!(err, ControlError::NotFound(_)), "got {err:?}");
let made = plane
.mirror_add(
&RepoPath::new("team", "absent"),
"ssh://git@upstream/x.git",
false,
true,
)
.expect("create_repo registers an import whose repository is to come");
assert_eq!(
made.repo.as_ref().expect("a repo").to_string(),
"team/absent"
);
// The repository is made by the first sync that reaches the upstream, at
// the upstream's object format and default branch (2026-10-18) — not
// here, at a guess.
assert!(
made.repo_pending,
"the registration says its repository is to come"
);
assert!(
plane.get_repo(&RepoPath::new("team", "absent")).is_err(),
"Mirror.Add made the repository before the upstream said its format"
);
}