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

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

//! **`gunnar control mirror`, driven as a real binary against a real control
//! plane and a real forge — both of them doubles on loopback.**
//!
//! Every assertion here is about what the `gunnar` binary PRINTS and what it
//! EXITS with, because that is the whole of what an operator gets. Nothing
//! calls a library function the CLI happens to also call.
//!
//! # Why a scripted `Mirror` service and not a `gunnar serve`
//!
//! `tests/control_cli.rs` starts a real server, and it is right to: the four
//! bootstrap steps are about a real key table. This file is about the two
//! things a real server cannot be asked to produce on cue —
//!
//! * a job that is QUEUED, then RUNNING with no denominator, then RUNNING with
//!   one, then DONE with ref updates, in that order and within a test's
//!   patience; and
//! * a job that FAILED for one exact reason, so the sentence the CLI prints can
//!   be compared with the sentence the server would have written.
//!
//! A real `Mirror.Sync` needs somebody else's network at the other end. What is
//! under test is the CLI's rendering and its exit code, so the states are
//! scripted and the transport, the credential signing, the argument parsing and
//! the printing are all the shipped ones.
//!
//! The wrong-kind refusal is NOT invented here: the string the script fails a
//! job with is `gunnar_server::mirror_fetch::check_credential`'s own, copied in
//! the one test that asserts it, so a change to that sentence shows up as a
//! failure to render rather than as two texts drifting apart.
//!
//! # The forge side is `gunnar-forge`'s own double
//!
//! `gunnar_forge::testing::MockServer` — the loopback HTTP server every test in
//! that crate already runs against, exported for exactly this. A second HTTP
//! double here would be a second thing to get subtly wrong (one implementation).

use std::collections::{BTreeMap, VecDeque};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};

use gunnar_proto::mirror_server::{Mirror, MirrorServer};
use gunnar_proto::{
    AbdicateReplicationRequest, AddMirrorRequest, AddMirrorResponse, DemoteReplicationRequest,
    MirrorConfig, MirrorCredentialKind, MirrorJob, MirrorJobState, MirrorState,
    MirrorStatusRequest, MirrorStatusResponse, RefUpdate, RemoveMirrorRequest,
    RemoveMirrorResponse, RemoveReplicationRequest, ReplicationStatusRequest,
    ReplicationStatusResponse, RepoPath, ResyncReplicationRequest, ResyncReplicationResponse,
    SetMirrorCaRequest, SetMirrorCaResponse, SetMirrorCredentialRequest,
    SetMirrorCredentialResponse, SetReplicationRequest, SetRoleRequest, SyncMirrorRequest,
    SyncMirrorResponse,
};
use tonic::{Request, Response, Status};

// ───────────────────────────────────────────────────────────────────────────
// A key, so the CLI can sign
// ───────────────────────────────────────────────────────────────────────────

/// **A throwaway ed25519 key, minted for this file and enrolled nowhere.**
///
/// Every `gunnar control` call signs a fresh single-use `sshsig` credential with
/// the key `-i` names, and refuses to make the call at all when there is none —
/// so a test that drives the binary needs a private key on disk before it can
/// reach the transport. The double below does not verify it; what it is here for
/// is that the CLI's own credential path is the shipped one and not bypassed.
///
/// A literal rather than a generated key: generating one would mean a key
/// library in `[dev-dependencies]` for a value that never changes, and a fixture
/// whose bytes are in the file is a fixture nobody has to reproduce.
const TEST_KEY: &str = "\
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACB+ZTMETSyezK7EBXWKRTreU1fplkiMdvg6m4j2EQw3BAAAAKCVmo+KlZqP
igAAAAtzc2gtZWQyNTUxOQAAACB+ZTMETSyezK7EBXWKRTreU1fplkiMdvg6m4j2EQw3BA
AAAEBcWDsmkthmRt/0eruV322IHuaS5w2p+vwgLf8pZ3Blpn5lMwRNLJ7MrsQFdYpFOt5T
V+mWSIx2+DqbiPYRDDcEAAAAGmd1bm5hci1jb250cm9sLW1pcnJvci10ZXN0AQID
-----END OPENSSH PRIVATE KEY-----
";

// ───────────────────────────────────────────────────────────────────────────
// The scripted control plane
// ───────────────────────────────────────────────────────────────────────────

#[derive(Default)]
struct Plane {
    /// `"<ns>/<repo>"` → what `Status` reports for it.
    mirrors: BTreeMap<String, MirrorConfig>,
    /// `"<ns>/<repo>"` → the job each successive `Status` call hands back.
    /// The last entry repeats once the queue is drained, so a poll that runs
    /// one round longer than the script sees a settled job rather than a hole.
    script: BTreeMap<String, VecDeque<MirrorJob>>,
    /// What the CLI asked for, in order: `(path, url, prune, create_repo)`.
    added: Vec<(String, String, bool, bool)>,
    synced: Vec<String>,
    /// `(path, kind, username)` — never the secret. See `secrets`.
    credentials: Vec<(String, i32, String)>,
    /// Every secret the CLI actually sent, kept ONLY so the "it never appears in
    /// the output" assertion is not vacuous: a CLI that sent nothing would pass
    /// that test trivially, and this is what tells the two apart.
    secrets: Vec<String>,
    next_job: u32,
}

#[derive(Clone, Default)]
struct Scripted(Arc<Mutex<Plane>>);

fn key(path: &RepoPath) -> String {
    format!("{}/{}", path.namespace, path.repo)
}

fn need(repo: Option<RepoPath>) -> Result<RepoPath, Status> {
    repo.ok_or_else(|| Status::invalid_argument("repo is required"))
}

#[tonic::async_trait]
impl Mirror for Scripted {
    async fn add(
        &self,
        request: Request<AddMirrorRequest>,
    ) -> Result<Response<AddMirrorResponse>, Status> {
        let request = request.into_inner();
        let path = need(request.repo)?;
        let name = key(&path);
        let mut plane = self.0.lock().unwrap();
        plane.added.push((
            name.clone(),
            request.remote_url.clone(),
            request.prune,
            request.create_repo,
        ));
        // **Adding an existing mirror is a no-op, not an error** — that is the
        // rule `import --all` re-run has to be idempotent against, so the double
        // has to honour it or the idempotence test would pass for the wrong
        // reason. The stored job survives, because `Add` does not enqueue.
        let entry = plane.mirrors.entry(name).or_default();
        entry.repo = Some(path);
        entry.remote_url = request.remote_url;
        entry.prune = request.prune;
        entry.state = MirrorState::Idle as i32;
        Ok(Response::new(AddMirrorResponse {
            mirror: Some(entry.clone()),
        }))
    }

    async fn sync(
        &self,
        request: Request<SyncMirrorRequest>,
    ) -> Result<Response<SyncMirrorResponse>, Status> {
        let path = need(request.into_inner().repo)?;
        let name = key(&path);
        let mut plane = self.0.lock().unwrap();
        if !plane.mirrors.contains_key(&name) {
            return Err(Status::not_found(format!(
                "no mirror is registered for {name}"
            )));
        }
        plane.synced.push(name.clone());
        let existing = plane.mirrors[&name].job.clone();
        // Double `Sync` is ONE job: an already-live job is handed back rather
        // than a second one made.
        let job = match existing {
            Some(job)
                if job.state == MirrorJobState::Queued as i32
                    || job.state == MirrorJobState::Running as i32 =>
            {
                job
            }
            _ => {
                plane.next_job += 1;
                // A scripted path enqueues under the id its script uses, so the
                // CLI's job-id continuity check follows the same job it is being
                // shown. Without this the double would hand out `mirror-1` and
                // then answer with `mirror-2`, which is a REAL condition (a
                // second sync replacing yours) and not the one under test.
                let job_id = plane
                    .script
                    .get(&name)
                    .and_then(|queue| queue.front())
                    .map(|job| job.job_id.clone())
                    .unwrap_or_else(|| format!("mirror-{}", plane.next_job));
                MirrorJob {
                    job_id,
                    path: Some(path.clone()),
                    state: MirrorJobState::Queued as i32,
                    queue_position: 2,
                    ..MirrorJob::default()
                }
            }
        };
        let mirror = plane.mirrors.get_mut(&name).expect("present");
        mirror.job = Some(job.clone());
        mirror.state = MirrorState::Syncing as i32;
        Ok(Response::new(SyncMirrorResponse {
            mirror: Some(mirror.clone()),
            // 0 and empty AT ENQUEUE, exactly as the server does it.
            refs_changed: 0,
            updates: Vec::new(),
            job: Some(job),
        }))
    }

    async fn status(
        &self,
        request: Request<MirrorStatusRequest>,
    ) -> Result<Response<MirrorStatusResponse>, Status> {
        let request = request.into_inner();
        let mut plane = self.0.lock().unwrap();
        let wanted = request.repo.map(|path| key(&path));
        if let Some(name) = &wanted {
            if !plane.mirrors.contains_key(name) {
                return Err(Status::not_found(format!(
                    "no mirror is registered for {name}"
                )));
            }
            // Advance the script one step per poll.
            let next = plane.script.get_mut(name).and_then(|queue| {
                if queue.len() > 1 {
                    queue.pop_front()
                } else {
                    queue.front().cloned()
                }
            });
            if let Some(job) = next {
                let mirror = plane.mirrors.get_mut(name).expect("present");
                mirror.state = match MirrorJobState::try_from(job.state) {
                    Ok(MirrorJobState::Failed) => MirrorState::Failed as i32,
                    Ok(MirrorJobState::Done) => MirrorState::Idle as i32,
                    _ => MirrorState::Syncing as i32,
                };
                mirror.job = Some(job);
            }
        }
        let mirrors = plane
            .mirrors
            .iter()
            .filter(|(name, _)| wanted.as_ref().is_none_or(|w| w == *name))
            .map(|(_, mirror)| mirror.clone())
            .collect();
        Ok(Response::new(MirrorStatusResponse { mirrors }))
    }

    async fn remove(
        &self,
        request: Request<RemoveMirrorRequest>,
    ) -> Result<Response<RemoveMirrorResponse>, Status> {
        let path = need(request.into_inner().repo)?;
        let name = key(&path);
        let mut plane = self.0.lock().unwrap();
        if plane.mirrors.remove(&name).is_none() {
            return Err(Status::not_found(format!(
                "no mirror is registered for {name}"
            )));
        }
        Ok(Response::new(RemoveMirrorResponse {}))
    }

    async fn set_credential(
        &self,
        request: Request<SetMirrorCredentialRequest>,
    ) -> Result<Response<SetMirrorCredentialResponse>, Status> {
        let request = request.into_inner();
        let path = need(request.repo)?;
        let name = key(&path);
        let mut plane = self.0.lock().unwrap();
        if !request.secret.is_empty() {
            plane.secrets.push(request.secret.clone());
        }
        if !plane.mirrors.contains_key(&name) {
            return Err(Status::not_found(format!(
                "no mirror is registered for {name}"
            )));
        }
        plane
            .credentials
            .push((name.clone(), request.kind, request.username.clone()));
        let mirror = plane.mirrors.get_mut(&name).expect("present");
        mirror.credential = request.kind;
        // The username rides back; the SECRET never does, in any field.
        mirror.credential_username = if request.kind == MirrorCredentialKind::Token as i32 {
            request.username
        } else {
            String::new()
        };
        Ok(Response::new(SetMirrorCredentialResponse {
            mirror: Some(mirror.clone()),
        }))
    }

    /// GUN-MIRROR-CA: the CA rides in as typed; the double answers with a subject when set.
    async fn set_ca(
        &self,
        request: Request<SetMirrorCaRequest>,
    ) -> Result<Response<SetMirrorCaResponse>, Status> {
        let request = request.into_inner();
        let path = need(request.repo)?;
        let name = key(&path);
        let mut plane = self.0.lock().unwrap();
        let mirror = plane
            .mirrors
            .get_mut(&name)
            .ok_or_else(|| Status::not_found(format!("no mirror is registered for {name}")))?;
        if request.ca_pem.is_empty() {
            mirror.ca_subject.clear();
            mirror.ca_fingerprint_sha256.clear();
        } else if request.ca_pem.contains("-----BEGIN CERTIFICATE-----") {
            mirror.ca_subject = "CN=Acme Private Root".to_owned();
            mirror.ca_fingerprint_sha256 = "ab".repeat(32);
        } else {
            return Err(Status::invalid_argument(
                "the mirror's CA holds no certificate: paste it as PEM",
            ));
        }
        Ok(Response::new(SetMirrorCaResponse {
            mirror: Some(mirror.clone()),
        }))
    }

    // ── replication: not this file's subject ────────────────────────────────
    async fn set_replication(
        &self,
        _: Request<SetReplicationRequest>,
    ) -> Result<Response<ReplicationStatusResponse>, Status> {
        Err(Status::unimplemented(
            "replication is not this double's job",
        ))
    }
    async fn remove_replication(
        &self,
        _: Request<RemoveReplicationRequest>,
    ) -> Result<Response<ReplicationStatusResponse>, Status> {
        Err(Status::unimplemented(
            "replication is not this double's job",
        ))
    }
    async fn demote_replication(
        &self,
        _: Request<DemoteReplicationRequest>,
    ) -> Result<Response<ReplicationStatusResponse>, Status> {
        Err(Status::unimplemented(
            "replication is not this double's job",
        ))
    }
    async fn set_role(
        &self,
        _: Request<SetRoleRequest>,
    ) -> Result<Response<ReplicationStatusResponse>, Status> {
        Err(Status::unimplemented(
            "replication is not this double's job",
        ))
    }
    async fn abdicate_replication(
        &self,
        _: Request<AbdicateReplicationRequest>,
    ) -> Result<Response<ReplicationStatusResponse>, Status> {
        Err(Status::unimplemented(
            "replication is not this double's job",
        ))
    }
    async fn replication_status(
        &self,
        _: Request<ReplicationStatusRequest>,
    ) -> Result<Response<ReplicationStatusResponse>, Status> {
        Err(Status::unimplemented(
            "replication is not this double's job",
        ))
    }
    async fn resync_replication(
        &self,
        _: Request<ResyncReplicationRequest>,
    ) -> Result<Response<ResyncReplicationResponse>, Status> {
        Err(Status::unimplemented(
            "replication is not this double's job",
        ))
    }
    async fn flush_replication(
        &self,
        _: Request<gunnar_proto::FlushReplicationRequest>,
    ) -> Result<Response<gunnar_proto::FlushReplicationResponse>, Status> {
        Err(Status::unimplemented(
            "replication is not this double's job",
        ))
    }

    type LogFetchStream = std::pin::Pin<
        Box<
            dyn tokio_stream::Stream<Item = Result<gunnar_proto::LogChunk, Status>>
                + Send
                + 'static,
        >,
    >;

    async fn log_head(
        &self,
        _: Request<gunnar_proto::LogHeadRequest>,
    ) -> Result<Response<gunnar_proto::LogHeadResponse>, Status> {
        Err(Status::unimplemented(
            "the transaction log is not this double's job",
        ))
    }

    async fn log_fetch(
        &self,
        _: Request<gunnar_proto::LogFetchRequest>,
    ) -> Result<Response<Self::LogFetchStream>, Status> {
        Err(Status::unimplemented(
            "the transaction log is not this double's job",
        ))
    }

    type SnapshotFetchStream = std::pin::Pin<
        Box<
            dyn tokio_stream::Stream<Item = Result<gunnar_proto::LogChunk, Status>>
                + Send
                + 'static,
        >,
    >;

    async fn snapshot_fetch(
        &self,
        _: Request<gunnar_proto::SnapshotFetchRequest>,
    ) -> Result<Response<Self::SnapshotFetchStream>, Status> {
        Err(Status::unimplemented(
            "the twin snapshot is not this double's job",
        ))
    }

    async fn log_retain(
        &self,
        _: Request<gunnar_proto::LogRetainRequest>,
    ) -> Result<Response<gunnar_proto::LogHeadResponse>, Status> {
        Err(Status::unimplemented(
            "the transaction log is not this double's job",
        ))
    }
}

/// A scripted `Mirror` service on an ephemeral loopback port, plus the temp
/// `HOME` the CLI finds its key in.
struct Harness {
    addr: String,
    plane: Arc<Mutex<Plane>>,
    dir: tempfile::TempDir,
    /// Kept so the runtime — and with it the server — outlives the tests.
    _runtime: tokio::runtime::Runtime,
}

impl Harness {
    fn start() -> Harness {
        let dir = tempfile::tempdir().expect("a temp dir");
        let home = dir.path().join("home");
        std::fs::create_dir_all(home.join(".ssh")).expect("mkdir ~/.ssh");
        write_private(&home.join(".ssh").join("id_ed25519"), TEST_KEY);

        let plane = Arc::new(Mutex::new(Plane::default()));
        let service = Scripted(Arc::clone(&plane));
        let runtime = tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .build()
            .expect("a runtime");
        let listener = runtime
            .block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
            .expect("bind loopback");
        let addr = listener.local_addr().expect("local addr").to_string();
        runtime.spawn(async move {
            let _ = tonic::transport::Server::builder()
                .add_service(MirrorServer::new(service))
                .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
                .await;
        });
        Harness {
            addr,
            plane,
            dir,
            _runtime: runtime,
        }
    }

    fn home(&self) -> PathBuf {
        self.dir.path().join("home")
    }

    /// Register a mirror the way `Mirror.Add` would, without going through the
    /// CLI — for tests whose subject is a later verb.
    fn preregister(&self, path: &str, url: &str) {
        let (namespace, repo) = path.split_once('/').expect("<ns>/<repo>");
        let mut plane = self.plane.lock().unwrap();
        plane.mirrors.insert(
            path.to_owned(),
            MirrorConfig {
                repo: Some(RepoPath {
                    namespace: namespace.to_owned(),
                    repo: repo.to_owned(),
                }),
                remote_url: url.to_owned(),
                state: MirrorState::Idle as i32,
                ..MirrorConfig::default()
            },
        );
    }

    /// What every successive `Mirror.Status` for `path` will answer.
    fn script(&self, path: &str, jobs: Vec<MirrorJob>) {
        self.plane
            .lock()
            .unwrap()
            .script
            .insert(path.to_owned(), jobs.into());
    }

    fn added(&self) -> Vec<(String, String, bool, bool)> {
        self.plane.lock().unwrap().added.clone()
    }
    fn synced(&self) -> Vec<String> {
        self.plane.lock().unwrap().synced.clone()
    }
    fn credentials(&self) -> Vec<(String, i32, String)> {
        self.plane.lock().unwrap().credentials.clone()
    }
    fn secrets(&self) -> Vec<String> {
        self.plane.lock().unwrap().secrets.clone()
    }

    fn run(&self, args: &[&str]) -> Ran {
        self.run_with_stdin(args, "")
    }

    fn run_with_stdin(&self, args: &[&str], stdin: &str) -> Ran {
        let mut argv: Vec<String> = args.iter().map(|a| a.to_string()).collect();
        argv.push("--at".into());
        argv.push(self.addr.clone());
        run_gunnar(&self.home(), &argv, stdin, &[])
    }

    fn run_with_env(&self, args: &[&str], stdin: &str, env: &[(&str, &str)]) -> Ran {
        let mut argv: Vec<String> = args.iter().map(|a| a.to_string()).collect();
        argv.push("--at".into());
        argv.push(self.addr.clone());
        run_gunnar(&self.home(), &argv, stdin, env)
    }
}

fn write_private(path: &Path, pem: &str) {
    let mut options = std::fs::OpenOptions::new();
    options.write(true).create(true).truncate(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt as _;
        options.mode(0o600);
    }
    let mut file = options.open(path).expect("open the key file");
    file.write_all(pem.as_bytes()).expect("write the key");
}

/// What a `gunnar` invocation did. Both streams, because the identity line goes
/// to stderr and the verb's answer to stdout, and an operator reads one screen.
struct Ran {
    ok: bool,
    out: String,
}

impl Ran {
    fn says(&self, needle: &str) -> bool {
        self.out.contains(needle)
    }
    fn assert_says(&self, needle: &str) {
        assert!(
            self.says(needle),
            "expected the output to contain {needle:?}, and it printed:\n{}",
            self.out
        );
    }
    fn assert_silent_about(&self, needle: &str) {
        assert!(
            !self.says(needle),
            "the output leaked {needle:?}:\n{}",
            self.out
        );
    }
}

fn gunnar_binary() -> PathBuf {
    let mut dir = std::env::current_exe().expect("the test binary has a path");
    dir.pop(); // deps
    dir.pop(); // debug
    let candidate = dir.join("gunnar-cli");
    assert!(
        candidate.is_file(),
        "the `gunnar-cli` binary was not built beside this test at {}; every assertion here is \
         about what that binary PRINTS, so there is nothing to fall back to",
        candidate.display()
    );
    candidate
}

fn run_gunnar(home: &Path, args: &[String], stdin: &str, env: &[(&str, &str)]) -> Ran {
    let mut command = Command::new(gunnar_binary());
    command
        .args(args)
        .env("HOME", home)
        .env_remove("GUNNAR_CONTROL_ADDR")
        .env_remove("GUNNAR_FORGE_TOKEN")
        .env_remove("GITHUB_TOKEN")
        .env_remove("CODEBERG_TOKEN")
        .env_remove("GITLAB_TOKEN")
        .env_remove("FORGEJO_TOKEN")
        .env_remove("SSH_AUTH_SOCK")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    for (name, value) in env {
        command.env(name, value);
    }
    let mut child = command.spawn().expect("run gunnar");
    child
        .stdin
        .take()
        .expect("piped")
        .write_all(stdin.as_bytes())
        .expect("write stdin");
    let out = child.wait_with_output().expect("wait for gunnar");
    Ran {
        ok: out.status.success(),
        out: format!(
            "{}{}",
            String::from_utf8_lossy(&out.stdout),
            String::from_utf8_lossy(&out.stderr)
        ),
    }
}

// ── job builders ────────────────────────────────────────────────────────────

fn job(id: &str, state: MirrorJobState) -> MirrorJob {
    MirrorJob {
        job_id: id.to_owned(),
        state: state as i32,
        ..MirrorJob::default()
    }
}

// ───────────────────────────────────────────────────────────────────────────
// add → sync → status
// ───────────────────────────────────────────────────────────────────────────

/// **The whole arc, and the progress line a human watches.**
///
/// `add` reads the mirror back rather than echoing the request; `sync --wait`
/// polls and renders each state as it moves; the DONE job carries the ref
/// updates and only then are they printed.
#[test]
fn add_then_sync_waits_and_renders_a_done_job_with_its_updates() {
    let harness = Harness::start();

    let added = harness.run(&[
        "control",
        "mirror",
        "add",
        "alice/linux",
        "https://github.com/torvalds/linux.git",
        "--create",
    ]);
    assert!(added.ok, "`mirror add` failed:\n{}", added.out);
    added.assert_says("alice/linux");
    added.assert_says("https://github.com/torvalds/linux.git");
    // Read back, not echoed: the credential is a fact about the STORED mirror.
    added.assert_says("credential");
    assert_eq!(
        harness.added(),
        vec![(
            "alice/linux".to_string(),
            "https://github.com/torvalds/linux.git".to_string(),
            false,
            true,
        )],
        "`--create` must reach the wire as create_repo, and nothing else may"
    );

    harness.script(
        "alice/linux",
        vec![
            MirrorJob {
                // No denominator yet. `(0, 0)` with `has_progress = false` is
                // "counting", NOT zero percent, and the CLI must say so.
                has_progress: false,
                ..job("mirror-1", MirrorJobState::Running)
            },
            MirrorJob {
                has_progress: true,
                progress_done: 120,
                progress_total: 400,
                ..job("mirror-1", MirrorJobState::Running)
            },
            MirrorJob {
                objects: 400,
                bytes: 1_048_576,
                updates: vec![RefUpdate {
                    ref_name: "refs/heads/main".into(),
                    old_oid: "0000000000000000000000000000000000000000".into(),
                    new_oid: "1111111111111111111111111111111111111111".into(),
                }],
                ..job("mirror-1", MirrorJobState::Done)
            },
        ],
    );

    let synced = harness.run(&["control", "mirror", "sync", "alice/linux", "--wait"]);
    assert!(synced.ok, "`mirror sync --wait` failed:\n{}", synced.out);
    // It says what it is waiting for, before it waits.
    synced.assert_says("mirror-1");
    synced.assert_says("counting");
    synced.assert_silent_about("0/0");
    synced.assert_says("120/400");
    synced.assert_says("done");
    synced.assert_says("refs/heads/main");
    assert_eq!(harness.synced(), vec!["alice/linux".to_string()]);

    // And `status` renders the settled job the same way, updates included.
    let status = harness.run(&["control", "mirror", "status", "alice/linux"]);
    assert!(status.ok, "`mirror status` failed:\n{}", status.out);
    status.assert_says("mirror-1");
    status.assert_says("done");
    status.assert_says("refs/heads/main");
}

/// **A job with no denominator says so, and a `status` on a RUNNING job does
/// not print updates it does not have.**
#[test]
fn status_says_counting_and_withholds_updates_until_done() {
    let harness = Harness::start();
    harness.preregister("alice/linux", "https://github.com/torvalds/linux.git");
    harness.script(
        "alice/linux",
        vec![MirrorJob {
            has_progress: false,
            progress_done: 0,
            progress_total: 0,
            updates: vec![RefUpdate {
                ref_name: "refs/heads/not-yet".into(),
                old_oid: "0".repeat(40),
                new_oid: "2".repeat(40),
            }],
            ..job("mirror-7", MirrorJobState::Running)
        }],
    );

    let status = harness.run(&["control", "mirror", "status", "alice/linux"]);
    assert!(status.ok, "`mirror status` failed:\n{}", status.out);
    status.assert_says("mirror-7");
    status.assert_says("counting");
    status.assert_silent_about("0/0");
    // A ref update on a job that has not finished is a partial plan; the whole
    // plan lands in one compare-and-swap or none of it does, so printing it
    // would be reporting a ref that has not moved.
    status.assert_silent_about("refs/heads/not-yet");
}

/// **`--wait` on a job that FAILED exits non-zero and names the reason.**
///
/// A zero exit with a failure printed is a script that carries on.
#[test]
fn wait_on_a_failed_job_exits_non_zero_and_names_the_error() {
    let harness = Harness::start();
    harness.preregister("alice/linux", "https://github.com/torvalds/linux.git");
    harness.script(
        "alice/linux",
        vec![
            job("mirror-2", MirrorJobState::Running),
            MirrorJob {
                error: "the upstream refused the connection".into(),
                ..job("mirror-2", MirrorJobState::Failed)
            },
        ],
    );

    let ran = harness.run(&["control", "mirror", "sync", "alice/linux", "--wait"]);
    assert!(
        !ran.ok,
        "a FAILED mirror job must exit non-zero, and it printed:\n{}",
        ran.out
    );
    ran.assert_says("mirror-2");
    ran.assert_says("the upstream refused the connection");
}

/// **The wrong-kind refusal reaches the operator by name.**
///
/// The server checks the credential against the transport the URL names BEFORE
/// dialling, and fails the job with a sentence that says which kind is stored
/// and which is wanted. The failure this guards is a CLI that renders it as
/// "sync failed": the whole value of that sentence is that it stops somebody
/// rotating a key that was never the problem.
///
/// The text is `gunnar_server::mirror_fetch::check_credential`'s own.
#[test]
fn a_credential_of_the_wrong_kind_is_rendered_by_name() {
    let harness = Harness::start();
    harness.preregister("alice/linux", "https://github.com/torvalds/linux.git");
    harness.script(
        "alice/linux",
        vec![MirrorJob {
            error: "this mirror's remote is dialled over smart http, and the credential stored \
                    for it is an ssh key. smart http cannot carry it, so the fetch would go out \
                    unauthenticated and be refused as though no credential were set. Store a \
                    token instead, with `Mirror.SetCredential`."
                .into(),
            ..job("mirror-3", MirrorJobState::Failed)
        }],
    );

    let ran = harness.run(&["control", "mirror", "status", "alice/linux"]);
    assert!(ran.ok, "`mirror status` failed:\n{}", ran.out);
    ran.assert_says("smart http cannot carry it");
    ran.assert_says("Store a token instead");
}

// ───────────────────────────────────────────────────────────────────────────
// credential
// ───────────────────────────────────────────────────────────────────────────

/// **The secret goes in from a FILE and never comes back out.**
///
/// Both halves are asserted, and the second is what makes the first mean
/// something: the double records every secret it received, so "the value is not
/// in the output" cannot pass by the CLI having sent nothing.
#[test]
fn a_credential_is_read_from_a_file_and_never_printed() {
    let harness = Harness::start();
    harness.preregister("alice/linux", "https://github.com/torvalds/linux.git");
    let secret_path = harness.dir.path().join("token.txt");
    // Trailing newline on purpose: a token file written by a human has one, and
    // a CLI that sends it sends a credential the forge rejects.
    std::fs::write(&secret_path, "SUPERSECRET-do-not-print-me\n").expect("write the token");

    let ran = harness.run(&[
        "control",
        "mirror",
        "credential",
        "alice/linux",
        "--token-file",
        secret_path.to_str().expect("utf-8 path"),
        "--username",
        "x-access-token",
    ]);
    assert!(ran.ok, "`mirror credential` failed:\n{}", ran.out);
    ran.assert_says("token");
    ran.assert_says("x-access-token");
    ran.assert_silent_about("SUPERSECRET-do-not-print-me");

    assert_eq!(
        harness.secrets(),
        vec!["SUPERSECRET-do-not-print-me".to_string()],
        "the secret must reach the server, trimmed of the file's newline — otherwise the \
         'never printed' assertion above is satisfied by a CLI that sent nothing"
    );
    assert_eq!(
        harness.credentials(),
        vec![(
            "alice/linux".to_string(),
            MirrorCredentialKind::Token as i32,
            "x-access-token".to_string(),
        )]
    );
}

/// An ssh key from a file reads back as `ssh-key`, and a refusal on the way
/// does not carry the value either.
/// ★ GUN-MIRROR-CA: `control mirror ca` sends the PEM file and prints the CA gunnar now
/// trusts for that mirror; `--forget` takes it back.
#[test]
fn a_mirror_ca_is_read_from_a_file_and_shown_by_subject_and_fingerprint() {
    let harness = Harness::start();
    harness.preregister("alice/tool", "https://git.acme.internal/upstream/tool.git");
    let pem = harness.dir.path().join("acme-root.pem");
    std::fs::write(
        &pem,
        "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n",
    )
    .expect("write the CA");
    let ran = harness.run(&[
        "control",
        "mirror",
        "ca",
        "alice/tool",
        "--ca-file",
        pem.to_str().expect("utf-8 path"),
    ]);
    assert!(ran.ok, "`mirror ca` failed:\n{}", ran.out);
    ran.assert_says("CN=Acme Private Root");
    ran.assert_says(&"ab".repeat(32));
    let forgot = harness.run(&["control", "mirror", "ca", "alice/tool", "--forget"]);
    assert!(forgot.ok, "`mirror ca --forget` failed:\n{}", forgot.out);
    forgot.assert_says("public roots only");
}

#[test]
fn an_ssh_key_reads_back_by_kind_and_a_refusal_carries_no_secret() {
    let harness = Harness::start();
    harness.preregister("alice/linux", "ssh://git@codeberg.org/alice/linux.git");
    let key_path = harness.dir.path().join("deploy_key");
    write_private(&key_path, TEST_KEY);

    let ran = harness.run(&[
        "control",
        "mirror",
        "credential",
        "alice/linux",
        "--ssh-key",
        key_path.to_str().expect("utf-8 path"),
    ]);
    assert!(ran.ok, "`mirror credential --ssh-key` failed:\n{}", ran.out);
    ran.assert_says("ssh-key");
    ran.assert_silent_about("OPENSSH PRIVATE KEY");
    ran.assert_silent_about("b3BlbnNzaC1rZXkt");

    // The same key against a mirror that is not there: a refusal is the other
    // path a secret escapes on.
    let refused = harness.run(&[
        "control",
        "mirror",
        "credential",
        "alice/nothing-here",
        "--ssh-key",
        key_path.to_str().expect("utf-8 path"),
    ]);
    assert!(
        !refused.ok,
        "an unknown mirror must be refused:\n{}",
        refused.out
    );
    refused.assert_silent_about("OPENSSH PRIVATE KEY");
    refused.assert_silent_about("b3BlbnNzaC1rZXkt");

    // And `--forget` says the kind is gone rather than saying nothing.
    let forgotten = harness.run(&["control", "mirror", "credential", "alice/linux", "--forget"]);
    assert!(forgotten.ok, "`--forget` failed:\n{}", forgotten.out);
    forgotten.assert_says("none");
}

// ───────────────────────────────────────────────────────────────────────────
// import
// ───────────────────────────────────────────────────────────────────────────

/// A GitHub page of repositories, as `GET /user/repos` returns it. Hand-built
/// here rather than reusing `gunnar-forge`'s fixtures: those live in that
/// crate's `tests/` and an integration test cannot reach another crate's test
/// module. The keys are the six the driver reads.
///
/// **`clone_host` is the host the rows claim to be served from**, and it is a
/// parameter because it is what the import verb now compares against the forge
/// it was pointed at. For an honest instance it is the double's own authority —
/// a self-hosted forge serves its clones from itself, which is what the earlier
/// literal `github.com` here quietly was not.
fn github_page(clone_host: &str, rows: &[(&str, bool, bool, u64)]) -> String {
    let items: Vec<String> = rows
        .iter()
        .map(|(name, private, archived, size)| {
            format!(
                r#"{{"name":"{name}","full_name":"octocat/{name}",
                    "clone_url":"http://{clone_host}/octocat/{name}.git",
                    "ssh_url":"git@{clone_host}:octocat/{name}.git",
                    "default_branch":"main","private":{private},"archived":{archived},
                    "size":{size},"description":null,"language":null,"parent":null}}"#
            )
        })
        .collect();
    format!("[{}]", items.join(","))
}

/// The double's own authority, `127.0.0.1:<port>`, off the URL it is listening
/// on — so the rows it serves name the machine that served them.
fn host_of(url: &str) -> String {
    url.trim_start_matches("http://")
        .trim_start_matches("https://")
        .trim_end_matches('/')
        .to_owned()
}

fn forge_double(rows: &[(&str, bool, bool, u64)]) -> gunnar_forge::testing::MockServer {
    let server = gunnar_forge::testing::MockServer::start();
    let page = github_page(&host_of(&server.url()), rows);
    server.on_body("GET", "/user/repos", 200, &page);
    server
}

fn token_file(dir: &Path, token: &str) -> PathBuf {
    let path = dir.join("forge-token");
    std::fs::write(&path, format!("{token}\n")).expect("write the forge token");
    path
}

/// **Import: list, exclude the archived, queue the rest — and say so.**
///
/// Then run it AGAIN. `Add` on an existing mirror is a no-op and `Sync` dedups,
/// so a second `--all` must be as boring as the first: the verb an operator
/// re-runs after adding one repository upstream cannot be one that errors on
/// the twenty already there.
#[test]
fn import_all_queues_every_live_repository_and_is_idempotent() {
    let harness = Harness::start();
    let forge = forge_double(&[
        ("hello-world", false, false, 1234),
        ("spoon-knife", true, false, 12),
        ("ancient-history", false, true, 7),
    ]);
    let token = token_file(harness.dir.path(), "ghp_notarealtoken");
    let from = format!("github={}", forge.url());

    let args = [
        "control",
        "mirror",
        "import",
        "--from",
        &from,
        "--token-file",
        token.to_str().expect("utf-8 path"),
        "--into",
        "alice",
        "--https",
        "--all",
    ];
    let ran = harness.run(&args);
    assert!(ran.ok, "`mirror import --all` failed:\n{}", ran.out);
    // The table.
    ran.assert_says("hello-world");
    ran.assert_says("spoon-knife");
    // Archived is excluded BY DEFAULT and the count says it, rather than the
    // row silently not being there.
    ran.assert_silent_about("alice/ancient-history");
    ran.assert_says("archived");
    ran.assert_says("2 queued");

    let added = harness.added();
    assert_eq!(added.len(), 2, "expected two Add calls, got {added:?}");
    assert!(
        added.iter().all(|(_, _, _, create)| *create),
        "import must create the repository it mirrors into: {added:?}"
    );
    assert_eq!(added[0].0, "alice/hello-world");
    assert_eq!(
        added[0].1,
        format!("http://{}/octocat/hello-world.git", host_of(&forge.url())),
        "the URL registered is the one the forge that listed it serves"
    );
    assert_eq!(
        harness.synced(),
        vec![
            "alice/hello-world".to_string(),
            "alice/spoon-knife".to_string()
        ]
    );
    // `--https` stores the SAME forge token, because the forge that listed them
    // is the forge that will serve the clone.
    assert_eq!(harness.credentials().len(), 2);
    assert!(harness
        .credentials()
        .iter()
        .all(|(_, kind, _)| *kind == MirrorCredentialKind::Token as i32));
    assert!(
        harness.secrets().iter().all(|s| s == "ghp_notarealtoken"),
        "the token stored must be the one the listing used"
    );
    ran.assert_silent_about("ghp_notarealtoken");

    // ── again, and it must be as boring as the first time ──────────────────
    let again = harness.run(&args);
    assert!(
        again.ok,
        "re-running `import --all` on repositories already mirrored must not error:\n{}",
        again.out
    );
    again.assert_says("2 queued");
    assert_eq!(harness.added().len(), 4, "the second run adds them again");
}

/// **A forge 401 stops before anything is queued.**
///
/// Half an import is worse than none: it leaves an operator guessing which
/// repositories made it. The listing is the first call, so a bad token costs
/// nothing — and this asserts the control plane saw NO write.
#[test]
fn import_stops_on_a_401_with_nothing_queued() {
    let harness = Harness::start();
    let forge = gunnar_forge::testing::MockServer::start();
    forge.on_body(
        "GET",
        "/user/repos",
        401,
        r#"{"message":"Bad credentials"}"#,
    );
    let token = token_file(harness.dir.path(), "ghp_revoked");
    let from = format!("github={}", forge.url());

    let ran = harness.run(&[
        "control",
        "mirror",
        "import",
        "--from",
        &from,
        "--token-file",
        token.to_str().expect("utf-8 path"),
        "--into",
        "alice",
        "--https",
        "--all",
    ]);
    assert!(!ran.ok, "a 401 must fail the command:\n{}", ran.out);
    ran.assert_says("github");
    ran.assert_says("401");
    ran.assert_silent_about("ghp_revoked");
    assert!(
        harness.added().is_empty() && harness.synced().is_empty(),
        "a 401 queued something: added={:?} synced={:?}",
        harness.added(),
        harness.synced()
    );
}

/// **A forge cannot name the host your token goes to.**
///
/// `clone_url` is copied verbatim out of the forge's own JSON answer, and
/// `import` then stores the operator's forge token — typically a broadly scoped
/// personal access token — against whatever it says. A compromised forge, or a
/// self-hosted one an operator was talked into pointing at, needed only to
/// answer with a `clone_url` on a host it controls.
///
/// The double here lists one repository honestly and one with a clone URL on
/// `harvest.invalid`. Every assertion is on what the control plane SAW: the
/// honest row registered, the dishonest row not registered, no credential
/// stored for it, and the token in nothing the CLI printed.
///
/// MEASURED before the check existed: two `Add` calls and two
/// `SetMirrorCredential` calls, the second of them carrying `ghp_notarealtoken`
/// bound to `https://harvest.invalid/octocat/trap.git`.
#[test]
fn a_clone_url_the_forge_invented_gets_neither_a_mirror_nor_the_token() {
    let harness = Harness::start();
    let forge = gunnar_forge::testing::MockServer::start();
    let honest = host_of(&forge.url());
    forge.on_body(
        "GET",
        "/user/repos",
        200,
        &format!(
            "[{},{}]",
            one_repo("hello-world", &honest),
            one_repo("trap", "harvest.invalid"),
        ),
    );
    let token = token_file(harness.dir.path(), "ghp_notarealtoken");
    let from = format!("github={}", forge.url());

    let ran = harness.run(&[
        "control",
        "mirror",
        "import",
        "--from",
        &from,
        "--token-file",
        token.to_str().expect("utf-8 path"),
        "--into",
        "alice",
        "--https",
        "--all",
    ]);
    assert!(
        !ran.ok,
        "a repository that was refused must not read as a clean import:\n{}",
        ran.out
    );
    ran.assert_says("harvest.invalid");
    ran.assert_says("1 queued, 1 failed");
    ran.assert_silent_about("ghp_notarealtoken");

    let added = harness.added();
    assert_eq!(
        added.len(),
        1,
        "only the honest row may be registered: {added:?}"
    );
    assert_eq!(added[0].0, "alice/hello-world");
    assert!(
        !harness.secrets().is_empty(),
        "the honest row still gets the token, or this test would pass on a CLI that \
         stopped storing credentials altogether"
    );
    assert_eq!(
        harness.credentials().len(),
        1,
        "the token was stored for a host the forge chose: {:?}",
        harness.credentials()
    );
    assert_eq!(harness.credentials()[0].0, "alice/hello-world");
    assert_eq!(harness.synced(), vec!["alice/hello-world".to_string()]);
}

/// One row of a GitHub listing, with its clone host chosen by the caller.
fn one_repo(name: &str, clone_host: &str) -> String {
    format!(
        r#"{{"name":"{name}","full_name":"octocat/{name}",
            "clone_url":"https://{clone_host}/octocat/{name}.git",
            "ssh_url":"git@{clone_host}:octocat/{name}.git",
            "default_branch":"main","private":false,"archived":false,
            "size":1,"description":null,"language":null,"parent":null}}"#
    )
}

/// **`--pick` reads the choice from stdin, so the picker is pipeable.**
///
/// Indices as printed, and names, in one stream — no TUI, because a chooser
/// that only works at a terminal is one a script cannot re-run.
#[test]
fn import_pick_reads_indices_and_names_from_stdin() {
    let harness = Harness::start();
    let forge = forge_double(&[
        ("hello-world", false, false, 1234),
        ("spoon-knife", true, false, 12),
        ("third-thing", false, false, 3),
    ]);
    let token = token_file(harness.dir.path(), "ghp_notarealtoken");
    let from = format!("github={}", forge.url());

    let ran = harness.run_with_stdin(
        &[
            "control",
            "mirror",
            "import",
            "--from",
            &from,
            "--token-file",
            token.to_str().expect("utf-8 path"),
            "--into",
            "alice",
            "--https",
            "--pick",
        ],
        "2\nthird-thing\n",
    );
    assert!(ran.ok, "`mirror import --pick` failed:\n{}", ran.out);
    ran.assert_says("2 queued");
    assert_eq!(
        harness.synced(),
        vec![
            "alice/spoon-knife".to_string(),
            "alice/third-thing".to_string()
        ],
        "--pick queued the wrong rows"
    );
}

/// **`--ssh` sets no credential and says where the deploy key goes.**
///
/// A mirror over `ssh://` with no key is a mirror that will fail on the first
/// sync. The CLI cannot enrol the key — it is the UPSTREAM forge's, not
/// gunnar's — so the one thing it owes the operator is the next command.
#[test]
fn import_over_ssh_sets_no_credential_and_names_the_next_command() {
    let harness = Harness::start();
    let forge = forge_double(&[("hello-world", false, false, 1234)]);
    let token = token_file(harness.dir.path(), "ghp_notarealtoken");
    let from = format!("github={}", forge.url());

    let ran = harness.run(&[
        "control",
        "mirror",
        "import",
        "--from",
        &from,
        "--token-file",
        token.to_str().expect("utf-8 path"),
        "--into",
        "alice",
        "--ssh",
        "--all",
    ]);
    assert!(ran.ok, "`mirror import --ssh` failed:\n{}", ran.out);
    assert_eq!(
        harness.added()[0].1,
        format!("git@{}:octocat/hello-world.git", host_of(&forge.url())),
        "--ssh must mirror the ssh clone URL"
    );
    assert!(
        harness.credentials().is_empty(),
        "--ssh must store no credential: {:?}",
        harness.credentials()
    );
    ran.assert_says("deploy key");
    ran.assert_says("mirror credential");
}

/// With neither `--all` nor `--pick`, the table is printed and NOTHING is
/// queued. Listing is the safe half and it is the default.
#[test]
fn import_without_all_or_pick_only_lists() {
    let harness = Harness::start();
    let forge = forge_double(&[("hello-world", false, false, 1234)]);
    let token = token_file(harness.dir.path(), "ghp_notarealtoken");
    let from = format!("github={}", forge.url());

    let ran = harness.run(&[
        "control",
        "mirror",
        "import",
        "--from",
        &from,
        "--token-file",
        token.to_str().expect("utf-8 path"),
        "--into",
        "alice",
    ]);
    assert!(ran.ok, "`mirror import` (list only) failed:\n{}", ran.out);
    ran.assert_says("hello-world");
    ran.assert_says("--all");
    assert!(harness.added().is_empty(), "a listing queued something");
}

/// The token may come from the environment instead of a file, and `--from`
/// names the forge whose conventional variable is read.
#[test]
fn import_reads_the_token_from_the_environment_when_no_file_is_given() {
    let harness = Harness::start();
    let forge = forge_double(&[("hello-world", false, false, 1234)]);
    let from = format!("github={}", forge.url());

    let ran = harness.run_with_env(
        &[
            "control", "mirror", "import", "--from", &from, "--into", "alice",
        ],
        "",
        &[("GITHUB_TOKEN", "ghp_from_the_environment")],
    );
    assert!(
        ran.ok,
        "`mirror import` with GITHUB_TOKEN failed:\n{}",
        ran.out
    );
    ran.assert_says("hello-world");
    ran.assert_silent_about("ghp_from_the_environment");
}