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/browse_replica.rs

//! **The browse replica**, end to end and red-first.
//!
//! A browse replica is a standby that holds ONLY public repositories, serves
//! anonymous reads of them, refuses every control-plane change, and can never
//! be promoted. Four things have to be true for that sentence to be true, and
//! each has a test here that fails if its mechanism is removed:
//!
//! 1. **`PublicOnly` never enqueues a private repository** — remove the
//!    visibility filter in `Replicator::enqueue` and
//!    `a_public_only_target_is_offered_public_repositories_and_never_private_ones`
//!    goes red.
//! 2. **Public → Private DELETES the replica's copy**, over the replica's
//!    control plane, signed as the primary's `Machine` — remove the
//!    `VisibilityChanged` arm of the subscriber, or the `forget` branch of the
//!    worker, and `a_repository_that_goes_private_is_forgotten_by_the_browse_replica`
//!    goes red. The same test proves the reverse: Private → Public brings it
//!    back. **Deleting** the repository does the same thing to the replica —
//!    remove `RepoDeleted` from the subscriber's filter and
//!    `a_repository_that_is_deleted_is_forgotten_by_the_browse_replica` goes
//!    red — and a **resync derives** the forget from the catalog rather than
//!    relying on either event having been delivered, which is
//!    `a_resync_offers_a_public_only_replica_the_forget_for_a_private_repository`.
//! 3. **A browse box refuses `SetRole PRIMARY`** while allowing `STANDBY`, and
//!    freezes client writes like a standby — see
//!    `a_browse_replica_cannot_be_promoted_and_is_frozen`.
//! 4. **`--anonymous refuse` sends a stranger to the browse host by name**, and
//!    the browse box itself serves the stranger whatever the knob says — see
//!    `anonymous_refuse_names_the_browse_host_and_a_browse_box_serves_anyway`.
//!
//! The two-server test drives real `git push` over real SSH into the primary
//! and reads the REPLICA's catalog and ref advertisement: what a stranger
//! would see, not what a counter says.

mod common;

use std::sync::atomic::Ordering::SeqCst;
use std::sync::Arc;
use std::time::{Duration, Instant};

use common::{local_repo, tools_present, Anonymous, Harness, HarnessOptions};
use gunnar_cli::server::{
    ReplicationConfig, ReplicationScope, ReplicationTarget, Replicator, VisibilityScope,
};
use gunnar_core::{Grant, HashKind, Level, Principal, PrincipalKind, RepoRef, Visibility};
use gunnar_grpc::{ControlPlane, EventBus};
use gunnar_proto::ReplicationRole;

const WAIT: Duration = Duration::from_secs(30);

fn wait_for<T>(what: &str, mut check: impl FnMut() -> Option<T>) -> T {
    let deadline = Instant::now() + WAIT;
    loop {
        if let Some(value) = check() {
            return value;
        }
        assert!(
            Instant::now() < deadline,
            "waited {WAIT:?} for {what} and it never happened"
        );
        std::thread::sleep(Duration::from_millis(50));
    }
}

fn never_within<T>(patience: Duration, mut check: impl FnMut() -> Option<T>) -> bool {
    let deadline = Instant::now() + patience;
    while Instant::now() < deadline {
        if check().is_some() {
            return false;
        }
        std::thread::sleep(Duration::from_millis(50));
    }
    check().is_none()
}

/// The primary's deploy key, ENROLLED ON THE REPLICA as a `Machine` with
/// namespace admin — the identity the send-pack authenticates with AND the one
/// the Public → Private delete is signed with. Returns the key file's path on
/// the primary's side (the same temp dir serves both here).
fn enrol_machine_on(replica: &Harness, namespace: &str) -> std::path::PathBuf {
    let private = gunnar_core::mint_ed25519().expect("mint the deploy key");
    let parsed = gunnar_core::ssh_key::PrivateKey::from_openssh(&private).expect("parses");
    let public = parsed.public_key().to_openssh().expect("render");
    let path = replica.dir.path().join("deploy_key");
    std::fs::write(&path, &private).expect("write the deploy key");
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod");
    }
    let auth = replica.stores.auth();
    auth.add_principal(
        Principal::new("primary", "primary", PrincipalKind::Machine).expect("valid"),
    )
    .expect("add the machine principal");
    auth.register_key("primary", &public, None)
        .expect("register the deploy key");
    auth.grant(Grant::namespace("primary", namespace, Level::Admin))
        .expect("namespace admin, as push-create needs");
    path
}

/// A `PublicOnly` target aimed at `replica`, with the delete path wired.
fn public_only_target(replica: &Harness, identity: std::path::PathBuf) -> ReplicationConfig {
    ReplicationConfig {
        targets: vec![ReplicationTarget {
            name: "browse".into(),
            scope: ReplicationScope::Server,
            remote: format!("ssh://git@127.0.0.1:{}", replica.ssh_port),
            identity: Some(identity),
            known_hosts: Some(replica.dir.path().join("replication_known_hosts")),
            strict_host_key: false,
            visibility: VisibilityScope::PublicOnly,
            control: Some(
                replica
                    .grpc_addr
                    .expect("the replica runs its control plane")
                    .to_string(),
            ),
        }],
        retry_base_ms: 200,
        retry_max_ms: 2_000,
        ..ReplicationConfig::default()
    }
}

fn plane_of(unit: &Harness) -> gunnar_cli::server::CoreControlPlane {
    let slot = Arc::clone(
        unit.server
            .as_ref()
            .expect("the server is running")
            .replication_slot(),
    );
    gunnar_cli::server::CoreControlPlane::new(Arc::clone(&unit.stores)).with_replication(slot)
}

fn remote_ref(harness: &Harness, namespace: &str, repo: &str, rev: &str) -> Option<String> {
    let url = harness.url(namespace, repo);
    let out = harness.git(harness.dir.path(), &["ls-remote", &url, rev]);
    if !out.ok {
        return None;
    }
    out.stdout.lines().find_map(|line| {
        let (oid, name) = line.split_once('\t')?;
        (name.trim() == rev).then(|| oid.trim().to_owned())
    })
}

/// `Repos.Delete{purge}` against `primary`'s own control plane.
fn delete_over_grpc(primary: &Harness, namespace: &str, repo: &str) {
    let channel = primary
        .runtime
        .block_on(
            tonic::transport::Endpoint::from_shared(primary.grpc_url())
                .expect("a gRPC endpoint")
                .connect(),
        )
        .expect("connect to the primary's control plane");
    let mut repos = gunnar_proto::repos_client::ReposClient::new(channel);
    primary
        .runtime
        .block_on(repos.delete(primary.authed(
            "Repos.Delete",
            gunnar_proto::DeleteRepoRequest {
                path: Some(gunnar_proto::RepoPath::new(namespace, repo)),
                purge: true,
                ..Default::default()
            },
        )))
        .expect("Repos.Delete over the real control plane");
}

fn set_visibility_over_grpc(primary: &Harness, namespace: &str, repo: &str, to: Visibility) {
    let channel = primary
        .runtime
        .block_on(
            tonic::transport::Endpoint::from_shared(primary.grpc_url())
                .expect("a gRPC endpoint")
                .connect(),
        )
        .expect("connect to the primary's control plane");
    let mut repos = gunnar_proto::repos_client::ReposClient::new(channel);
    let visibility = match to {
        Visibility::Public => gunnar_proto::Visibility::Public,
        Visibility::Private => gunnar_proto::Visibility::Private,
    };
    primary
        .runtime
        .block_on(repos.set_visibility(primary.authed(
            "Repos.SetVisibility",
            gunnar_proto::SetVisibilityRequest {
                path: Some(gunnar_proto::RepoPath::new(namespace, repo)),
                visibility: visibility as i32,
            },
        )))
        .expect("Repos.SetVisibility over the real control plane");
}

/// Mechanism 1: the visibility filter at enqueue. In-process, no network: a
/// primary with one `PublicOnly` target and one `All` target, a private and a
/// public repository, and `enqueue` asked for each.
#[test]
fn a_public_only_target_is_offered_public_repositories_and_never_private_ones() {
    if !tools_present() {
        eprintln!("skipping: git and ssh are required");
        return;
    }
    let replica = Harness::start_with_options(
        "nordisk",
        "gunnar",
        HashKind::Sha256,
        HarnessOptions {
            grpc: true,
            ..HarnessOptions::default()
        },
    );
    let identity = enrol_machine_on(&replica, "nordisk");
    let mut config = public_only_target(&replica, identity.clone());
    config.targets.push(ReplicationTarget {
        name: "standby".into(),
        scope: ReplicationScope::Server,
        remote: format!("ssh://git@127.0.0.1:{}", replica.ssh_port),
        identity: Some(identity),
        known_hosts: Some(replica.dir.path().join("replication_known_hosts")),
        strict_host_key: false,
        visibility: VisibilityScope::All,
        control: None,
    });
    // The subscriber is what would drain the queues; keep it off so what
    // `enqueue` returns is what this test reads, not a race with a worker.
    config.subscriber_disabled = true;
    let primary = Harness::start_replicating("nordisk", "gunnar", HashKind::Sha256, config);
    let private = RepoRef::new("nordisk", "gunnar").expect("legal");
    let public = RepoRef::new("nordisk", "open").expect("legal");
    primary
        .stores
        .catalog()
        .create(
            gunnar_core::NewRepo::new(public.clone())
                .hash_kind(HashKind::Sha256)
                .visibility(Visibility::Public),
        )
        .expect("create the public repository");

    let offered = |path: &RepoRef| -> Vec<String> {
        let mut names: Vec<String> = primary
            .replicator()
            .enqueue(path)
            .into_iter()
            .map(|(name, _)| name)
            .collect();
        names.sort();
        names
    };
    assert_eq!(
        offered(&private),
        vec!["standby".to_owned()],
        "a PRIVATE repository must be offered to the standby and NEVER to the public-only target"
    );
    assert_eq!(
        offered(&public),
        vec!["browse".to_owned(), "standby".to_owned()],
        "a PUBLIC repository is offered to both"
    );
    // The forget side: only the public-only target is told, and it is told
    // regardless of the repository's present visibility (the worker decides).
    let mut forget: Vec<String> = primary
        .replicator()
        .enqueue_forget(&private)
        .into_iter()
        .map(|(name, _)| name)
        .collect();
    forget.sort();
    assert_eq!(
        forget,
        vec!["browse".to_owned()],
        "a forget is a public-only target's job alone; a standby keeps everything"
    );
}

/// Mechanism 2, end to end over two real servers: a public repository reaches
/// the browse replica; flipping it Private on the primary DELETES it on the
/// replica through `Repos.Delete` signed as the primary's machine; flipping it
/// back brings it back. The replica is in the BROWSE role throughout, so the
/// delete is also proof that the browse box's mutator refusal lets exactly this
/// write through.
#[test]
fn a_repository_that_goes_private_is_forgotten_by_the_browse_replica() {
    if !tools_present() {
        eprintln!("skipping: git and ssh are required");
        return;
    }
    let replica = Harness::start_with_options(
        "nordisk",
        "gunnar",
        HashKind::Sha256,
        HarnessOptions {
            grpc: true,
            visibility: Visibility::Public,
            // The replica refuses a method-blind credential, as every 5.3.3
            // listener does (stinky row 2): the primary's `Repos.Delete` and
            // `Repos.Create` must be signed for the method they call, or the
            // forget never lands.
            ..HarnessOptions::default()
        },
    );
    let identity = enrol_machine_on(&replica, "nordisk");
    plane_of(&replica)
        .replication_set_role(ReplicationRole::Browse)
        .expect("make the replica a browse box");
    assert!(replica.stores.browse().load(SeqCst));

    let primary = Harness::start_replicating_with_options(
        "nordisk",
        "gunnar",
        HashKind::Sha256,
        HarnessOptions {
            grpc: true,
            visibility: Visibility::Public,
            ..HarnessOptions::default()
        },
        public_only_target(&replica, identity),
    );
    // The tester flips the repository's visibility, which is the ACCOUNT
    // admin's decision (owner, 2026-10-03; enterprise-visibility 75fdf86):
    // Admin over the namespace, not over the one repository.
    primary
        .stores
        .auth()
        .grant(Grant::namespace("tester", "nordisk", Level::Admin))
        .expect("grant admin");

    let work = primary.dir.path().join("work");
    let head = local_repo(&primary, &work, HashKind::Sha256, "public content\n");
    primary
        .git(
            &work,
            &[
                "push",
                &primary.url("nordisk", "gunnar"),
                "main:refs/heads/main",
            ],
        )
        .expect_ok("push to the primary");
    let replicated = wait_for("the public repository to reach the browse replica", || {
        remote_ref(&replica, "nordisk", "gunnar", "refs/heads/main")
    });
    assert_eq!(
        replicated, head,
        "the replica's main must be the pushed commit"
    );

    // Public → Private on the PRIMARY, over its real control plane, so the
    // VisibilityChanged event is the one the service publishes.
    set_visibility_over_grpc(&primary, "nordisk", "gunnar", Visibility::Private);
    let path = RepoRef::new("nordisk", "gunnar").expect("legal");
    // "Done" is the primary's word: the counter moves after the replica's
    // `Repos.Delete` has RETURNED, i.e. after catalog row AND directory are
    // gone. Reading the replica's catalog alone would race the purge that
    // follows the row's removal.
    let counters = wait_for("the primary to record the forget", || {
        let counters = primary.replicator().counters();
        (counters.forgotten >= 1).then_some(counters)
    });
    assert_eq!(
        counters.succeeded, counters.succeeded,
        "the forget is counted apart from syncs: {counters:?}"
    );
    assert!(
        matches!(replica.stores.catalog().get(&path), Ok(None)),
        "the replica's catalog must no longer hold the repository"
    );
    assert!(
        remote_ref(&replica, "nordisk", "gunnar", "refs/heads/main").is_none(),
        "the replica must not advertise a ref of a repository it was told to forget"
    );
    let dir = replica
        .stores
        .repo_dir(&path)
        .expect("the replica's layout resolves the path whether or not it exists");
    assert!(
        !dir.is_dir(),
        "the delete is a purge: the bytes are gone, not tombstoned ({})",
        dir.display()
    );

    // While private, a further push must NOT reach the replica.
    let work2 = primary.dir.path().join("work2");
    let head2 = local_repo(&primary, &work2, HashKind::Sha256, "private content\n");
    primary
        .git(
            &work2,
            &[
                "push",
                "--force",
                &primary.url("nordisk", "gunnar"),
                "main:refs/heads/main",
            ],
        )
        .expect_ok("push while private");
    assert!(
        never_within(Duration::from_secs(2), || {
            remote_ref(&replica, "nordisk", "gunnar", "refs/heads/main")
        }),
        "a push to a PRIVATE repository must never reach the public-only replica"
    );

    // Private → Public: the repository comes back. NOT push-created anew: the
    // replica push-creates sha1 (`push_create_spec`) and this repository is
    // sha256, so the replicator `Repos.Create`s it on the replica in its own
    // format — signed as the delete was — and then pushes. MEASURED red
    // 2026-09-22 before that: `replication.incompatible … Sha256 but browse is
    // Sha1`, and the wait here ran out.
    set_visibility_over_grpc(&primary, "nordisk", "gunnar", Visibility::Public);
    let back = wait_for("the repository to return to the browse replica", || {
        remote_ref(&replica, "nordisk", "gunnar", "refs/heads/main")
    });
    assert_eq!(
        back, head2,
        "the replica must hold the CURRENT main once public again"
    );
}

/// **Mechanism 2b (GUN-012): a repository that is DELETED is forgotten too.**
///
/// `Repos.Delete` published its event and the replication subscriber did not
/// ask for it — `EventFilter` listed `RefUpdated`, `RefDeleted` and
/// `VisibilityChanged`, and the match arm for anything else was `_ => {}`. So
/// the strongest instruction an operator can give about who may read a
/// repository was the one instruction that never left the box: the primary
/// forgot it and the browse replica went on serving it, indefinitely, with no
/// state anywhere recording the divergence.
///
/// The assertion is on the REPLICA, through the same two doors the
/// visibility test uses: its catalog no longer holds the repository, and it no
/// longer advertises the ref — which is what a stranger would see.
///
/// MEASURED before the fix: the primary's `forgotten` counter stayed 0, the
/// replica's catalog still held `nordisk/gunnar`, and `ls-remote` against the
/// replica still answered with the commit.
#[test]
fn a_repository_that_is_deleted_is_forgotten_by_the_browse_replica() {
    if !tools_present() {
        eprintln!("skipping: git and ssh are required");
        return;
    }
    let replica = Harness::start_with_options(
        "nordisk",
        "gunnar",
        HashKind::Sha256,
        HarnessOptions {
            grpc: true,
            visibility: Visibility::Public,
            ..HarnessOptions::default()
        },
    );
    let identity = enrol_machine_on(&replica, "nordisk");
    plane_of(&replica)
        .replication_set_role(ReplicationRole::Browse)
        .expect("make the replica a browse box");

    let primary = Harness::start_replicating_with_options(
        "nordisk",
        "gunnar",
        HashKind::Sha256,
        HarnessOptions {
            grpc: true,
            visibility: Visibility::Public,
            ..HarnessOptions::default()
        },
        public_only_target(&replica, identity),
    );
    primary
        .stores
        .auth()
        .grant(Grant::repo(
            "tester",
            RepoRef::new("nordisk", "gunnar").expect("legal"),
            Level::Admin,
        ))
        .expect("grant admin");

    let work = primary.dir.path().join("work");
    let head = local_repo(&primary, &work, HashKind::Sha256, "public content\n");
    primary
        .git(
            &work,
            &[
                "push",
                &primary.url("nordisk", "gunnar"),
                "main:refs/heads/main",
            ],
        )
        .expect_ok("push to the primary");
    let replicated = wait_for("the public repository to reach the browse replica", || {
        remote_ref(&replica, "nordisk", "gunnar", "refs/heads/main")
    });
    assert_eq!(
        replicated, head,
        "the fixture must actually be on the replica first"
    );

    // …and now delete it on the PRIMARY, over its real control plane, so the
    // event is the one `Repos.Delete` publishes.
    delete_over_grpc(&primary, "nordisk", "gunnar");
    let path = RepoRef::new("nordisk", "gunnar").expect("legal");
    assert!(
        matches!(primary.stores.catalog().get(&path), Ok(None)),
        "the fixture must have deleted it on the primary"
    );

    wait_for("the primary to record the forget", || {
        let counters = primary.replicator().counters();
        (counters.forgotten >= 1).then_some(counters)
    });
    assert!(
        matches!(replica.stores.catalog().get(&path), Ok(None)),
        "the replica's catalog still holds a repository the primary deleted"
    );
    assert!(
        remote_ref(&replica, "nordisk", "gunnar", "refs/heads/main").is_none(),
        "the replica still advertises a deleted repository to a stranger"
    );
}

/// **GUN-011: a resync DERIVES the forget instead of trusting a one-shot
/// event.**
///
/// `VisibilityChanged` fires exactly once. On a full queue `enqueue_forget`
/// answered `RefusedQueueFull` and the instruction was simply gone — no retry,
/// no flag, no second event — and the repository stayed readable on the browse
/// replica. A resync now offers every Private repository in scope as a forget,
/// which is derivable from the catalog alone: it survives a restart and needs
/// no in-memory history.
///
/// The replicator is built directly and never spawned — no server, no bus
/// traffic, nothing draining — so the queue after the resync is the applied
/// output rather than a race.
///
/// MEASURED before the change: `pending` was 1 after the resync, the public
/// repository alone; the private one was counted as `private_skipped` and
/// otherwise ignored.
#[test]
fn a_resync_offers_a_public_only_replica_the_forget_for_a_private_repository() {
    let dir = tempfile::tempdir().expect("a temp dir");
    let stores = Arc::new(
        gunnar_cli::server::ServerStores::open(dir.path().join("data")).expect("open the stores"),
    );
    stores.catalog().create_namespace("ns").expect("namespace");
    for (name, visibility) in [("shut", Visibility::Private), ("open", Visibility::Public)] {
        stores
            .catalog()
            .create(
                gunnar_core::NewRepo::new(RepoRef::new("ns", name).expect("legal"))
                    .hash_kind(HashKind::Sha256)
                    .visibility(visibility),
            )
            .expect("create the repository");
    }

    let config = ReplicationConfig {
        targets: vec![ReplicationTarget {
            name: "browse".into(),
            scope: ReplicationScope::Server,
            // Nothing is ever dialled: no worker is spawned.
            remote: "ssh://git@127.0.0.1:1".into(),
            identity: None,
            known_hosts: Some(dir.path().join("known_hosts")),
            strict_host_key: false,
            visibility: VisibilityScope::PublicOnly,
            control: Some("127.0.0.1:1".into()),
        }],
        ..ReplicationConfig::default()
    };
    let replicator = Replicator::new(Arc::clone(&stores), Arc::new(EventBus::new(16)), config);

    let queued = replicator.resync().expect("resync");
    assert_eq!(
        queued, 1,
        "one PUBLIC repository is in scope to be SHIPPED: {queued}"
    );
    assert_eq!(
        replicator.status()[0].pending,
        2,
        "the private repository must be queued too — as a forget, which the worker decides at \
         take time — or a repository made private while the queue was full stays readable on \
         the browse replica for ever"
    );

    // Repeating it is the resume, not a second queue entry.
    replicator.resync().expect("resync again");
    assert_eq!(
        replicator.status()[0].pending,
        2,
        "a repeated resync must coalesce, not grow the queue"
    );

    // …and an `All` target is untouched by any of this: a standby keeps
    // everything, so both repositories are ships and neither is a forget.
    let standby = ReplicationConfig {
        targets: vec![ReplicationTarget {
            name: "standby".into(),
            scope: ReplicationScope::Server,
            remote: "ssh://git@127.0.0.1:1".into(),
            identity: None,
            known_hosts: Some(dir.path().join("known_hosts")),
            strict_host_key: false,
            visibility: VisibilityScope::All,
            control: None,
        }],
        ..ReplicationConfig::default()
    };
    let plain = Replicator::new(stores, Arc::new(EventBus::new(16)), standby);
    assert_eq!(plain.resync().expect("resync"), 2, "a standby ships both");
    assert_eq!(plain.status()[0].pending, 2);
}

/// Mechanism 3: the role itself. A browse box is frozen like a standby, is
/// refused `PRIMARY` with FailedPrecondition, accepts `STANDBY`, and the flags
/// the front doors and the control plane read follow the role exactly.
#[test]
fn a_browse_replica_cannot_be_promoted_and_is_frozen() {
    if !tools_present() {
        eprintln!("skipping: git and ssh are required");
        return;
    }
    let unit = Harness::start_with_options(
        "nordisk",
        "gunnar",
        HashKind::Sha256,
        HarnessOptions {
            visibility: Visibility::Public,
            ..HarnessOptions::default()
        },
    );
    let plane = plane_of(&unit);
    let frozen = || unit.stores.writes_frozen().load(SeqCst);
    let browse = || unit.stores.browse().load(SeqCst);
    assert!(!frozen() && !browse(), "a fresh box is a writable primary");

    let status = plane
        .replication_set_role(ReplicationRole::Browse)
        .expect("set browse");
    assert_eq!(status.role, ReplicationRole::Browse as i32);
    assert!(frozen(), "a browse replica refuses client writes");
    assert!(browse(), "and the browse flag is up");

    // A client push is refused, and the refusal names what the box is.
    let work = unit.dir.path().join("work");
    local_repo(&unit, &work, HashKind::Sha256, "content\n");
    let push = unit.git(
        &work,
        &[
            "push",
            &unit.url("nordisk", "gunnar"),
            "main:refs/heads/main",
        ],
    );
    assert!(!push.ok, "a client push to a browse replica must fail");
    assert!(
        push.stderr.contains("browse replica"),
        "the refusal must say the box is a browse replica, not merely frozen: {}",
        push.stderr
    );

    // But a stranger may still READ the public repository (it is empty; the
    // advertisement is the read).
    let anon = unit.git_anonymous(
        unit.dir.path(),
        &["ls-remote", &unit.anonymous_url("nordisk", "gunnar")],
    );
    assert!(
        anon.ok,
        "a browse replica serves anonymous reads: {}",
        anon.stderr
    );

    // PRIMARY is refused, by precondition, and nothing moved.
    let err = plane
        .replication_set_role(ReplicationRole::Primary)
        .expect_err("a browse replica must refuse promotion");
    assert!(
        matches!(err, gunnar_grpc::seam::ControlError::FailedPrecondition(_)),
        "wrong refusal: {err:?}"
    );
    assert!(frozen() && browse(), "a refused promotion changes nothing");
    let on_disk = std::fs::read_to_string(unit.stores.root().join("replication.json"))
        .expect("replication.json");
    assert!(
        on_disk.contains("Browse"),
        "the role on disk is still Browse: {on_disk}"
    );

    // STANDBY narrows: still frozen, no longer browse — and from there,
    // promotion is the operator's call again.
    plane
        .replication_set_role(ReplicationRole::Standby)
        .expect("narrow to standby");
    assert!(
        frozen() && !browse(),
        "a standby is frozen and not a browse box"
    );
    plane
        .replication_set_role(ReplicationRole::Primary)
        .expect("a standby may be promoted");
    assert!(!frozen() && !browse());
}

/// Mechanism 4: the knob. A primary with `--anonymous refuse --browse-host X`
/// refuses a stranger's read of a PUBLIC repository and names X; the same
/// stranger with a registered key is served. A box in the BROWSE role with the
/// same knob serves the stranger anyway: the knob points at it.
#[test]
fn anonymous_refuse_names_the_browse_host_and_a_browse_box_serves_anyway() {
    if !tools_present() {
        eprintln!("skipping: git and ssh are required");
        return;
    }
    let refusing = || HarnessOptions {
        visibility: Visibility::Public,
        anonymous: Anonymous::Refuse {
            browse_host: Some("browse.example.org:2222"),
        },
        ..HarnessOptions::default()
    };
    let primary = Harness::start_with_options("nordisk", "gunnar", HashKind::Sha256, refusing());
    let anon = primary.git_anonymous(
        primary.dir.path(),
        &["ls-remote", &primary.anonymous_url("nordisk", "gunnar")],
    );
    assert!(!anon.ok, "a stranger must be refused on the primary");
    assert!(
        anon.stderr.contains("browse.example.org:2222"),
        "the refusal must name the browse host: {}",
        anon.stderr
    );
    assert!(
        anon.stderr.contains("nordisk/gunnar"),
        "and the repository, so `git clone` can be retyped: {}",
        anon.stderr
    );
    let keyed = primary.git(
        primary.dir.path(),
        &["ls-remote", &primary.url("nordisk", "gunnar")],
    );
    assert!(
        keyed.ok,
        "a registered key is served as before: {}",
        keyed.stderr
    );

    let browse = Harness::start_with_options("nordisk", "gunnar", HashKind::Sha256, refusing());
    plane_of(&browse)
        .replication_set_role(ReplicationRole::Browse)
        .expect("set browse");
    let served = browse.git_anonymous(
        browse.dir.path(),
        &["ls-remote", &browse.anonymous_url("nordisk", "gunnar")],
    );
    assert!(
        served.ok,
        "a browse replica serves the stranger whatever --anonymous says: {}",
        served.stderr
    );
}