crates/gunnar-cli/tests/control_cli.rs
//! **`gunnar control`, driven as a real binary against a real server.**
//!
//! The four steps of the enrolment story, in order, each run as an actual process
//! with an actual exit code, because a first-time user runs a command and reads
//! what it printed. Nothing here calls a library function that the CLI happens
//! to also call.
//!
//! ```text
//! gunnar control status no auth -> "OPEN"
//! gunnar control bootstrap alice --key @… no auth -> gunnar-admin
//! gunnar control bootstrap … (again) refused, and says what to do
//! gunnar control login -i … the ssh key, and no session
//! gunnar control user add bob --key @… authenticated
//! gunnar control user add … (as bob) refused: not gunnar-admin
//! ```
//!
//! Two failures are asserted to be **different sentences**, because the whole
//! design turns on them not being confusable: a server that is already enrolled
//! and a server that cannot be reached.
use std::io::BufRead as _;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
/// A `gunnar serve` child process on ephemeral loopback ports.
struct Serving {
child: Child,
grpc: String,
_dir: tempfile::TempDir,
home: PathBuf,
/// Where the server's stderr lands, so a test can assert on the log an
/// operator would be reading.
log_path: PathBuf,
}
impl Drop for Serving {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
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
}
impl Serving {
/// Start a server with **no identity at all** and wait for its banner.
fn fresh() -> Serving {
Serving::fresh_at("info")
}
/// [`Serving::fresh`] at a chosen `RUST_LOG`.
///
/// A parameter because the log-level reload's acknowledgement ORDERING is only
/// observable below `warn`: at `info` a `warn!` is printable under both the
/// old filter and the new one, so a server started at `info` cannot tell
/// announce-then-reload from reload-then-announce.
fn fresh_at(rust_log: &str) -> Serving {
let dir = tempfile::tempdir().expect("a temp dir");
let home = dir.path().join("home");
std::fs::create_dir_all(&home).expect("mkdir");
// The server's log goes to a FILE, not to `/dev/null`. It was
// `Stdio::null()` until the request id landed, which meant nothing in
// this file could ever assert that the id the CLI prints names a line
// the server actually wrote — and that join is the whole feature. A
// file rather than `Stdio::piped()` because nobody is draining the
// pipe: a server that logs more than a pipe buffer would block on the
// write and this suite would read as a hang.
let log_path = dir.path().join("server.log");
let log = std::fs::File::create(&log_path).expect("create the server log");
let mut child = Command::new(gunnar_binary())
.arg("--data-root")
.arg(dir.path().join("data"))
.arg("serve")
.arg("--ssh-bind")
.arg("127.0.0.1:0")
.arg("--grpc-bind")
.arg("127.0.0.1:0")
.env("HOME", &home)
// Not inherited from the test runner, which may carry any filter.
// `info` is what a shipped server runs at, and the assertions below
// are about what an operator sees at that level and no other.
.env("RUST_LOG", rust_log)
.stdout(Stdio::piped())
.stderr(Stdio::from(log))
.spawn()
.expect("spawn gunnar serve");
let stdout = child.stdout.take().expect("piped");
let mut reader = std::io::BufReader::new(stdout);
let mut grpc = None;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while std::time::Instant::now() < deadline {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
if let Some(rest) = line.strip_prefix("grpc ") {
let addr = rest.split_whitespace().next().unwrap_or_default();
if !addr.is_empty() && addr != "disabled" {
grpc = Some(addr.to_owned());
break;
}
}
}
let grpc = grpc.expect(
"gunnar serve never announced a gRPC address. On a server with NO identities that \
used to be a refusal to start, which is the defect this whole feature exists to \
fix — so a missing banner here is the regression, not a flake.",
);
// ── keep draining stdout, for the server's LIFE ────────────────────
//
// Dropping `reader` here closes the read end of the pipe, and the next
// `println!` in the server then fails — Rust ignores SIGPIPE, so a
// failed `print` PANICS and the process dies. Nothing noticed while the
// gRPC line happened to be the last thing the banner printed; adding
// one line after it (the `log …` line) turned this suite into a 1-in-8
// flake whose symptom was "the control plane could not be reached",
// i.e. it looked exactly like a server bug.
//
// A thread rather than `Stdio::null()`, because the banner is evidence:
// it is what the four-step test reads.
std::thread::spawn(move || {
let mut sink = String::new();
let _ = std::io::Read::read_to_string(&mut reader, &mut sink);
});
Serving {
child,
grpc,
_dir: dir,
home,
log_path,
}
}
/// Everything the server has written to its own stderr so far.
///
/// Read from the file each time rather than cached: the interesting lines
/// are written *by the call the test just made*.
fn log(&self) -> String {
std::fs::read_to_string(&self.log_path).unwrap_or_default()
}
}
/// What a `gunnar` invocation did.
struct Ran {
ok: bool,
out: String,
}
impl Ran {
fn says(&self, needle: &str) -> bool {
self.out.contains(needle)
}
}
/// Run the real binary. `HOME` is the server's temp home, so key discovery and
/// any state the CLI might write land somewhere this test can inspect.
fn gunnar(home: &Path, args: &[&str]) -> Ran {
let out = Command::new(gunnar_binary())
.args(args)
.env("HOME", home)
.env_remove("GUNNAR_CONTROL_ADDR")
.env_remove("SSH_AUTH_SOCK")
.output()
.expect("run gunnar");
Ran {
ok: out.status.success(),
out: format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
),
}
}
/// Every `*.pem` directly in `dir`: what a bootstrap run from `dir` must not add to.
fn pem_files(dir: &Path) -> std::collections::BTreeSet<PathBuf> {
std::fs::read_dir(dir)
.map(|entries| {
entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "pem"))
.collect()
})
.unwrap_or_default()
}
/// Mint an ed25519 pair into `dir`, returning `(private path, public path)`.
fn mint(dir: &Path, name: &str) -> (PathBuf, PathBuf) {
let private = gunnar_core::mint_ed25519().expect("mint");
let parsed =
gunnar_core::ssh_key::PrivateKey::from_openssh(&private).expect("the minted key parses");
let public = parsed.public_key().to_openssh().expect("render");
let priv_path = dir.join(name);
let pub_path = dir.join(format!("{name}.pub"));
std::fs::write(&priv_path, &private).expect("write");
std::fs::write(&pub_path, format!("{public}\n")).expect("write");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&priv_path, std::fs::Permissions::from_mode(0o600)).expect("0600");
}
(priv_path, pub_path)
}
/// Every file under a directory, so "the CLI wrote nothing" can be asserted
/// rather than assumed.
fn snapshot(root: &Path) -> std::collections::BTreeSet<PathBuf> {
let mut out = std::collections::BTreeSet::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path.clone());
}
out.insert(path);
}
}
out
}
// ───────────────────────────────────────────────────────────────────────────
/// **Unreachable is not unenrolled.** The two sentences must not be the same
/// sentence; reading the second as the first is how a closed door reads as an
/// open one.
#[test]
fn an_unreachable_control_plane_says_unreachable_and_never_says_unenrolled() {
let dir = tempfile::tempdir().expect("a temp dir");
// Port 1 on loopback: nothing listens there and connecting is refused
// immediately, so this is a transport failure and not a timeout.
let ran = gunnar(dir.path(), &["control", "status", "--at", "127.0.0.1:1"]);
assert!(!ran.ok, "a status against nothing must not exit 0");
assert!(
ran.says("could not be reached"),
"an unreachable control plane must SAY so: {}",
ran.out
);
assert!(
!ran.says("bootstrap OPEN"),
"…and it must never be reported as an unenrolled server: {}",
ran.out
);
// The other direction, so the assertion above is not a string that could
// never appear: a REAL server does print the open-window line.
let serving = Serving::fresh();
let ran = gunnar(&serving.home, &["control", "status", "--at", &serving.grpc]);
assert!(ran.ok, "status against a live server: {}", ran.out);
assert!(
ran.says("bootstrap OPEN"),
"a fresh server must report the window open: {}",
ran.out
);
assert!(!ran.says("could not be reached"));
}
/// **A fresh box's `control status` prints its machine in `control capacity`'s own lines**
/// (T17, for the twin election): `cores`, `ram total`, `total`, `disktest`, `disk write`,
/// `disk read`, `disk sync` — label first, the raw integer or `unmeasured` next — so
/// `xtask elect twin`'s `parse_capacity` reads it with no second parser. `not-run` here:
/// no start-up disk test runs outside the appliance's PID 1, and that is a word, never a 0.
#[test]
fn a_fresh_boxs_status_prints_its_machine_in_the_capacity_lines() {
let serving = Serving::fresh();
let ran = gunnar(&serving.home, &["control", "status", "--at", &serving.grpc]);
assert!(ran.ok, "{}", ran.out);
let after = |k: &str| -> Option<String> {
ran.out.lines().find_map(|l| {
l.strip_prefix(k)
.filter(|r| r.starts_with(char::is_whitespace))
.map(|r| r.trim().to_owned())
})
};
let num = |k: &str| {
after(k).and_then(|r| {
r.split_whitespace()
.next()
.and_then(|t| t.parse::<u64>().ok())
})
};
assert!(
num("cores").is_some_and(|m| m >= 1000 && m % 1000 == 0),
"{}",
ran.out
);
assert!(num("ram total").is_some_and(|b| b > 0), "{}", ran.out);
assert!(num("total").is_some_and(|b| b > 0), "{}", ran.out);
assert_eq!(after("disktest").as_deref(), Some("not-run"), "{}", ran.out);
for k in ["disk write", "disk read", "disk sync"] {
assert!(
after(k).is_some_and(|r| r.starts_with("unmeasured")),
"{k}: {}",
ran.out
);
}
assert!(ran.says("machine this box is fresh"), "{}", ran.out);
}
/// **The whole four-step flow, as a first-time user types it.**
#[test]
fn the_four_step_flow_runs_end_to_end() {
let serving = Serving::fresh();
let keys = serving.home.join("keys");
std::fs::create_dir_all(&keys).expect("mkdir");
let (alice_key, alice_pub) = mint(&keys, "alice");
let (bob_key, bob_pub) = mint(&keys, "bob");
// ── 1: bootstrap, with NO credential and no private key in sight ──────
let before = snapshot(&serving.home);
let cwd = std::env::current_dir().expect("cwd");
let pems_in_cwd_before = pem_files(&cwd);
let ran = gunnar(
&serving.home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", alice_pub.display()),
"--at",
&serving.grpc,
],
);
assert!(ran.ok, "the first bootstrap must succeed: {}", ran.out);
assert!(ran.says("enrolled alice"), "{}", ran.out);
assert!(ran.says("gunnar-admin"), "{}", ran.out);
assert!(
ran.says("account alice"),
"it must name the account the user owns: {}",
ran.out
);
// ── 2: LOGGED OUT. The ONLY thing written is the credential the server
// issued, in a directory of the user's own — and NOT in the current
// directory, where it sat until 2026-09-10 (this crate's directory, for
// this very test; a git checkout, for an operator).
let after = snapshot(&serving.home);
let created: std::collections::BTreeSet<_> = after.difference(&before).cloned().collect();
let server = serving.grpc.replace(':', "_");
let credential_dir = serving.home.join(".config").join("gunnar").join(&server);
let expected: std::collections::BTreeSet<PathBuf> = [
serving.home.join(".config"),
serving.home.join(".config").join("gunnar"),
credential_dir.clone(),
credential_dir.join("alice.cert.pem"),
credential_dir.join("alice.key.pem"),
credential_dir.join("gunnar-issuer.pem"),
]
.into_iter()
.collect();
assert_eq!(
created, expected,
"`gunnar control bootstrap` may write exactly the issued credential under \
~/.config/gunnar/<server>/ and nothing else: no session, no state. It must keep NO \
session: the unauthenticated door closed behind it, and a client that held \
privileged state would be the same defect as a server that held the door open. \
Output:\n{}",
ran.out
);
assert_eq!(
pem_files(&cwd),
pems_in_cwd_before,
"bootstrap wrote a .pem into the current directory {}: that is the 2026-09-10 defect, \
a private key one `git add` from a remote",
cwd.display()
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = |p: &Path| std::fs::metadata(p).expect("stat").permissions().mode() & 0o777;
assert_eq!(
mode(&credential_dir),
0o700,
"the credential directory is the owner's alone"
);
assert_eq!(
mode(&credential_dir.join("alice.key.pem")),
0o600,
"the private key is 0600"
);
}
assert!(
ran.says(&credential_dir.join("alice.key.pem").display().to_string()),
"and the command names where it put the key, so the next flag has a path to point \
at: {}",
ran.out
);
assert!(
ran.says("logged out"),
"and it must say so, so the operator is not left assuming a session: {}",
ran.out
);
// …proved by behaviour, not only by the filesystem: the very next
// privileged call with NO key fails.
let ran = gunnar(
&serving.home,
&[
"control",
"user",
"add",
"bob",
"--key",
&format!("@{}", bob_pub.display()),
"--at",
&serving.grpc,
],
);
assert!(
!ran.ok,
"creating a user immediately after bootstrap, with no key, must FAIL: {}",
ran.out
);
assert!(
ran.says("no ssh private key found") || ran.says("did not accept your key"),
"and it must say the credential is missing, not something else: {}",
ran.out
);
// ── 1b: a second bootstrap is refused, and says what to do instead ────
let ran = gunnar(
&serving.home,
&[
"control",
"bootstrap",
"bob",
"--key",
&format!("@{}", bob_pub.display()),
"--at",
&serving.grpc,
],
);
assert!(!ran.ok, "a second bootstrap must fail: {}", ran.out);
assert!(
ran.says("already been bootstrapped"),
"it must name the cause: {}",
ran.out
);
assert!(
ran.says("gunnar control user add"),
"…and say what to do instead, or the user is stuck: {}",
ran.out
);
assert!(
ran.says("does NOT reopen"),
"…and say that deleting identities will not help, because that is what they will try \
next: {}",
ran.out
);
// ── the id, on the ONE refusal that used to lose it ───────────────────
//
// This arm writes its own prose instead of going through `explain`, so it
// bypassed the single site that appends the request id and printed none —
// on the refusal a first-time user is most likely to hit, and the one where
// "which line in the server's log is this" is hardest to answer any other
// way, because the caller holds no credential and cannot ask the server
// anything else.
//
// Asserted against the SERVER'S OWN LOG, not merely on the shape of the
// string: a `request-id:` clause that named a line nobody wrote would read
// exactly like this one.
let id = ran
.out
.split("request-id: ")
.nth(1)
.map(|rest| {
rest.split_whitespace()
.next()
.unwrap_or_default()
.to_owned()
})
.unwrap_or_else(|| {
panic!(
"the refused second bootstrap printed no request-id. This arm builds its own \
message and used to skip the one writer that appends it:\n{}",
ran.out
)
});
assert_eq!(
id.len(),
16,
"the printed request-id {id:?} is not the 16 hex digits a server line carries"
);
let log = serving.log();
assert!(
log.lines()
.any(|line| line.contains(&format!("req={id}")) && line.contains("grpc.request.done")),
"the CLI printed request-id {id} and the server wrote no `grpc.request.done` line \
carrying `req={id}`, so the id names nothing an operator can grep for. Server log:\n{log}"
);
// ── 3: log in with the ssh key ────────────────────────────────────────
let ran = gunnar(
&serving.home,
&[
"control",
"login",
"-i",
alice_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(ran.ok, "login with the enrolled key: {}", ran.out);
assert!(ran.says("logged in"), "{}", ran.out);
assert!(
ran.says("gunnar-admin"),
"the first user is gunnar-admin: {}",
ran.out
);
// A key nobody registered is refused, and refused as a KEY problem.
let (stranger_key, _) = mint(&keys, "stranger");
let ran = gunnar(
&serving.home,
&[
"control",
"login",
"-i",
stranger_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(!ran.ok, "an unregistered key must not log in: {}", ran.out);
assert!(
ran.says("did not accept your key"),
"and it must say it is the key: {}",
ran.out
);
assert!(
!ran.says("could not be reached"),
"…never that the server is unreachable, because it plainly is not: {}",
ran.out
);
// ── 4: create a user, authenticated ───────────────────────────────────
let ran = gunnar(
&serving.home,
&[
"control",
"user",
"add",
"bob",
"--key",
&format!("@{}", bob_pub.display()),
"-i",
alice_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(ran.ok, "a gunnar-admin may create a user: {}", ran.out);
assert!(ran.says("created bob"), "{}", ran.out);
assert!(
ran.says("ordinary user"),
"a user created without --gunnar-admin is an ordinary user: {}",
ran.out
);
// bob can log in, and is told he is NOT gunnar-admin rather than being
// told his key failed.
let ran = gunnar(
&serving.home,
&[
"control",
"login",
"-i",
bob_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(ran.ok, "an ordinary user can log in: {}", ran.out);
assert!(ran.says("not gunnar-admin"), "{}", ran.out);
assert!(
ran.says("admin of your own account"),
"…and is told what he DOES have: {}",
ran.out
);
// ── and he may not create users ───────────────────────────────────────
let (mallory_key, mallory_pub) = mint(&keys, "mallory");
let _ = mallory_key;
let ran = gunnar(
&serving.home,
&[
"control",
"user",
"add",
"mallory",
"--key",
&format!("@{}", mallory_pub.display()),
"-i",
bob_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(
!ran.ok,
"an ordinary user must NOT be able to create users: {}",
ran.out
);
assert!(
ran.says("not allowed to do this"),
"and must be told it is authorisation, not authentication: {}",
ran.out
);
// ── gunnar-admin is grantable onward, by any gunnar-admin ─────────────
let ran = gunnar(
&serving.home,
&[
"control",
"grant",
"bob",
"admin",
"--server",
"-i",
alice_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(
ran.ok,
"a gunnar-admin may hand gunnar-admin on: {}",
ran.out
);
let ran = gunnar(
&serving.home,
&[
"control",
"user",
"add",
"mallory",
"--key",
&format!("@{}", mallory_pub.display()),
"-i",
bob_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(
ran.ok,
"bob is gunnar-admin now and must be able to create users: {}",
ran.out
);
// ── and removing every user still does not reopen the door ────────────
let ran = gunnar(&serving.home, &["control", "status", "--at", &serving.grpc]);
assert!(ran.ok);
assert!(
ran.says("bootstrap closed"),
"the window is closed and stays closed: {}",
ran.out
);
assert!(
ran.says("alice"),
"…and the server says who claimed it: {}",
ran.out
);
}
// ───────────────────────────────────────────────────────────────────────────
// The fresh-server story at its real scale
// ───────────────────────────────────────────────────────────────────────────
/// **Two gunnar-admins, a hundred ordinary users, and the split holds.**
///
/// The four-step flow above proves the verbs at three users; this proves the
/// same door at the population a real server starts life with: `alice` and
/// `bob` as gunnar-admins, `user1`…`user100` as ordinary users. Every call
/// is the real binary against the real live server, authenticated per call
/// with the caller's own key — nothing here reaches around the transport.
///
/// Both directions, so neither half is vacuous:
///
/// - **green** — each admin creates fifty of the hundred, which is the only
/// proof that bob's `--gunnar-admin` grant actually grants;
/// - **red** — an ordinary user is refused BOTH admin verbs over the wire
/// (create a user, grant himself gunnar-admin), and a login with the same
/// key proves the refusals are authorisation, not a broken credential.
///
/// The final sweep stops the server and reads the table itself: the admin set
/// must be EXACTLY `{alice, bob}` across all 102 principals, so one stray
/// server-wide grant to any of the hundred fails the test even if every
/// over-the-wire refusal above passed.
///
/// Seen red (measure, never assert), with a temporary `grant user57 admin
/// --server` inserted before the red legs, each guard individually: the
/// user-add refusal fails with *"user57, an ordinary user, was allowed to
/// create a user"*; the self-grant refusal with *"granted user57 Admin on the
/// whole server"*; the login guard reports *"role gunnar-admin"*; the count
/// sees 103 (mallory got created); and the sweep fails with the admin set at
/// `{"bob", "alice", "user57"}`.
#[test]
fn two_admins_create_a_hundred_users_and_the_split_holds() {
let mut serving = Serving::fresh();
let keys = serving.home.join("keys");
std::fs::create_dir_all(&keys).expect("mkdir");
let (alice_key, alice_pub) = mint(&keys, "alice");
let (bob_key, bob_pub) = mint(&keys, "bob");
// ── the no-auth logon: an empty auth table reports its window open ────
let ran = gunnar(&serving.home, &["control", "status", "--at", &serving.grpc]);
assert!(ran.ok, "status against the fresh server: {}", ran.out);
assert!(
ran.says("bootstrap OPEN"),
"an empty auth table must serve status with no credential and say the window is open: {}",
ran.out
);
// ── admin 1: alice, through the unauthenticated bootstrap door ──────
let ran = gunnar(
&serving.home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", alice_pub.display()),
"--at",
&serving.grpc,
],
);
assert!(ran.ok, "bootstrap with no credential: {}", ran.out);
assert!(ran.says("gunnar-admin"), "{}", ran.out);
// ── admin 2: bob, created WITH the grant by the first admin ────────
let ran = gunnar(
&serving.home,
&[
"control",
"user",
"add",
"bob",
"--key",
&format!("@{}", bob_pub.display()),
"--gunnar-admin",
"-i",
alice_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(ran.ok, "alice creates bob as gunnar-admin: {}", ran.out);
assert!(
ran.says("role gunnar-admin"),
"bob must come back an admin, not an ordinary user: {}",
ran.out
);
// ── the hundred: fifty by each admin, every one an ordinary user ──────
for i in 1..=100u32 {
let name = format!("user{i}");
let (_, user_pub) = mint(&keys, &name);
let admin_key = if i <= 50 { &alice_key } else { &bob_key };
let ran = gunnar(
&serving.home,
&[
"control",
"user",
"add",
&name,
"--key",
&format!("@{}", user_pub.display()),
"-i",
admin_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(ran.ok, "{name} must be creatable: {}", ran.out);
assert!(
ran.says("role ordinary user"),
"{name} must be an ordinary user: {}",
ran.out
);
}
// ── the server's own count, over the wire ─────────────────────────────
let ran = gunnar(&serving.home, &["control", "status", "--at", &serving.grpc]);
assert!(ran.ok, "{}", ran.out);
assert!(
ran.says("principals 102"),
"2 admins + 100 users must be 102 principals: {}",
ran.out
);
assert!(
// 102 ssh keys plus the bootstrap certificate alice left with.
ran.says("keys 103"),
"every principal keyed, plus alice's bootstrap certificate: {}",
ran.out
);
// ── RED: an ordinary user is refused both admin verbs ─────────────────
//
// user57 — created by bob, so this leg also depends on admin 2 having
// been a real admin. His own key signs both calls.
let user57_key = keys.join("user57");
let (_, mallory_pub) = mint(&keys, "mallory");
let ran = gunnar(
&serving.home,
&[
"control",
"user",
"add",
"mallory",
"--key",
&format!("@{}", mallory_pub.display()),
"-i",
user57_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(
!ran.ok,
"user57, an ordinary user, was allowed to create a user: {}",
ran.out
);
assert!(
ran.says("not allowed to do this"),
"and the refusal must be authorisation by name: {}",
ran.out
);
let ran = gunnar(
&serving.home,
&[
"control",
"grant",
"user57",
"admin",
"--server",
"-i",
user57_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(
!ran.ok,
"user57 was allowed to grant HIMSELF gunnar-admin, which is the whole \
privilege-escalation door: {}",
ran.out
);
assert!(ran.says("not allowed to do this"), "{}", ran.out);
// …and the key itself is fine, so the two refusals were the ACL speaking.
let ran = gunnar(
&serving.home,
&[
"control",
"login",
"-i",
user57_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(ran.ok, "user57 can log in: {}", ran.out);
assert!(
ran.says("not gunnar-admin"),
"and is told what he is: {}",
ran.out
);
// Nothing landed on the way out: mallory does not exist.
let ran = gunnar(&serving.home, &["control", "status", "--at", &serving.grpc]);
assert!(
ran.says("principals 102"),
"the refused calls must not have created anything: {}",
ran.out
);
// ── the sweep: the admin set is EXACTLY the two, out of 102 ───────────
//
// The server is stopped first because a serving gunnar holds the control
// store's exclusive lock; the read is against the same data path the live
// assertions above were served from.
serving.child.kill().expect("stop the server");
serving.child.wait().expect("reap it");
let stores = gunnar_cli::server::ServerStores::open(serving._dir.path().join("data"))
.expect("open the stores the server just served from");
let auth = stores.auth();
let principals = auth.principals().expect("read the table");
assert_eq!(principals.len(), 102, "2 admins + 100 users");
let admins: std::collections::BTreeSet<String> = principals
.iter()
.filter(|p| {
auth.may_server(&p.id, gunnar_core::Level::Admin)
.expect("acl")
})
.map(|p| p.id.clone())
.collect();
assert_eq!(
admins,
["alice", "bob"]
.into_iter()
.map(str::to_owned)
.collect::<std::collections::BTreeSet<_>>(),
"the gunnar-admin set must be exactly the two admins; anybody else in it means the \
split did not hold"
);
}
// ───────────────────────────────────────────────────────────────────────────
// The verbs that used to need the server stopped
// ───────────────────────────────────────────────────────────────────────────
/// **Every administrative verb below used to require stopping the server.**
///
/// The offline `gunnar namespace`, `gunnar repo delete` and the visibility and
/// default-branch settings write the control repository *directly*, and a
/// running `gunnar serve` holds that store's exclusive lock. So an operator who
/// wanted to delete a repository had to take the whole forge down to do it.
/// The RPCs to do all of it online had been served since the control plane was
/// written; `gunnar control` covered `user`, `repo create/list`, `grant` and
/// `bootstrap`, and nothing else.
///
/// The claim under test is therefore not "the verb works". It is **"the verb
/// works while the server is up"**, so the server is never stopped anywhere in
/// this test and the last act is a `list` against the same live process.
///
/// Seen red: with the `Namespace` arm removed from `ControlCommand`, clap
/// refuses the argument and the first assertion fails with *"unrecognized
/// subcommand 'namespace'"*. With `--yes` accepted but ignored, the guard arm
/// fails with *"a delete with no --yes deleted it"*.
#[test]
fn namespace_repo_delete_visibility_and_default_branch_all_work_against_a_running_server() {
let serving = Serving::fresh();
let keys = serving.home.join("keys");
std::fs::create_dir_all(&keys).expect("mkdir");
let (admin, admin_pub) = mint(&keys, "alice");
let at = serving.grpc.clone();
let key = admin.display().to_string();
let ran = gunnar(
&serving.home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", admin_pub.display()),
"--at",
&at,
],
);
assert!(ran.ok, "bootstrap: {}", ran.out);
// ── namespace create / list ────────────────────────────────────────────
let ran = gunnar(
&serving.home,
&[
"control",
"namespace",
"create",
"team",
"-i",
&key,
"--at",
&at,
],
);
assert!(
ran.ok,
"`gunnar control namespace create` against a RUNNING server: {}",
ran.out
);
assert!(ran.says("created team"), "{}", ran.out);
let ran = gunnar(
&serving.home,
&["control", "namespace", "list", "-i", &key, "--at", &at],
);
assert!(ran.ok, "namespace list: {}", ran.out);
assert!(
ran.says("team"),
"list must show the namespace that was just created: {}",
ran.out
);
// ── repo create, then the three settings that had no online verb ───────
let ran = gunnar(
&serving.home,
&[
"control",
"repo",
"create",
"team/project",
"--hash",
"sha1",
"-i",
&key,
"--at",
&at,
],
);
assert!(ran.ok, "repo create: {}", ran.out);
assert!(ran.says("repo team/project"), "{}", ran.out);
assert!(
ran.says("access private"),
"a new repository is private, and `create` must say which: {}",
ran.out
);
let ran = gunnar(
&serving.home,
&[
"control",
"repo",
"set-default-branch",
"team/project",
"trunk",
"-i",
&key,
"--at",
&at,
],
);
assert!(ran.ok, "set-default-branch: {}", ran.out);
assert!(
ran.says("branch refs/heads/trunk"),
"the short name must come back as the FULL ref the server stored, so an operator can \
see what was actually written rather than what they typed: {}",
ran.out
);
let ran = gunnar(
&serving.home,
&[
"control",
"repo",
"set-visibility",
"team/project",
"public",
"-i",
&key,
"--at",
&at,
],
);
assert!(ran.ok, "set-visibility: {}", ran.out);
assert!(ran.says("access public"), "{}", ran.out);
assert!(
ran.says("anonymous READ") && ran.says("Nothing became writable"),
"`public` is the one setting most likely to be misread as write access; the verb must \
say what it did NOT do: {}",
ran.out
);
// ── get reads back what the three writes did, in one row ───────────────
let ran = gunnar(
&serving.home,
&[
"control",
"repo",
"get",
"team/project",
"-i",
&key,
"--at",
&at,
],
);
assert!(ran.ok, "repo get: {}", ran.out);
assert!(
ran.says("hash sha1")
&& ran.says("branch refs/heads/trunk")
&& ran.says("access public"),
"`get` must report every field the setters moved, read back off the SERVER rather than \
echoed by the setter that moved it: {}",
ran.out
);
// ── a name that is taken is ALREADY_EXISTS, not an internal error ──────
//
// `CoreControlPlane::internal` used to map every `gunnar_core::Error` to
// `INTERNAL` at all thirty-four of its call sites, so "that name is taken"
// and "a bad branch name" and "this repository does not exist" all arrived
// as "this server is broken" and sent an operator to read logs instead of
// their own command line.
let ran = gunnar(
&serving.home,
&[
"control",
"namespace",
"create",
"team",
"-i",
&key,
"--at",
&at,
],
);
assert!(!ran.ok, "creating a namespace twice must fail: {}", ran.out);
assert!(
!ran.says("Internal") && !ran.says("internal error"),
"a name that is already taken is the CALLER's situation and must not be reported as a \
server fault: {}",
ran.out
);
assert!(
ran.says("already exists") && ran.says("team"),
"the refusal must name what already exists: {}",
ran.out
);
// …and a branch name the server will not take is INVALID_ARGUMENT, for the
// same reason and through the same function.
let ran = gunnar(
&serving.home,
&[
"control",
"repo",
"set-default-branch",
"team/project",
"refs/tags/v1",
"-i",
&key,
"--at",
&at,
],
);
assert!(!ran.ok, "a tag is not a default branch: {}", ran.out);
assert!(
!ran.says("Internal") && !ran.says("internal error"),
"a bad branch name is an argument error, not a server fault: {}",
ran.out
);
assert!(
ran.says("invalid argument"),
"and it must say which kind of refusal it is: {}",
ran.out
);
// ── delete refuses without --yes, and the repository survives ──────────
let ran = gunnar(
&serving.home,
&[
"control",
"repo",
"delete",
"team/project",
"-i",
&key,
"--at",
&at,
],
);
assert!(!ran.ok, "a delete with no --yes must fail: {}", ran.out);
// **The two refusals are DIFFERENT sentences, and the difference is the
// point** (36b04371): a plain delete is recoverable and says so,
// a `--purge` is not and says THAT. One warning for both would either
// frighten people off the safe verb or fail to warn them off the dangerous
// one — so this asserts each verb's own sentence.
//
// Both sides of this merge had independently found the same stale
// assertion ("there is no undo", demanded of the SOFT delete after
// `Repos.Undelete` shipped) and each caught something the other did not.
// Master's positives are the more specific — they name the bytes coming
// back, not merely the word `undelete` — and the branch's NEGATIVE is the one
// that stops the old sentence creeping back in. Both are kept, because a
// test that only asserts the new wording would pass again the day somebody
// adds the old one beside it.
assert!(
ran.says("bytes are kept") && ran.says("undelete team/project") && ran.says("--yes"),
"a soft delete's refusal must name the path, say the bytes come back, and name the \
flag that means it: {}",
ran.out
);
assert!(
!ran.says("there is no undo"),
"delete is soft now; a refusal that still says the old sentence is telling an operator \
something false about their own data: {}",
ran.out
);
let purge = gunnar(
&serving.home,
&[
"control",
"repo",
"delete",
"team/project",
"--purge",
"-i",
&key,
"--at",
&at,
],
);
assert!(!purge.ok, "a purge with no --yes must fail: {}", purge.out);
assert!(
purge.says("there is no undo") && purge.says("team/project"),
"a --purge refusal must name the path and the consequence: {}",
purge.out
);
let ran = gunnar(
&serving.home,
&[
"control",
"repo",
"get",
"team/project",
"-i",
&key,
"--at",
&at,
],
);
assert!(
ran.ok && ran.says("repo team/project"),
"a delete with no --yes deleted it anyway: {}",
ran.out
);
// ── …and with --yes it is gone, off the live server ────────────────────
let ran = gunnar(
&serving.home,
&[
"control",
"repo",
"delete",
"team/project",
"--yes",
"-i",
&key,
"--at",
&at,
],
);
assert!(ran.ok, "repo delete --yes: {}", ran.out);
assert!(ran.says("deleted team/project"), "{}", ran.out);
let ran = gunnar(
&serving.home,
&["control", "repo", "list", "-i", &key, "--at", &at],
);
assert!(ran.ok, "repo list: {}", ran.out);
assert!(
ran.says("no repositories"),
"the repository is still listed after being deleted: {}",
ran.out
);
// ── namespace delete, and the count it reports ─────────────────────────
let ran = gunnar(
&serving.home,
&[
"control",
"namespace",
"delete",
"team",
"-i",
&key,
"--at",
&at,
],
);
assert!(ran.ok, "namespace delete: {}", ran.out);
assert!(
ran.says("0 repositories went with it"),
"the count is the point: a namespace that took eleven repositories with it and one that \
took none are different events: {}",
ran.out
);
// ── the server is STILL UP, which is the whole claim ───────────────────
let ran = gunnar(
&serving.home,
&["control", "namespace", "list", "-i", &key, "--at", &at],
);
assert!(
ran.ok,
"the server did not survive its own administration; every verb above is supposed to be \
the ONLINE alternative to stopping it: {}",
ran.out
);
assert!(
!ran.says("team"),
"`team` is still listed after being deleted: {}",
ran.out
);
// `alice` is still there, and must be: a user's own account is a
// namespace and deleting somebody else's does not touch it. Without this
// the assertion above would also pass against a `list` that printed
// nothing at all.
assert!(
ran.says("alice"),
"the bootstrapped user's own account namespace went with `team`: {}",
ran.out
);
}
/// **`control repo gc` reaches a real gc on a real server, waits for it, and
/// reports the outcome — and `repo gc-status` can still say so afterwards.**
///
/// The RPC underneath is asynchronous: `Repos.Gc` enqueues and returns, because
/// the server runs one gc at a time for the whole box. The CLI is not, and this
/// asserts that seam: a person types `repo gc`, the CLI polls the queue for
/// them, and they are told what happened without having to know there was a
/// queue at all.
///
/// **The repository here is empty, and the engine refuses to gc a repository
/// with no ref** — every object in it would be dead, and a gc that deleted
/// everything is not a gc. That refusal is what this test drives to, on
/// purpose: it is the full asynchronous path (accept, queue, worker, engine,
/// job, poll, report) and it ends in the engine's own sentence rather than in a
/// stub. The DONE-with-bytes half of the same path is
/// `gunnar-server/tests/gc_rpc.rs`, which plants a ref and asserts the sealed
/// generation on disk — a live push is not reachable from this harness.
///
/// The two failure modes must come back DIFFERENT, and both halves are here: a
/// name that was never a repository is refused on the request that made it,
/// while a live repository that cannot be maintained fails its job and the
/// reason survives for `gc-status` to print.
#[test]
fn repo_gc_reaches_the_server_waits_for_the_job_and_reports_the_outcome() {
let serving = Serving::fresh();
let keys = serving.home.join("keys");
std::fs::create_dir_all(&keys).expect("mkdir");
let (admin, admin_pub) = mint(&keys, "alice");
let at = serving.grpc.clone();
let key = admin.display().to_string();
let ran = gunnar(
&serving.home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", admin_pub.display()),
"--at",
&at,
],
);
assert!(ran.ok, "bootstrap: {}", ran.out);
gunnar(
&serving.home,
&[
"control",
"namespace",
"create",
"swept",
"-i",
&key,
"--at",
&at,
],
);
let ran = gunnar(
&serving.home,
&[
"control",
"repo",
"create",
"swept/one",
"--hash",
"sha1",
"-i",
&key,
"--at",
&at,
],
);
assert!(ran.ok, "repo create: {}", ran.out);
// Nothing has been asked for yet, and that is a different answer from a gc
// that reclaimed nothing.
let ran = gunnar(
&serving.home,
&[
"control",
"repo",
"gc-status",
"swept/one",
"-i",
&key,
"--at",
&at,
],
);
assert!(ran.ok, "gc-status before any gc: {}", ran.out);
assert!(
ran.says("no job"),
"a repository nobody has gc'd must say so, not report a run: {}",
ran.out
);
// The full path, end to end: the CLI enqueues, waits out the queue, and
// reports the engine's own refusal with a non-zero exit. The words matter —
// an operator reads this and has to know it was their empty repository and
// not the server that was wrong.
let ran = gunnar(
&serving.home,
&[
"control",
"repo",
"gc",
"swept/one",
"-i",
&key,
"--at",
&at,
],
);
assert!(
!ran.ok,
"a gc the engine refused must not exit 0: {}",
ran.out
);
assert!(
ran.says("swept/one") && ran.says("no ref"),
"the CLI must report the engine's reason, not a bare failure: {}",
ran.out
);
// **The outcome outlives the run**, which is the whole reason a console can
// poll it. A `gc-status` that answered "no job" here would be a button that
// goes dark having never said anything.
let ran = gunnar(
&serving.home,
&[
"control",
"repo",
"gc-status",
"swept/one",
"-i",
&key,
"--at",
&at,
],
);
assert!(ran.ok, "gc-status after the gc: {}", ran.out);
assert!(
ran.says("FAILED") && ran.says("no ref"),
"a finished gc must still be readable afterwards, with its reason: {}",
ran.out
);
// A repository that does not exist is refused NOW, on the request that
// named it — not accepted and then failed minutes later inside a job
// nobody is watching.
let ran = gunnar(
&serving.home,
&[
"control",
"repo",
"gc",
"swept/nope",
"-i",
&key,
"--at",
&at,
],
);
assert!(
!ran.ok,
"a gc of a nonexistent repository succeeded: {}",
ran.out
);
}
/// **A namespace that still holds a repository is not deleted by accident.**
///
/// `DeleteNamespaceRequest::delete_repos` exists so that destroying data is
/// never implicit, and the CLI has to surface both halves: the refusal, and
/// the flag that means it.
///
/// Seen red: with `delete_repos` hard-coded to `true`, the first arm fails with
/// *"a namespace holding a repository was deleted with no --delete-repos"*.
#[test]
fn deleting_a_namespace_that_holds_a_repository_needs_the_flag_that_says_so() {
let serving = Serving::fresh();
let keys = serving.home.join("keys");
std::fs::create_dir_all(&keys).expect("mkdir");
let (admin, admin_pub) = mint(&keys, "alice");
let at = serving.grpc.clone();
let key = admin.display().to_string();
let ran = gunnar(
&serving.home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", admin_pub.display()),
"--at",
&at,
],
);
assert!(ran.ok, "bootstrap: {}", ran.out);
gunnar(
&serving.home,
&[
"control",
"namespace",
"create",
"held",
"-i",
&key,
"--at",
&at,
],
);
let ran = gunnar(
&serving.home,
&[
"control", "repo", "create", "held/one", "--hash", "sha1", "-i", &key, "--at", &at,
],
);
assert!(ran.ok, "repo create: {}", ran.out);
let ran = gunnar(
&serving.home,
&[
"control",
"namespace",
"delete",
"held",
"-i",
&key,
"--at",
&at,
],
);
assert!(
!ran.ok,
"a namespace holding a repository was deleted with no --delete-repos: {}",
ran.out
);
// Still there. The refusal is asserted by what survived it, not only by an
// exit code.
let ran = gunnar(
&serving.home,
&["control", "repo", "list", "-i", &key, "--at", &at],
);
assert!(ran.says("held/one"), "the repository is gone: {}", ran.out);
let ran = gunnar(
&serving.home,
&[
"control",
"namespace",
"delete",
"held",
"--delete-repos",
"-i",
&key,
"--at",
&at,
],
);
assert!(ran.ok, "namespace delete --delete-repos: {}", ran.out);
assert!(
ran.says("1 repository went with it"),
"the count must be the number that actually went, so an operator can tell a namespace \
that was empty from one that was not: {}",
ran.out
);
}
/// **A repository path is parsed on this side of the wire.**
///
/// Every verb that takes `<account>/<name>` goes through one parser, so a typo
/// is a local error naming the argument rather than an `INVALID_ARGUMENT` from
/// a box that may be at the other end of an ssh tunnel — and so the five verbs
/// cannot disagree about what a legal path is (one implementation).
///
/// Seen red: with `repo_path` reverted to `split_once('/')` with no emptiness
/// check, `team/` is accepted and the third arm fails.
#[test]
fn a_malformed_repository_path_fails_locally_and_names_the_argument() {
let dir = tempfile::tempdir().expect("a temp dir");
// Port 1: nothing listens. A path error must therefore never mention the
// connection, which is what proves it was decided before one was opened.
for verb in [
vec!["control", "repo", "get", "nolash"],
vec!["control", "repo", "delete", "nolash", "--yes"],
vec!["control", "repo", "set-visibility", "nolash", "public"],
vec!["control", "repo", "get", "team/"],
vec!["control", "repo", "get", "/project"],
] {
let mut args = verb.clone();
args.extend_from_slice(&["--at", "127.0.0.1:1"]);
let ran = gunnar(dir.path(), &args);
assert!(!ran.ok, "{verb:?} was accepted: {}", ran.out);
assert!(
ran.says("a repository path is <account>/<name>"),
"{verb:?} must fail on the PATH, not on the connection: {}",
ran.out
);
assert!(
!ran.says("could not be reached"),
"{verb:?} opened a connection before deciding the path was malformed: {}",
ran.out
);
}
}
/// **The id `gunnar control` prints on a refusal names a line in the server's
/// own log**.
///
/// This is the operator's 03:00 question asked of the control plane: *someone
/// says a call failed — can I find out why?* Before this the answer was no.
/// `gunnar-grpc` emitted one audit record per **authorised** call and nothing
/// else; a refusal, a call to a method that does not exist, and a slow call
/// were all indistinguishable from silence, and nothing the caller held could
/// be grepped for.
///
/// So this asserts the whole chain, end to end, with nothing in it stubbed:
///
/// 1. a real `gunnar control user add` is refused by a real `gunnar serve`;
/// 2. the CLI's own output carries `request-id: <id>`;
/// 3. the **server's stderr on disk** — the file a journal or `podman logs`
/// would hold — carries a `grpc.request.done` line with `req=<that same
/// id>`, `grpc_status=7` (PERMISSION_DENIED) and an `elapsed_ms`.
///
/// # Why the log is read as text and not through a capture layer
///
/// Because that is what an operator has. `tracing_subscriber`'s ANSI escapes
/// wrap **field names**, so a coloured log makes `grep req=` match nothing and
/// a pipeline summing the matches report a confident zero — the defect
/// `with_ansi(stderr().is_terminal())` fixed on 2026-08-05. Asserting on a
/// captured in-process `Line` would never see that class of bug again. This
/// greps the bytes.
///
/// # Seen red
///
/// * With the `request-id` clause removed from `control::explain`, this fails
/// at *"the refusal printed no request-id"*.
/// * With `.layer(RequestSpanLayer::new(..))`'s stamp removed from the
/// response, the CLI prints no id and it fails at the same place — which is
/// why the third assertion, on the server's own bytes, is separate: only that
/// one can tell "the client was given an id nobody logged" from "no id at
/// all".
/// * With `grpc_status` dropped from the exit line, it fails at *"the server
/// line does not say WHY"* while the id assertions still pass.
#[test]
fn a_refused_control_call_prints_an_id_that_names_a_line_in_the_server_log() {
let serving = Serving::fresh();
let keys = tempfile::tempdir().expect("a key dir");
let (admin_key, admin_pub) = mint(keys.path(), "admin");
let (ordinary_key, ordinary_pub) = mint(keys.path(), "ordinary");
let (_, victim_pub) = mint(keys.path(), "victim");
let ran = gunnar(
&serving.home,
&[
"control",
"bootstrap",
"admin",
"--key",
&format!("@{}", admin_pub.display()),
"--at",
&serving.grpc,
],
);
assert!(ran.ok, "bootstrap: {}", ran.out);
let ran = gunnar(
&serving.home,
&[
"control",
"user",
"add",
"ordinary",
"--key",
&format!("@{}", ordinary_pub.display()),
"-i",
admin_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(ran.ok, "creating the ordinary user: {}", ran.out);
// ── the refusal an operator would be handed a ticket about ─────────────
let ran = gunnar(
&serving.home,
&[
"control",
"user",
"add",
"victim",
"--key",
&format!("@{}", victim_pub.display()),
"-i",
ordinary_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(
!ran.ok,
"an ordinary user must not be able to create a user; without a refusal this test \
asserts nothing: {}",
ran.out
);
let marker = "request-id: ";
let id: String = ran
.out
.split_once(marker)
.map(|(_, rest)| {
rest.chars()
.take_while(char::is_ascii_hexdigit)
.collect::<String>()
})
.filter(|id| !id.is_empty())
.unwrap_or_else(|| {
panic!(
"the refusal printed no request-id, so the person holding this error has \
nothing to give the person holding the log:\n{}",
ran.out
)
});
assert_eq!(
id.len(),
16,
"a request id is sixteen fixed-width hex digits; {id:?} is not one, and a variable-width \
id does not line up in a column an operator reads"
);
// ── the server's own bytes ─────────────────────────────────────────────
let log = serving.log();
// Non-vacuity: an empty log contains no escapes and no wrong ids either,
// and would satisfy every "does not contain" below.
assert!(
log.contains("INFO") || log.contains("WARN"),
"the server wrote nothing to stderr, so nothing below is being asserted:\n{log}"
);
assert!(
!log.contains('\u{1b}'),
"the server's log carries ANSI escapes when stderr is a FILE, so `grep req=` over a \
captured log matches nothing:\n{}",
log.escape_debug()
);
let line = log
.lines()
.filter(|line| line.contains("grpc.request.done"))
.find(|line| line.contains(&format!("req={id}")))
.unwrap_or_else(|| {
panic!(
"the CLI printed request-id {id} and no grpc.request.done line in the server's \
log carries req={id}; the caller holds an identifier that names nothing:\n{log}"
)
});
assert!(
line.contains("grpc_status=7"),
"the server line does not say WHY the call was refused (7 = PERMISSION_DENIED):\n{line}"
);
assert!(
line.contains("elapsed_ms="),
"no elapsed_ms on the exit line, so 'was it slow or was it refused' is unanswerable — \
and this field is the one a tonic INTERCEPTOR could never have produced:\n{line}"
);
assert!(
line.contains("WARN"),
"a refusal must be at warn or a server running RUST_LOG=warn logs nothing about \
it:\n{line}"
);
// And the audit record for the SAME call inherits the same id, which is the
// hop from "which call" to "which principal".
assert!(
log.lines().any(|other| other.contains(&format!("req={id}"))
&& other.contains("principal=")
&& !other.contains("grpc.request.")),
"no line naming a principal carries req={id}, so the id stops at the transport and never \
reaches the authorisation decision:\n{log}"
);
}
/// **`SIGUSR1` makes a running server more verbose, and `SIGUSR2` puts it
/// back — with no restart**.
///
/// An operator debugging a live gunnar had exactly one way to see more:
/// restart it, which ends every in-flight transfer and very often stops the
/// thing they were debugging from happening. On an appliance there is no shell
/// to re-export `RUST_LOG` in and the process is PID 1.
///
/// # What makes this a real assertion and not a round-trip
///
/// It does not check that a filter object changed. It drives a **real
/// `gunnar serve`** at `RUST_LOG=info`, makes a real gRPC call three times, and
/// counts a line that only exists at `debug` — `grpc.request.begin` — in the
/// server's **stderr on disk**, before the signal, after `SIGUSR1`, and after
/// `SIGUSR2`. The before-count must be **zero** and the zero must be *earned*:
/// the `grpc.request.done` line from the same call has to be there, or an
/// unreachable server would satisfy the whole test.
///
/// That is also why the level is exercised through `tracing`'s **global
/// max-level short-circuit** rather than through a filter directive alone: a
/// `debug!` callsite under an `info` filter is not merely filtered, it is not
/// evaluated. Nothing but `Handle::reload` rebuilding the interest cache makes
/// it fire, so a "reload" that only swapped the filter object would leave this
/// test at zero.
///
/// # Seen red
///
/// * With `control.handle.reload(filter)` removed from `logging::apply` (so the
/// rung counter still advances and the acknowledgement is still printed),
/// this fails at *"SIGUSR1 was acknowledged and the log did not get more
/// verbose"* — which is precisely the shape of a level control that reports
/// success and does nothing.
/// * Removing `apply`'s `quieter` branch does **not** fail this test, and that
/// is recorded rather than hidden: at `RUST_LOG=info` a `warn!` is printable
/// under both filters, so the ordering is invisible here. It is owned by
/// `a_quiet_server_still_gets_told_its_level_went_back_down`.
#[test]
fn a_signal_changes_a_running_servers_log_level_without_restarting_it() {
use rustix::process::{kill_process, Signal};
let serving = Serving::fresh();
let pid =
rustix::process::Pid::from_raw(serving.child.id() as i32).expect("the child has a pid");
/// One `Bootstrap.Status` over gRPC: no credential, no key, always served.
fn poke(serving: &Serving) {
let ran = gunnar(&serving.home, &["control", "status", "--at", &serving.grpc]);
assert!(ran.ok, "control status against a live server: {}", ran.out);
}
/// Wait until `needle` appears in the server's log, or fail saying what was
/// there instead. A signal is asynchronous; the alternative to waiting is a
/// flake that reads exactly like the bug.
fn wait_for(serving: &Serving, needle: &str, what: &str) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
while std::time::Instant::now() < deadline {
if serving.log().contains(needle) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
panic!(
"{what}; the server's log never contained {needle:?}:\n{}",
serving.log()
);
}
fn count(serving: &Serving, needle: &str) -> usize {
serving
.log()
.lines()
.filter(|line| line.contains(needle))
.count()
}
// ── before: info, and the debug line is absent ─────────────────────────
poke(&serving);
wait_for(
&serving,
"grpc.request.done",
"the server never logged the call at all, so the zero below would be meaningless",
);
assert_eq!(
count(&serving, "grpc.request.begin"),
0,
"a debug-only line is in an `info` server's log, so this test cannot tell a working \
reload from a broken filter:\n{}",
serving.log()
);
let done_before = count(&serving, "grpc.request.done");
assert!(done_before > 0);
// ── SIGUSR1: one rung up ───────────────────────────────────────────────
kill_process(pid, Signal::USR1).expect("send SIGUSR1");
wait_for(
&serving,
"level=debug",
"SIGUSR1 was not acknowledged. Note that the DEFAULT disposition of SIGUSR1 is to \
TERMINATE, so an unhandled one would have killed the server",
);
poke(&serving);
wait_for(
&serving,
"grpc.request.begin",
"SIGUSR1 was acknowledged and the log did not get more verbose, so the level control \
reports a change it did not make",
);
let begin_after_usr1 = count(&serving, "grpc.request.begin");
assert!(begin_after_usr1 > 0);
// ── SIGUSR2: straight back to the startup filter ───────────────────────
kill_process(pid, Signal::USR2).expect("send SIGUSR2");
wait_for(
&serving,
"level=info",
"SIGUSR2 was not acknowledged, or the acknowledgement was written under the QUIETER \
filter and therefore never printed",
);
let done_before_third = count(&serving, "grpc.request.done");
poke(&serving);
// Wait for the third call to have been served before counting, or the
// assertion below could pass simply by looking too early.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
while count(&serving, "grpc.request.done") == done_before_third
&& std::time::Instant::now() < deadline
{
std::thread::sleep(std::time::Duration::from_millis(25));
}
assert!(
count(&serving, "grpc.request.done") > done_before_third,
"the third call was never served:\n{}",
serving.log()
);
assert_eq!(
count(&serving, "grpc.request.begin"),
begin_after_usr1,
"SIGUSR2 was acknowledged and the server is still at debug, so an operator cannot get \
their production log level back without a restart:\n{}",
serving.log()
);
// …and the server is still the same process, which is the entire claim.
assert_eq!(
serving.child.id() as i32,
pid.as_raw_nonzero().get(),
"the pid moved, so something restarted and this proved nothing"
);
let ran = gunnar(&serving.home, &["control", "status", "--at", &serving.grpc]);
assert!(
ran.ok,
"the server did not survive two signals whose default action is to terminate it: {}",
ran.out
);
}
/// **A server quieter than `warn` is still told when its level goes back
/// down**.
///
/// The acknowledgement is at `warn`, so on a box running `RUST_LOG=error` the
/// order the reload and the announcement happen in decides whether the operator
/// sees anything at all. Going **up** is safe either way — the new filter is
/// `debug`. Going **back down** is not: announce after reloading and the line
/// is written under `error`, where a `warn!` is not printable, and the operator
/// who just turned `trace` off on a loaded server gets silence as their
/// confirmation.
///
/// So `logging::apply` announces under whichever of the two filters is the more
/// verbose: after the reload on the way up, before it on the way down. This is
/// the test that can see the difference; the `info` one above cannot, and says
/// so.
///
/// Seen red: with `apply`'s `quieter` branch removed, the wait for the `USR2`
/// acknowledgement times out and this fails, while
/// `a_signal_changes_a_running_servers_log_level_without_restarting_it` still
/// passes.
#[test]
fn a_quiet_server_still_gets_told_its_level_went_back_down() {
use rustix::process::{kill_process, Signal};
let serving = Serving::fresh_at("error");
let pid =
rustix::process::Pid::from_raw(serving.child.id() as i32).expect("the child has a pid");
let wait_for = |needle: &str, what: &str| {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
while std::time::Instant::now() < deadline {
if serving.log().contains(needle) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
panic!(
"{what}; the server's log never contained {needle:?}:\n{}",
serving.log()
);
};
// Non-vacuity: at `error` the server says nothing at all about a served
// call, which is what makes this the interesting rung.
let ran = gunnar(&serving.home, &["control", "status", "--at", &serving.grpc]);
assert!(ran.ok, "control status: {}", ran.out);
assert!(
!serving.log().contains("grpc.request.done"),
"a server at RUST_LOG=error logged a served call, so `error` is not being honoured and \
the whole premise of this test is wrong:\n{}",
serving.log()
);
kill_process(pid, Signal::USR1).expect("send SIGUSR1");
wait_for(
"level=debug",
"the way UP was not acknowledged, which is the easy direction",
);
kill_process(pid, Signal::USR2).expect("send SIGUSR2");
wait_for(
"level=error",
"the way DOWN was not acknowledged: the line was written AFTER the reload, under a \
filter that cannot print a warn, so an operator turning trace off on a loaded server \
gets silence for a confirmation",
);
// And it really is back down: nothing at `info` reaches the log again.
let done_before = serving
.log()
.lines()
.filter(|line| line.contains("grpc.request.done"))
.count();
let ran = gunnar(&serving.home, &["control", "status", "--at", &serving.grpc]);
assert!(ran.ok, "control status: {}", ran.out);
std::thread::sleep(std::time::Duration::from_millis(300));
assert_eq!(
serving
.log()
.lines()
.filter(|line| line.contains("grpc.request.done"))
.count(),
done_before,
"the acknowledgement said `error` and the server is still logging at info:\n{}",
serving.log()
);
}
/// **A mirror that will create its repository reads back before its first sync**
/// (GUN-FIELD-TEST, 2026-10-31).
///
/// `control mirror add <ns>/<new> <url> --create` registers the mirror and leaves the
/// repository to the first sync (owner order 2026-10-18), and the CLI then reads the
/// registration back with `Mirror.Status`. Status asked for `read` on a repository that does
/// not exist yet, which nobody holds, so the field test's server admin got "principal alice
/// has no read grant on repo:field/mpull-http3" from an add that had worked, and `status`
/// refused until the sync had created it. `Mirror.Sync` already takes the namespace's grant
/// for a pending repository (`mirror_scope`); `Status` must too.
#[test]
fn a_mirror_that_creates_its_repository_reads_back_before_its_first_sync() {
let serving = Serving::fresh();
let keys = serving.home.join("keys");
std::fs::create_dir_all(&keys).expect("mkdir");
let (alice_key, alice_pub) = mint(&keys, "alice");
let alice = alice_key.to_str().unwrap().to_owned();
let at = serving.grpc.clone();
let ran = gunnar(
&serving.home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", alice_pub.display()),
"--at",
&at,
],
);
assert!(ran.ok, "bootstrap: {}", ran.out);
// A loopback upstream is this server's own network: allowed deliberately, as the
// refusal says to. The add dials nothing.
std::fs::write(
serving
._dir
.path()
.join("data")
.join("mirror_allow_private_destinations"),
"",
)
.expect("allow a loopback upstream");
let ran = gunnar(
&serving.home,
&[
"control",
"mirror",
"add",
"alice/later",
"http://127.0.0.1:9/up.git",
"--create",
"-i",
&alice,
"--at",
&at,
],
);
assert!(
ran.ok,
"the add registered the mirror, and reading it back was refused: {}",
ran.out
);
let ran = gunnar(
&serving.home,
&[
"control",
"mirror",
"status",
"alice/later",
"-i",
&alice,
"--at",
&at,
],
);
assert!(
ran.ok && ran.says("alice/later"),
"the namespace's owner cannot see the mirror they just registered: {}",
ran.out
);
}
/// **`policy gates` names every gate that refuses a push** (GUN-FIELD-TEST, 2026-10-31).
///
/// On a fresh repository the field test pushed a commit with a `Co-Authored-By: Claude`
/// trailer and the ai-trailers gate refused it, as it must (on by default) — while
/// `control policy gates` on the same repository answered "0 of 4 configured … Every gate is
/// off, so the pre-receive gate rejects NOTHING". `Policy.ListGates` listed a fixed v1 set
/// derived from the protection rules and never asked the policy the push path builds
/// (`PolicyConfig::gates`), so the default-on gate, the push-option gate and the
/// repository-wide size limits were invisible to it, and the console's settings read the same
/// answer.
#[test]
fn the_gate_list_names_the_default_on_ai_trailer_gate_on_a_fresh_repository() {
let serving = Serving::fresh();
let keys = serving.home.join("keys");
std::fs::create_dir_all(&keys).expect("mkdir");
let (alice_key, alice_pub) = mint(&keys, "alice");
let alice = alice_key.to_str().unwrap().to_owned();
let at = serving.grpc.clone();
let ran = gunnar(
&serving.home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", alice_pub.display()),
"--at",
&at,
],
);
assert!(ran.ok, "bootstrap: {}", ran.out);
let ran = gunnar(
&serving.home,
&[
"control",
"repo",
"create",
"alice/fresh",
"--hash",
"sha1",
"-i",
&alice,
"--at",
&at,
],
);
assert!(ran.ok, "repo create: {}", ran.out);
let ran = gunnar(
&serving.home,
&[
"control",
"policy",
"gates",
"alice/fresh",
"-i",
&alice,
"--at",
&at,
],
);
assert!(ran.ok, "listing gates: {}", ran.out);
assert!(
ran.says("ai-trailers") && ran.says("ai-trailers ON"),
"the ai-trailers gate refuses every push carrying an AI trailer on this repository, \
and the gate list does not say so: {}",
ran.out
);
assert!(
!ran.says("rejects NOTHING"),
"a repository whose push path refuses AI trailers is described as rejecting nothing: {}",
ran.out
);
}
/// **`gunnar control policy`, end to end against a live server — and the
/// write half refused to a user who may push.**
///
/// # Why this test exists
///
/// The server has served all six `Policy.*` RPCs since `gunnar_core::PolicyStore`
/// gave the policy document a home at `refs/gunnar/policy`. `gunnar control
/// --help` listed eight verbs and none of them reached it, and the console
/// rendered the same six as disabled panels off a coverage row that had gone
/// stale. So a merge required a verified signed approval naming the exact
/// commit, and a **direct push to the same branch** was refused by nothing —
/// not because the gate was missing, but because nothing could turn it on.
///
/// Everything here is the real binary against the real live server,
/// authenticated per call with the caller's own key.
///
/// # Both directions
///
/// * **green** — `protect` writes a rule, `list` reads it back, `gates` reports
/// `branch-protection` as ON *because* a rule exists, `set-ref-names` and
/// `ref-names` round-trip, and `unprotect` removes it;
/// * **red** — a bare branch name is refused LOCALLY before a connection is
/// made; `unprotect` of a rule that is not there is an error and not a
/// cheerful success; and bob, who has **write** on the namespace and can
/// therefore push, is refused `protect` over the wire while still being
/// allowed to `list`. That last one is the whole asymmetry: pushing and
/// deciding what may be pushed are different levels, and the console's
/// viewer must land on the same side of it.
///
/// Seen red (measure, never assert) by deleting the `refs/`-prefix guard in
/// `control::ref_pattern` — the local leg then reports the rule as stored and
/// the `gates` assertion fails, because a rule on `main` matches no ref and
/// enables nothing.
#[test]
fn the_policy_verbs_configure_the_gate_and_refuse_a_user_who_may_only_push() {
let serving = Serving::fresh();
let keys = serving.home.join("keys");
std::fs::create_dir_all(&keys).expect("mkdir");
let (alice_key, alice_pub) = mint(&keys, "alice");
let (bob_key, bob_pub) = mint(&keys, "bob");
let alice = alice_key.to_str().unwrap().to_owned();
let bob = bob_key.to_str().unwrap().to_owned();
let at = serving.grpc.clone();
let ran = gunnar(
&serving.home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", alice_pub.display()),
"--at",
&at,
],
);
assert!(ran.ok, "bootstrap: {}", ran.out);
let ran = gunnar(
&serving.home,
&[
"control",
"repo",
"create",
"alice/guarded",
"--hash",
"sha1",
"-i",
&alice,
"--at",
&at,
],
);
assert!(ran.ok, "creating the repository: {}", ran.out);
// ── red, and locally: a bare branch name is not a ref pattern ─────────
//
// Refused before a socket is opened. A rule on `main` matches no ref, so
// the server would accept it, `list` would print it and it would guard
// nothing — a protection that reads as present and is not, which is this
// whole feature's bug re-introduced one typo at a time.
let ran = gunnar(
&serving.home,
&[
"control",
"policy",
"protect",
"alice/guarded",
"main",
"-i",
&alice,
"--at",
&at,
],
);
assert!(
!ran.ok,
"a bare branch name was accepted as a pattern: {}",
ran.out
);
assert!(
ran.says("refs/heads/main"),
"the refusal must name the fix, not just the fault: {}",
ran.out
);
// ── the empty state, said as a word ──────────────────────────────────
let ran = gunnar(
&serving.home,
&[
"control",
"policy",
"list",
"alice/guarded",
"-i",
&alice,
"--at",
&at,
],
);
assert!(ran.ok, "listing an unconfigured repository: {}", ran.out);
assert!(
ran.says("NOTHING is protected here"),
"an unprotected repository and a failed read are the same empty list and very \
different facts: {}",
ran.out
);
// ── red: removing a rule that was never there ────────────────────────
let ran = gunnar(
&serving.home,
&[
"control",
"policy",
"unprotect",
"alice/guarded",
"refs/heads/main",
"-i",
&alice,
"--at",
&at,
],
);
assert!(
!ran.ok,
"removing a rule that does not exist reported success: {}",
ran.out
);
// ── green: the rule ──────────────────────────────────────────────────
let ran = gunnar(
&serving.home,
&[
"control",
"policy",
"protect",
"alice/guarded",
"refs/heads/main",
"--require-signature",
"-i",
&alice,
"--at",
&at,
],
);
assert!(ran.ok, "writing a protection rule: {}", ran.out);
assert!(
ran.says("force-push REFUSED") && ran.says("delete REFUSED"),
"the rule must print what it FORBIDS, not the proto's double negative: {}",
ran.out
);
let ran = gunnar(
&serving.home,
&[
"control",
"policy",
"list",
"alice/guarded",
"-i",
&alice,
"--at",
&at,
],
);
assert!(ran.ok, "listing: {}", ran.out);
assert!(
ran.says("refs/heads/main") && ran.says("1 rule(s)"),
"the rule written over the control plane did not read back: {}",
ran.out
);
// ── the gates, which is where a rule that guards nothing shows up ────
let ran = gunnar(
&serving.home,
&[
"control",
"policy",
"gates",
"alice/guarded",
"-i",
&alice,
"--at",
&at,
],
);
assert!(ran.ok, "listing gates: {}", ran.out);
assert!(
ran.says("branch-protection ON"),
"a rule exists and `branch-protection` is still reported off, so `ListGates` is not \
reading the same document `SetProtection` wrote: {}",
ran.out
);
assert!(
ran.says("signed-commits ON"),
"`--require-signature` was stored and the gate list does not reflect it: {}",
ran.out
);
// ── ref names, and the refusal that keeps a clear from being a typo ──
let ran = gunnar(
&serving.home,
&[
"control",
"policy",
"set-ref-names",
"alice/guarded",
"-i",
&alice,
"--at",
&at,
],
);
assert!(
!ran.ok,
"a set-ref-names with neither --allow nor --deny silently CLEARED the policy: {}",
ran.out
);
let ran = gunnar(
&serving.home,
&[
"control",
"policy",
"set-ref-names",
"alice/guarded",
"--allow",
"refs/heads/**",
"--deny",
"refs/heads/wip/**",
"-i",
&alice,
"--at",
&at,
],
);
assert!(ran.ok, "setting a ref-name policy: {}", ran.out);
let ran = gunnar(
&serving.home,
&[
"control",
"policy",
"ref-names",
"alice/guarded",
"-i",
&alice,
"--at",
&at,
],
);
assert!(ran.ok, "reading the ref-name policy: {}", ran.out);
assert!(
ran.says("DENY refs/heads/wip/**") && ran.says("deny wins"),
"the ref-name policy did not read back, or does not say which side wins: {}",
ran.out
);
// ── red, over the wire: a user who may PUSH may not decide what may ──
//
// bob gets `write` on the namespace, which is exactly the level that
// lets him push to `refs/heads/main`. Setting protection is `Admin` on
// gunnar's side ("turning the pre-receive gate off for a ref is an ACL
// act, not a push"), so he must be refused — while still being allowed to
// READ the rule, because a pusher who cannot see the gate that will reject
// him is worse than no gate.
let ran = gunnar(
&serving.home,
&[
"control",
"user",
"add",
"bob",
"--key",
&format!("@{}", bob_pub.display()),
"-i",
&alice,
"--at",
&at,
],
);
assert!(ran.ok, "creating bob: {}", ran.out);
let ran = gunnar(
&serving.home,
&[
"control",
"grant",
"bob",
"write",
"--namespace",
"alice",
"-i",
&alice,
"--at",
&at,
],
);
assert!(ran.ok, "granting bob write: {}", ran.out);
let ran = gunnar(
&serving.home,
&[
"control",
"policy",
"list",
"alice/guarded",
"-i",
&bob,
"--at",
&at,
],
);
assert!(
ran.ok && ran.says("refs/heads/main"),
"a user with write may not read the gate that will reject him: {}",
ran.out
);
let ran = gunnar(
&serving.home,
&[
"control",
"policy",
"protect",
"alice/guarded",
"refs/heads/main",
"--allow-force-push",
"-i",
&bob,
"--at",
&at,
],
);
assert!(
!ran.ok,
"bob has WRITE and was allowed to turn off the force-push guard on main, which \
means anyone who can push can also remove the reason they cannot: {}",
ran.out
);
let ran = gunnar(
&serving.home,
&[
"control",
"policy",
"unprotect",
"alice/guarded",
"refs/heads/main",
"-i",
&bob,
"--at",
&at,
],
);
assert!(
!ran.ok,
"bob has WRITE and was allowed to DELETE the protection rule: {}",
ran.out
);
// The rule survived both attempts. Asserting the refusals alone would pass
// against a server that refused the call and applied it anyway.
let ran = gunnar(
&serving.home,
&[
"control",
"policy",
"list",
"alice/guarded",
"-i",
&alice,
"--at",
&at,
],
);
assert!(
ran.says("force-push REFUSED"),
"the refusals were reported and the rule was widened anyway: {}",
ran.out
);
// ── green: the admin removes it ──────────────────────────────────────
let ran = gunnar(
&serving.home,
&[
"control",
"policy",
"unprotect",
"alice/guarded",
"refs/heads/main",
"-i",
&alice,
"--at",
&at,
],
);
assert!(ran.ok, "removing the rule as admin: {}", ran.out);
let ran = gunnar(
&serving.home,
&[
"control",
"policy",
"list",
"alice/guarded",
"-i",
&alice,
"--at",
&at,
],
);
assert!(ran.says("NOTHING is protected here"), "{}", ran.out);
drop(serving);
}