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