crates/gunnar-cli/tests/forge_control_cli.rs
#![cfg(all(feature = "server", feature = "control"))]
//! **`gunnar control show` and `gunnar control merge`, driven as the real
//! binary against a RUNNING server** (the last sliver of the store-local forge lock).
//!
//! The offline `gunnar forge show` and the merge that only `PullRequests.Merge`
//! could reach both open the data root directly and take an exclusive lock, so
//! while a `gunnar serve` holds that root they cannot run — which on a sealed
//! appliance with no shell means an operator can neither SEE a pull request's
//! review nor MERGE it. `Forge.GetReview` and `PullRequests.Merge` already
//! answered both over the control plane, and `forge_merge.rs` proves the merge
//! GATE exhaustively at the plane call the RPC makes; what was missing was the
//! CLI door to them. This drives that door end to end, as an actual process
//! with an actual exit code, because an operator on a headless box runs a
//! command and reads what it printed.
//!
//! The reds it is written against, each asserted here:
//!
//! * the store-local `gunnar forge show` on the same data root is LOCKED OUT
//! while the server holds it — that lock is the whole reason the control-plane
//! door has to exist;
//! * `gunnar control merge` on a pull request with NO verified approval is
//! REFUSED, and the target ref does not move — point the verb at a gate that
//! passed anyone and this goes green when it must be red;
//! * with a verified approval `gunnar control show` names the exact approver and
//! commit, and `gunnar control merge` moves the ref to the approved oid —
//! asserted by reading the ref back out of the store, not by trusting the
//! verb's own "merged" line;
//! * a caller with no grant is refused by both verbs (Read gates `show`, Write
//! gates `merge`).
mod common;
use std::process::{Command, Output};
use std::sync::Arc;
use common::Harness;
use gunnar_cli::server::CoreControlPlane;
use gunnar_core::forge::{sign_event, Event, ForgeEvent};
use gunnar_core::HashKind;
use gunnar_grpc::ControlPlane as _;
use gunnar_proto::RepoPath;
const NAMESPACE: &str = "team";
const NAME: &str = "appliance";
const REPO: &str = "team/appliance";
const SOURCE: &str = "refs/heads/topic";
const TARGET: &str = "refs/heads/main";
fn tools_present() -> bool {
["git", "ssh"]
.iter()
.all(|tool| Command::new(tool).arg("--version").output().is_ok())
}
fn path() -> RepoPath {
RepoPath {
namespace: NAMESPACE.into(),
repo: NAME.into(),
}
}
/// Run the built `gunnar` binary and hand back its exit status and streams.
fn gunnar(harness: &Harness, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_gunnar-cli"))
.args(args)
.env("HOME", harness.dir.path())
// Nothing in the environment must turn the plaintext loopback dial into
// an https one; the harness gRPC listener speaks h2c.
.env_remove("GUNNAR_CLIENT_CERT")
.env_remove("GUNNAR_CLIENT_KEY")
.env_remove("GUNNAR_CLIENT_CA")
.output()
.expect("spawn the gunnar binary")
}
/// The identity file the harness wrote for `tester` — a real enrolled key, so a
/// signed credential from it authenticates and its identity-table grants (repo Write) decide
/// what it may do.
fn tester_key(harness: &Harness) -> std::path::PathBuf {
harness.dir.path().join("id_ed25519")
}
/// Where `name` points right now, read out of the server's own store — the ref
/// is what ships, so asking the pull-request record whether the merge happened
/// would be asking the accused.
fn tip(harness: &Harness, name: &str) -> Option<String> {
let repo = gunnar_core::RepoRef::new(NAMESPACE, NAME).expect("a legal repo path");
let entry = harness
.stores
.catalog()
.get(&repo)
.expect("read the catalogue")
.expect("the repository exists");
let store = harness.stores.repo_store(&entry).expect("open the store");
store
.serve()
.refs()
.expect("read the refs")
.into_iter()
.find(|row| row.name == name)
.and_then(|row| row.oid)
.map(|oid| oid.iter().map(|b| format!("{b:02x}")).collect())
}
/// Append a signed forge event straight to the log the running server reads —
/// the appliance's approve door is `Forge.Append`, out of scope here; this puts
/// the evidence in place so the two verbs under test have a review to show and a
/// gate to pass.
fn approve(harness: &Harness, pull: u64, actor: &str, commit: &str) {
let signed = sign_event(
&harness.private_key,
ForgeEvent {
repo: REPO.into(),
pull,
actor: actor.into(),
at_unix_ms: 1_700_000_002_000,
event: Event::Approved {
commit: commit.to_owned(),
},
},
)
.expect("sign the approval");
use std::io::Write as _;
let mut line = serde_json::to_string(&signed).expect("json");
line.push('\n');
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(harness.stores.forge_log().records_path())
.expect("open the forge log");
file.write_all(line.as_bytes()).expect("append");
}
/// `main` at one commit, `topic` one commit ahead, and an open pull request from
/// the second into the first. Returns `(base, topic, number)`.
fn proposed(harness: &Harness, plane: &CoreControlPlane) -> (String, String, u64) {
let work = harness.dir.path().join("work");
let url = harness.url(NAMESPACE, NAME);
harness
.git(
harness.dir.path(),
&["init", "--quiet", "-b", "main", work.to_str().unwrap()],
)
.expect_ok("git init");
std::fs::write(work.join("a.txt"), "base\n").expect("write");
harness.git(&work, &["add", "."]).expect_ok("add");
harness
.git(&work, &["commit", "--quiet", "-m", "base"])
.expect_ok("commit");
harness
.git(&work, &["push", "--quiet", &url, "main"])
.expect_ok("push main");
let base = harness
.git(&work, &["rev-parse", "HEAD"])
.expect_ok("rev-parse main")
.stdout
.trim()
.to_owned();
harness
.git(&work, &["checkout", "--quiet", "-b", "topic"])
.expect_ok("branch");
std::fs::write(work.join("a.txt"), "base\nproposed\n").expect("write");
harness.git(&work, &["add", "."]).expect_ok("add");
harness
.git(&work, &["commit", "--quiet", "-m", "the proposal"])
.expect_ok("commit");
harness
.git(&work, &["push", "--quiet", &url, "topic"])
.expect_ok("push topic");
let topic = harness
.git(&work, &["rev-parse", "HEAD"])
.expect_ok("rev-parse topic")
.stdout
.trim()
.to_owned();
assert_eq!(
tip(harness, TARGET).as_deref(),
Some(base.as_str()),
"fixture did not land: main is not where the push put it"
);
assert_eq!(
tip(harness, SOURCE).as_deref(),
Some(topic.as_str()),
"fixture did not land: topic is not where the push put it"
);
let record = plane
.create_pull(
&path(),
"tester",
"the proposal",
"please look",
SOURCE,
TARGET,
)
.expect("open the pull request");
(base, topic, record.number)
}
/// **The whole operator story on a sealed appliance, end to end.**
#[test]
fn show_and_merge_a_pull_request_on_a_running_server() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let mut harness = Harness::start_with_grpc(NAMESPACE, NAME, HashKind::Sha1);
let plane = CoreControlPlane::new(Arc::clone(&harness.stores));
let (base, topic, number) = proposed(&harness, &plane);
let at = harness.grpc_url();
let key = tester_key(&harness);
let key = key.to_str().unwrap();
let number_s = number.to_string();
// ── RED, the lock itself: the store-local `gunnar forge show` cannot
// even open the data root while the server holds it. This is why the
// control-plane door has to exist; if this SUCCEEDED, the whole verb
// would be redundant.
let data_root = harness.dir.path().join("data");
let local = gunnar(
&harness,
&[
"--data-root",
data_root.to_str().unwrap(),
"forge",
"show",
"--repo",
REPO,
"--pull",
&number_s,
],
);
let local_out = String::from_utf8_lossy(&local.stdout);
let local_err = String::from_utf8_lossy(&local.stderr);
assert!(
!local.status.success() && !local_out.contains("review"),
"the store-local `gunnar forge show` must be locked out while the server holds the \
root — it must NOT print a review. If it ran here, the forge sliver would not \
exist.\nstdout: {local_out}\nstderr: {local_err}"
);
// …and locked out for the RIGHT reason — a second open of the data root, not
// a mangled command line (which would also exit non-zero and make the check
// above vacuous).
assert!(
!local_err.contains("Usage:") && !local_err.contains("unrecognized"),
"the store-local verb failed on its arguments, not on the lock; the lockout is not \
what was proven: {local_err}"
);
// ── RED, the gate over the wire: no approval yet, so `control merge` is
// refused and — asserted from the store, not the verb's word — main does
// not move.
let refused = gunnar(
&harness,
&["control", "merge", REPO, &number_s, "--at", &at, "-i", key],
);
assert!(
!refused.status.success(),
"a merge with no verified approval must be refused"
);
let stderr = String::from_utf8_lossy(&refused.stderr);
assert!(
stderr.contains("no verified approval names"),
"the refusal must say WHY, so an operator knows to get a review and not to rebase: \
{stderr}"
);
assert_eq!(
tip(&harness, TARGET).as_deref(),
Some(base.as_str()),
"the merge refused and moved {TARGET} anyway"
);
// ── The approval lands (tester approves the tip), and now `control show`
// over the wire names the exact approver and commit, with the verdict the
// server re-derived against the identity key table.
approve(&harness, number, "tester", &topic);
let shown = gunnar(
&harness,
&["control", "show", REPO, &number_s, "--at", &at, "-i", key],
);
assert!(
shown.status.success(),
"control show must succeed for a reader; stderr: {}",
String::from_utf8_lossy(&shown.stderr)
);
let out = String::from_utf8_lossy(&shown.stdout);
assert!(
out.contains(&format!("{REPO}#{number} review")),
"header: {out}"
);
assert!(
out.contains("tester") && out.contains(&topic[..12]) && out.contains("verified"),
"the review must name tester approving the exact tip {}, verified: {out}",
&topic[..12]
);
// ── GREEN, the merge: `control merge` moves the ref, and the MOVE is read
// back out of the store (measure, never assert), not taken from the verb's own line.
let merged = gunnar(
&harness,
&["control", "merge", REPO, &number_s, "--at", &at, "-i", key],
);
assert!(
merged.status.success(),
"an approved fast-forward must merge; stderr: {}",
String::from_utf8_lossy(&merged.stderr)
);
let merged_out = String::from_utf8_lossy(&merged.stdout);
assert!(
merged_out.contains(&topic) && merged_out.contains(TARGET),
"the verb must print the ref and the oid it moved to: {merged_out}"
);
assert_eq!(
tip(&harness, TARGET).as_deref(),
Some(topic.as_str()),
"control merge printed success but {TARGET} did not actually move to {topic}"
);
harness.stop();
}
/// **Both verbs refuse a caller with no grant** — Read gates `show`, Write gates
/// `merge`. Written against the red where a verb forgot to carry its credential
/// or the server let an ungranted principal through: an authenticated stranger
/// is `PermissionDenied`, not served.
#[test]
fn a_stranger_may_neither_show_nor_merge() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let mut harness = Harness::start_with_grpc(NAMESPACE, NAME, HashKind::Sha1);
let plane = CoreControlPlane::new(Arc::clone(&harness.stores));
let (_base, _topic, number) = proposed(&harness, &plane);
let at = harness.grpc_url();
let number_s = number.to_string();
// A real, enrolled principal — authenticated — but holding no grant on the
// repository at all, so the refusal is authorization and not authentication.
let private = gunnar_core::mint_ed25519().expect("mint a stranger key");
let parsed = gunnar_core::ssh_key::PrivateKey::from_openssh(&private).expect("the key parses");
let public = parsed.public_key().to_openssh().expect("public");
harness
.stores
.auth()
.add_principal(
gunnar_core::Principal::new("stranger", "stranger", gunnar_core::PrincipalKind::User)
.expect("valid"),
)
.expect("add the principal");
harness
.stores
.auth()
.register_key("stranger", &public, None)
.expect("register the key");
let key_path = harness.dir.path().join("stranger_key");
std::fs::write(&key_path, &private).expect("write the stranger key");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).expect("chmod");
}
let key = key_path.to_str().unwrap();
for verb in [["control", "show"], ["control", "merge"]] {
let ran = gunnar(
&harness,
&[verb[0], verb[1], REPO, &number_s, "--at", &at, "-i", key],
);
assert!(
!ran.status.success(),
"a caller with no grant must not reach `{} {}`",
verb[0],
verb[1]
);
let stderr = String::from_utf8_lossy(&ran.stderr);
assert!(
stderr.contains("stranger"),
"the refusal must name the principal it refused for `{} {}`: {stderr}",
verb[0],
verb[1]
);
}
harness.stop();
}