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