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_online_cli.rs

#![cfg(all(feature = "server", feature = "control"))]

//! **The forge's UI-only paths, from the CLI, against a RUNNING server**
//! (owner 2026-10-04: the console is a thin layer; what it can do, the CLI
//! can do, because both only CALL gunnar).
//!
//! gunnar has answered `PullRequests.*`, `Forge.*` and `Events.Watch` over the
//! control plane for a long time; the console reached them and the CLI did
//! not (`gunnar forge …` runs offline, against a stopped server's data root).
//! These rows drive the new `gunnar control` verbs as the real binary with a
//! real exit code, as an operator or a customer on a laptop would:
//!
//! * `whoami` — who the key is, and its server-wide level (`Access.WhoAmI`);
//! * `pr create|list|get|close` — `PullRequests.*`;
//! * `issue open|comment|get|list|close` — signed with the caller's OWN key and
//!   appended with `Forge.Append`; the actor is who gunnar says the key is;
//! * `review comment|approve|get` — the same, for a pull request's review;
//! * `events watch` — `Events.Watch`, one line per event.
//!
//! Every change is read back from the server (a second verb or the store), not
//! taken from the verb's own line.

mod common;

use std::process::{Command, Output, Stdio};

use common::Harness;
use gunnar_core::HashKind;

const NAMESPACE: &str = "team";
const NAME: &str = "appliance";
const REPO: &str = "team/appliance";

fn tools_present() -> bool {
    ["git", "ssh"]
        .iter()
        .all(|tool| Command::new(tool).arg("--version").output().is_ok())
}

fn gunnar_command(harness: &Harness, args: &[&str]) -> Command {
    let mut command = Command::new(env!("CARGO_BIN_EXE_gunnar-cli"));
    command
        .args(args)
        .env("HOME", harness.dir.path())
        .env_remove("GUNNAR_CLIENT_CERT")
        .env_remove("GUNNAR_CLIENT_KEY")
        .env_remove("GUNNAR_CLIENT_CA")
        .env_remove("GUNNAR_CONTROL_ADDR");
    command
}

/// `gunnar control <args> --at <grpc> -i <tester's key>`.
fn control(harness: &Harness, args: &[&str]) -> Output {
    let at = harness.grpc_url();
    let key = harness.dir.path().join("id_ed25519");
    let mut all = vec!["control"];
    all.extend_from_slice(args);
    all.extend_from_slice(&["--at", &at, "-i", key.to_str().unwrap()]);
    gunnar_command(harness, &all)
        .output()
        .expect("spawn the gunnar binary")
}

fn ok(out: &Output, what: &str) -> String {
    let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
    assert!(
        out.status.success(),
        "{what} failed\nstdout: {stdout}\nstderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    stdout
}

/// `main` at one commit and `topic` one ahead, pushed. Returns the topic oid.
fn pushed_topic(harness: &Harness) -> String {
    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");
    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");
    harness
        .git(&work, &["rev-parse", "HEAD"])
        .expect_ok("rev-parse topic")
        .stdout
        .trim()
        .to_owned()
}

/// ★ **`whoami`, pull requests and reviews, end to end over the wire.**
#[test]
fn whoami_pull_requests_and_reviews_from_the_cli() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start_with_grpc(NAMESPACE, NAME, HashKind::Sha1);
    let topic = pushed_topic(&harness);

    let me = ok(&control(&harness, &["whoami"]), "whoami");
    assert!(me.contains("tester"), "whoami names the principal: {me}");
    assert!(me.contains("read"), "…and its server-wide level: {me}");

    let created = ok(
        &control(
            &harness,
            &[
                "pr",
                "create",
                REPO,
                "--source",
                "refs/heads/topic",
                "--target",
                "refs/heads/main",
                "--title",
                "the proposal",
                "--body",
                "please look",
            ],
        ),
        "pr create",
    );
    assert!(
        created.contains("#1"),
        "the number gunnar assigned: {created}"
    );

    let listed = ok(&control(&harness, &["pr", "list", REPO]), "pr list");
    assert!(
        listed.contains("#1") && listed.contains("the proposal") && listed.contains("open"),
        "{listed}"
    );
    let got = ok(&control(&harness, &["pr", "get", REPO, "1"]), "pr get");
    assert!(
        got.contains("the proposal")
            && got.contains("refs/heads/topic")
            && got.contains("please look"),
        "{got}"
    );

    ok(
        &control(
            &harness,
            &["review", "comment", REPO, "1", "--body", "looks right"],
        ),
        "review comment",
    );
    ok(
        &control(
            &harness,
            &["review", "approve", REPO, "1", "--commit", &topic],
        ),
        "review approve",
    );
    let review = ok(
        &control(&harness, &["review", "get", REPO, "1"]),
        "review get",
    );
    assert!(
        review.contains("comments  1"),
        "the comment is counted: {review}"
    );
    assert!(
        review.contains("tester") && review.contains(&topic[..12]) && review.contains("verified"),
        "the approval is tester's, on the tip, verified by gunnar: {review}"
    );

    ok(&control(&harness, &["pr", "close", REPO, "1"]), "pr close");
    let closed = ok(&control(&harness, &["pr", "get", REPO, "1"]), "pr get");
    assert!(closed.contains("closed"), "read back closed: {closed}");

    harness.stop();
}

/// ★ **Issues, signed with the caller's own key, end to end over the wire.**
#[test]
fn issues_are_opened_commented_and_closed_from_the_cli() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start_with_grpc(NAMESPACE, NAME, HashKind::Sha1);

    let opened = ok(
        &control(
            &harness,
            &[
                "issue",
                "open",
                REPO,
                "--title",
                "the build is red",
                "--body",
                "since tuesday",
            ],
        ),
        "issue open",
    );
    assert!(
        opened.contains("#1"),
        "the number the fold assigned: {opened}"
    );
    ok(
        &control(
            &harness,
            &["issue", "comment", REPO, "1", "--body", "bisected it"],
        ),
        "issue comment",
    );
    let got = ok(
        &control(&harness, &["issue", "get", REPO, "1"]),
        "issue get",
    );
    assert!(
        got.contains("the build is red")
            && got.contains("tester")
            && got.contains("open")
            && got.contains("bisected it"),
        "{got}"
    );
    ok(
        &control(
            &harness,
            &["issue", "close", REPO, "1", "--reason", "fixed"],
        ),
        "issue close",
    );
    let listed = ok(&control(&harness, &["issue", "list", REPO]), "issue list");
    assert!(
        listed.contains("#1") && listed.contains("closed") && listed.contains("the build is red"),
        "{listed}"
    );

    harness.stop();
}

/// ★ **`events watch` prints the live feed**, one line per event: a push made
/// while it runs shows up, named by repository and ref; `--kind` narrows it
/// (the push's own `KeyUsed` would otherwise come first) and `--count` ends it.
#[test]
fn events_watch_prints_a_push_made_while_it_runs() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start_with_grpc(NAMESPACE, NAME, HashKind::Sha1);
    let at = harness.grpc_url();
    let key = harness.dir.path().join("id_ed25519");
    let child = gunnar_command(
        &harness,
        &[
            "control",
            "events",
            "watch",
            "--repo",
            REPO,
            "--kind",
            "ref-updated",
            "--count",
            "1",
            "--at",
            &at,
            "-i",
            key.to_str().unwrap(),
        ],
    )
    .stdout(Stdio::piped())
    .stderr(Stdio::piped())
    .spawn()
    .expect("spawn the watch");
    // Let it subscribe before anything happens: the feed starts at the tail.
    std::thread::sleep(std::time::Duration::from_millis(800));
    pushed_topic(&harness);

    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(child.wait_with_output());
    });
    let out = rx
        .recv_timeout(std::time::Duration::from_secs(30))
        .expect("the watch ended after --count events")
        .expect("the watch ran");
    let stdout = ok(&out, "events watch");
    assert!(
        stdout.contains(REPO) && stdout.contains("refs/heads/main"),
        "the push is on the feed: {stdout}"
    );

    harness.stop();
}

/// ★ **`user credentials` and `user revoke-credential`**: a person's passkeys
/// (Stage L), listed and revoked as themselves with no grant.
#[test]
fn a_person_lists_and_revokes_their_own_passkeys_from_the_cli() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start_with_grpc(NAMESPACE, NAME, HashKind::Sha1);
    let key = p256::ecdsa::SigningKey::from_slice(&[9; 32]).expect("a scalar");
    let public = key.verifying_key().to_sec1_point(false).as_bytes().to_vec();
    {
        use gunnar_proto::access_client::AccessClient;
        let at = harness.grpc_url();
        let request = harness.authed(
            "Access.AddCredential",
            gunnar_proto::AddCredentialRequest {
                principal_id: "tester".into(),
                credential_id: b"tester-yubikey".to_vec(),
                public_key: public,
                label: "YubiKey".into(),
                sign_count: 0,
            },
        );
        harness
            .runtime
            .block_on(async move {
                AccessClient::connect(at)
                    .await
                    .expect("dial")
                    .add_credential(request)
                    .await
            })
            .expect("tester adds a passkey to themselves");
    }
    let id = gunnar_grpc::webauthn::b64url(b"tester-yubikey");
    let listed = ok(
        &control(&harness, &["user", "credentials", "tester"]),
        "user credentials",
    );
    assert!(
        listed.contains(&id) && listed.contains("YubiKey") && listed.contains("never used"),
        "{listed}"
    );
    ok(
        &control(&harness, &["user", "revoke-credential", &id]),
        "user revoke-credential",
    );
    let listed = ok(
        &control(&harness, &["user", "credentials", "tester"]),
        "user credentials",
    );
    assert!(listed.contains("holds no passkeys"), "{listed}");
    harness.stop();
}

/// ★ **`invite create` is a server admin's** (IDENTITY-HOST): the link is the
/// identity core's (`Access.EnrolmentLink`), minted on a server admin's word; a
/// person adds a passkey on another device, lists and withdraws their links in
/// the console (`IdentityService`). `tester` holds a server Read only: refused,
/// and no secret is printed.
#[test]
fn invite_create_is_a_server_admins_and_a_person_is_refused() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start_with_grpc(NAMESPACE, NAME, HashKind::Sha1);
    let out = control(&harness, &["invite", "create", "tester", "--first-only"]);
    let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
    assert!(
        !out.status.success(),
        "a person minted a first-login link: {stdout}"
    );
    assert!(
        !stdout.contains("secret"),
        "a refused link printed a secret: {stdout}"
    );
    assert!(
        stderr.to_lowercase().contains("admin"),
        "refused by name: {stderr}"
    );
    harness.stop();
}