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