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