crates/gunnar-cli/tests/grants_admin_cli.rs
#![cfg(all(feature = "server", feature = "client", feature = "control"))]
//! **The three verbs an operator could not run until 2026-08-31: `grant
//! list`, `revoke`, and `health`/`stats`/`storage`.**
//!
//! # The finding
//!
//! MEASURED on the live box 2026-08-31: `gunnar control` could GIVE access
//! (`grant <principal> <level>`) and could never list or take it back — the
//! only revoke was `user remove`, which takes the whole human. And the three
//! `Admin` RPCs (`Health`, `Stats`, `StorageInfo`) were served with only the
//! console calling them, so reading a headless appliance's own
//! numbers meant toggling UpCloud VNC and screen-capturing a dashboard.
//!
//! All four RPCs — `Access.ListGrants`, `Access.Revoke`, and the `Admin`
//! three — existed server-side the whole time. This file is about the verbs,
//! and every assertion drives the real binary against a real `gunnar serve`.
//!
//! # How each green here was seen red
//!
//! Every load-bearing guard was tripped by a **product mutation** in
//! `control.rs` on 2026-08-31, each failing at exactly the intended line:
//!
//! * `grant list` lists **the real grants and not everything**: a fabricated
//! row on the never-granted repository tripped the ACL-not-catalog guard
//! ("printed a repository nobody was granted on"). `bob`'s rows are also
//! asserted exactly, in the server's own sort order — the first run of this
//! file went red on that ordering, which is how the order in the assertion
//! became the measured one rather than the assumed one.
//! * `revoke` is proven on the **applied output**: with the revoke mutated to
//! skip the RPC and pretend success (and the two row-level guards
//! temporarily disabled so the deepest guard was the one to fire), the SSH
//! door assertion tripped — "his key still reaches the repository over
//! SSH". A revoke that removes a row without removing the access is the
//! failure this file exists to catch, and no listing can see it.
//! * the two refusals are asserted to be **different sentences** ("no such
//! grant" versus "not allowed to do this"), each asserted positively AND as
//! the absence of the other.
//! * `stats` is cross-checked against ground truth this test created (2
//! principals, 1 repository) and against a second verb reading the same
//! table: `stats` mutated to print `grants + 1` tripped the
//! two-readers-one-table guard with "stats counts 2 grant(s) and `grant
//! list` prints 1 row(s)". (With the fabricated-row mutation active at the
//! same time the two cancelled and this guard stayed green — which is why
//! the mutations were run one at a time.)
//! * the transport red: `health` mutated to print a zeros page on a connect
//! failure tripped "`health` against a server with no control plane must
//! FAIL" — a verb that answers zeros there is the VNC dashboard problem
//! again, wearing a CLI.
//!
//! Row counts are taken from **stdout only** ([`served_binary::Ran::stdout`]):
//! `control.rs` announces `gunnar: signing as …` on stderr, and a count over
//! the merged streams counts the announcement too — the exact miscount that
//! broke `second_key.rs` on 2026-08-31.
use std::path::Path;
#[path = "common/served_binary.rs"]
mod served_binary;
use served_binary::{gunnar, mint, ssh_reaches, Ran, Serving};
/// The indented `<scope>\t<level>` rows under one principal's heading, from
/// stdout alone.
fn rows_of(listing: &Ran, principal: &str) -> Vec<String> {
let mut rows = Vec::new();
let mut inside = false;
for line in listing.stdout.lines() {
if line == principal {
inside = true;
} else if !line.starts_with(" ") {
inside = false;
} else if inside {
rows.push(line.trim().to_owned());
}
}
rows
}
/// `gunnar control grant list`, asserted to succeed.
fn grant_list(home: &Path, grpc: &str, ident: &Path, extra: &[&str]) -> Ran {
let mut args = vec!["control", "grant", "list"];
args.extend_from_slice(extra);
args.extend_from_slice(&["-i", ident.to_str().unwrap(), "--at", grpc]);
let ran = gunnar(home, &args);
assert!(ran.ok, "`grant list` must succeed: {}", ran.out);
ran
}
/// **`grant list` answers "what can `bob` see?" — with the real grants, not
/// with everything.**
#[test]
fn grant_list_shows_the_grants_that_were_given_and_no_others() {
let dir = tempfile::tempdir().expect("a temp dir");
let (data_root, home) = (dir.path().join("data"), dir.path().join("home"));
let keys = 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 serving = Serving::start(&data_root, &home, &dir.path().join("server.log"));
let ran = gunnar(
&home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", alice_pub.display()),
"--at",
&serving.grpc,
],
);
assert!(ran.ok, "bootstrap: {}", ran.out);
let rick = alice_key.to_str().unwrap();
for args in [
vec!["control", "repo", "create", "alice/proj"],
// A repository that EXISTS and is granted to nobody. This is the
// listing's red: an implementation that answers "what is on the
// server" instead of "what was granted" shows it, and the assertion
// below is what tells those apart.
vec!["control", "repo", "create", "alice/ungranted"],
vec![
"control",
"user",
"add",
"bob",
"--key",
&format!("@{}", bob_pub.display()),
],
vec!["control", "grant", "bob", "read", "--repo", "alice/proj"],
vec!["control", "grant", "bob", "read", "--namespace", "tools"],
] {
let mut full = args.clone();
full.extend_from_slice(&["-i", rick, "--at", &serving.grpc]);
let ran = gunnar(&home, &full);
assert!(ran.ok, "{args:?}: {}", ran.out);
}
// ── the listing, whole ────────────────────────────────────────────────
let listing = grant_list(&home, &serving.grpc, &alice_key, &[]);
let bob = rows_of(&listing, "bob");
// The order is the SERVER's — `AuthTable::grant` keeps a principal's
// grants sorted, namespace scopes before repo scopes — asserted exactly so
// a listing that re-sorts or interleaves shows up here.
assert_eq!(
bob,
vec!["namespace:tools\tread", "repo:alice/proj\tread"],
"bob must hold EXACTLY the two grants he was given — no more (a listing that shows \
everything), no fewer (a listing that shows nothing), and with the scope spelled so an \
operator can read it at a glance:\n{}",
listing.stdout
);
assert_eq!(
rows_of(&listing, "alice"),
vec!["server\tadmin"],
"the bootstrap admin's own server grant is a row like any other:\n{}",
listing.stdout
);
// The grant nobody gave. `alice/ungranted` is IN the catalog; only a
// listing that reads the grant table and not the catalog omits it.
assert!(
!listing.stdout.contains("ungranted"),
"`grant list` printed a repository nobody was granted on, so it is listing the server's \
contents rather than the ACL:\n{}",
listing.stdout
);
// ── filtered to one principal ─────────────────────────────────────────
let filtered = grant_list(&home, &serving.grpc, &alice_key, &["--principal", "bob"]);
assert_eq!(
rows_of(&filtered, "bob"),
bob,
"the filter must not change bob's rows:\n{}",
filtered.stdout
);
assert!(
!filtered.stdout.lines().any(|l| l == "alice"),
"--principal bob must not list alice:\n{}",
filtered.stdout
);
// ── and an ungranted principal is a sentence, not an empty page ───────
let none = grant_list(
&home,
&serving.grpc,
&alice_key,
&["--principal", "nobody-here"],
);
assert!(
none.stdout.contains("holds no grants"),
"no grants must be said out loud, or an operator cannot tell it from a broken \
listing:\n{}",
none.stdout
);
}
/// **`revoke` takes the access back — proven at the SSH door, not in the
/// listing** — and its two refusals are different sentences.
#[test]
fn revoke_takes_access_back_at_the_ssh_door() {
let dir = tempfile::tempdir().expect("a temp dir");
let (data_root, home) = (dir.path().join("data"), dir.path().join("home"));
let keys = 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 serving = Serving::start(&data_root, &home, &dir.path().join("server.log"));
let ran = gunnar(
&home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", alice_pub.display()),
"--at",
&serving.grpc,
],
);
assert!(ran.ok, "bootstrap: {}", ran.out);
let rick = alice_key.to_str().unwrap();
for args in [
vec!["control", "repo", "create", "alice/proj"],
vec![
"control",
"user",
"add",
"bob",
"--key",
&format!("@{}", bob_pub.display()),
],
] {
let mut full = args.clone();
full.extend_from_slice(&["-i", rick, "--at", &serving.grpc]);
let ran = gunnar(&home, &full);
assert!(ran.ok, "{args:?}: {}", ran.out);
}
// ── RED FIRST: without the grant, bob's key is refused over SSH ────
let ran = ssh_reaches(&home, &serving.ssh, &bob_key, "alice/proj");
assert!(
!ran.ok,
"bob reached alice/proj with NO grant, so the revoke assertion below could never \
fail: {}",
ran.out
);
// ── grant, and the applied green ──────────────────────────────────────
let ran = gunnar(
&home,
&[
"control",
"grant",
"bob",
"read",
"--repo",
"alice/proj",
"-i",
rick,
"--at",
&serving.grpc,
],
);
assert!(ran.ok, "grant: {}", ran.out);
let ran = ssh_reaches(&home, &serving.ssh, &bob_key, "alice/proj");
assert!(
ran.ok,
"with the read grant, bob must reach the repository over real SSH: {}",
ran.out
);
// ── the revoke ────────────────────────────────────────────────────────
let ran = gunnar(
&home,
&[
"control",
"revoke",
"bob",
"--repo",
"alice/proj",
"-i",
rick,
"--at",
&serving.grpc,
],
);
assert!(ran.ok, "revoke: {}", ran.out);
assert!(
ran.stdout.contains("revoked bob on repo:alice/proj"),
"{}",
ran.out
);
assert!(
ran.stdout.contains("bob now holds no grants"),
"the verb must read the remaining grants BACK, not assume them: {}",
ran.out
);
// ── the listing agrees: zero rows for bob ──────────────────────────
let listing = grant_list(&home, &serving.grpc, &alice_key, &[]);
assert!(
rows_of(&listing, "bob").is_empty(),
"after the revoke, `grant list` must show bob holding nothing:\n{}",
listing.stdout
);
// ── and the DOOR agrees, which is the half that matters (measure,
// never assert): a revoked grant that still clones is the failure this
// test exists to catch, and no listing can see it. ───────────────────
let ran = ssh_reaches(&home, &serving.ssh, &bob_key, "alice/proj");
assert!(
!ran.ok,
"bob's grant was revoked and his key still reaches the repository over SSH — the \
revoke removed a row without removing the access: {}",
ran.out
);
// ── refusal 1: no such grant (the scope is wrong, not the caller) ─────
let ran = gunnar(
&home,
&[
"control",
"revoke",
"bob",
"--repo",
"alice/proj",
"-i",
rick,
"--at",
&serving.grpc,
],
);
assert!(!ran.ok, "revoking the revoked grant must fail: {}", ran.out);
assert!(
ran.says("no such grant"),
"…and say the grant does not exist: {}",
ran.out
);
assert!(
!ran.says("not allowed to do this"),
"…never that the caller lacks permission, because alice is gunnar-admin and the two \
demand opposite reactions: {}",
ran.out
);
// ── refusal 2: not allowed (the grant exists, the caller may not) ─────
let ran = gunnar(
&home,
&[
"control",
"revoke",
"alice",
"--server",
"-i",
bob_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(
!ran.ok,
"an ordinary user must not revoke the admin's server grant: {}",
ran.out
);
assert!(
ran.says("not allowed to do this"),
"…and be told it is authorisation: {}",
ran.out
);
assert!(
!ran.says("no such grant"),
"…never that the grant is missing — it exists, and telling an unauthorised caller \
which grants exist would be the probe the gate refuses: {}",
ran.out
);
}
/// **`health`, `stats` and `storage` print the numbers the server holds** —
/// cross-checked against ground truth this test created and against a second
/// verb over the same table, and refused without a credential.
#[test]
fn health_stats_and_storage_answer_from_the_cli() {
let dir = tempfile::tempdir().expect("a temp dir");
let (data_root, home) = (dir.path().join("data"), dir.path().join("home"));
let keys = 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 serving = Serving::start(&data_root, &home, &dir.path().join("server.log"));
let ran = gunnar(
&home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", alice_pub.display()),
"--at",
&serving.grpc,
],
);
assert!(ran.ok, "bootstrap: {}", ran.out);
let rick = alice_key.to_str().unwrap();
for args in [
vec!["control", "repo", "create", "alice/proj"],
vec![
"control",
"user",
"add",
"bob",
"--key",
&format!("@{}", bob_pub.display()),
],
] {
let mut full = args.clone();
full.extend_from_slice(&["-i", rick, "--at", &serving.grpc]);
let ran = gunnar(&home, &full);
assert!(ran.ok, "{args:?}: {}", ran.out);
}
// ── health: the binds it prints are the binds this test dialed ────────
let health = gunnar(
&home,
&["control", "health", "-i", rick, "--at", &serving.grpc],
);
assert!(health.ok, "health: {}", health.out);
assert!(
health.stdout.contains("serving yes"),
"{}",
health.out
);
assert!(
health.stdout.contains(&serving.grpc),
"the gRPC bind `health` prints must be the address this test is talking to — the \
server's number, not a default: {}",
health.stdout
);
assert!(
health.stdout.contains(&serving.ssh),
"…and the SSH bind likewise: {}",
health.stdout
);
// ★ ACCT-2: the version line is the Gunnar RELEASE (`gunnar_common::RELEASE`, the gunnar
// crate's version), not gunnar-grpc's own semver.
let release = gunnar_common::RELEASE;
assert!(
health.stdout.contains(&format!("version {release}")),
"the version is the Gunnar release ({release}), because the server beside this test was \
built from this workspace: {}",
health.stdout
);
assert!(
health.stdout.contains("authentication required"),
"an ordinary server must report its gate on: {}",
health.stdout
);
// ── stats: exactly what this test created, not merely "some numbers" ──
let stats = gunnar(
&home,
&["control", "stats", "-i", rick, "--at", &serving.grpc],
);
assert!(stats.ok, "stats: {}", stats.out);
assert!(
stats.stdout.contains("principals 2"),
"this server holds alice and bob and nobody else: {}",
stats.stdout
);
assert!(
stats.stdout.contains("repos 1"),
"one repository was created: {}",
stats.stdout
);
// The grants counter must agree with the OTHER verb that reads the same
// table. Two readers, one table: `stats` says a number, `grant list`
// shows the rows, and a disagreement means one of them is lying.
let counted: u64 = stats
.stdout
.lines()
.find_map(|l| l.strip_prefix("grants"))
.expect("stats prints a grants line")
.trim()
.parse()
.expect("the grants line carries a number");
let listing = grant_list(&home, &serving.grpc, &alice_key, &[]);
let rows = listing
.stdout
.lines()
.filter(|l| l.starts_with(" "))
.count() as u64;
assert_eq!(
counted, rows,
"`stats` counts {counted} grant(s) and `grant list` prints {rows} row(s) — two verbs \
reading one table disagree:\nstats:\n{}\nlist:\n{}",
stats.stdout, listing.stdout
);
assert!(
counted > 0,
"the bootstrap admin's server grant alone makes this at least 1; 0 is the \
identity-value trap: {}",
stats.stdout
);
// ── storage: the repository row is there, behind a named backend ──────
let storage = gunnar(
&home,
&["control", "storage", "-i", rick, "--at", &serving.grpc],
);
assert!(storage.ok, "storage: {}", storage.out);
assert!(
storage.stdout.contains("alice/proj"),
"the created repository must appear as a row: {}",
storage.stdout
);
let backend = storage
.stdout
.lines()
.find_map(|l| l.strip_prefix("objects"))
.expect("storage prints an object-backend line")
.trim();
assert!(
!backend.is_empty(),
"the backend is NAMED — an empty name would be a page about nothing: {}",
storage.stdout
);
// ── usage: the meters, per namespace, and one namespace on request ─────
let usage = gunnar(
&home,
&["control", "usage", "-i", rick, "--at", &serving.grpc],
);
assert!(usage.ok, "usage: {}", usage.out);
let row = usage
.stdout
.lines()
.find(|l| l.starts_with("alice"))
.unwrap_or_else(|| {
panic!(
"no row for the namespace this test created:\n{}",
usage.stdout
)
});
assert!(
row.contains("repos 1"),
"the one repository this test created is alice's: {row}"
);
// A freshly created store already weighs something (the engine lays its
// index down at create), and the meter must say so: a zero here would be
// the identity-value trap the `stats` block above names.
let pack: u64 = row
.split_whitespace()
.skip_while(|w| *w != "pack")
.nth(1)
.and_then(|w| w.parse().ok())
.unwrap_or_else(|| panic!("the row carries `pack <bytes>`: {row}"));
assert!(
pack > 0,
"an empty repository still has a store on disk: {row}"
);
let one = gunnar(
&home,
&[
"control",
"usage",
"alice",
"-i",
rick,
"--at",
&serving.grpc,
],
);
assert!(one.ok, "usage alice: {}", one.out);
assert_eq!(
one.stdout
.lines()
.filter(|l| l.starts_with("alice"))
.count(),
1,
"asked for one namespace, print one row: {}",
one.stdout
);
let nobody = gunnar(
&home,
&[
"control",
"usage",
"nobody",
"-i",
rick,
"--at",
&serving.grpc,
],
);
assert!(
!nobody.ok,
"an unknown namespace is an error, not an empty bill: {}",
nobody.out
);
// ── capacity: "could you serve a cap of N bytes?", both arms, over the
// wire, against this box's real filesystem ────────────────────────────
//
// The YES and the NO come out of the SAME running server on the SAME
// disk; only the number asked for changes. That is what makes the no arm
// reachable rather than a branch nothing drives.
let yes = gunnar(
&home,
&[
"control",
"capacity",
"--bytes",
"1",
"-i",
rick,
"--at",
&serving.grpc,
],
);
assert!(yes.ok, "capacity: {}", yes.out);
assert!(
yes.stdout.contains("verdict YES"),
"one byte fits on any disk with room: {}",
yes.stdout
);
// 8 PiB. No disk on this machine, and none it could ever be given.
let no = gunnar(
&home,
&[
"control",
"capacity",
"--bytes",
"9007199254740992",
"-i",
rick,
"--at",
&serving.grpc,
],
);
assert!(
no.ok,
"a `no` is an ANSWER, not a failed command: {}",
no.out
);
assert!(
no.stdout.contains("verdict NO"),
"8 PiB must be refused, and the refusal must be reachable through the whole stack: {}",
no.stdout
);
assert!(
no.stdout.contains("short"),
"and must name what is MISSING, not only what is free: {}",
no.stdout
);
// **The growth answer is its own line, on both arms.** A caller that read
// only `verdict` would answer `NO` with "buy another disk"; on the
// appliance that disk is billed and invisible, so the fact travels beside
// the verdict and never instead of it.
for out in [&yes.stdout, &no.stdout] {
let growth = out
.lines()
.find_map(|l| l.strip_prefix("growth"))
.unwrap_or_else(|| panic!("every answer carries a growth line:\n{out}"))
.trim();
assert!(
!growth.is_empty() && !growth.starts_with('?'),
"the server must FILL the growth answer in, not leave it unspecified: {growth}"
);
assert!(
!growth.starts_with("SEALED"),
"gunnar is not PID 1 in this test, so nothing may report a sealed appliance: {growth}"
);
}
// **Two verbs, one table** — the idiom this file already uses for `stats`
// and `grant list`: what `capacity --namespace` reports as HELD must be
// the very byte count `usage` printed as `pack` above. A raise is charged
// the difference, so a `held` that disagreed with the meter would quote a
// tenant against bytes nobody is billing them for.
let held_out = gunnar(
&home,
&[
"control",
"capacity",
"--bytes",
"1",
"--namespace",
"alice",
"-i",
rick,
"--at",
&serving.grpc,
],
);
assert!(held_out.ok, "capacity --namespace: {}", held_out.out);
let held: u64 = held_out
.stdout
.lines()
.find(|l| l.starts_with("held"))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|w| w.parse().ok())
.unwrap_or_else(|| {
panic!(
"a named namespace prints a `held` row:\n{}",
held_out.stdout
)
});
assert_eq!(
held, pack,
"`capacity --namespace alice` says the namespace holds {held} B and `usage` says \
{pack} B — two verbs reading one meter disagree:\n{}\n{}",
held_out.stdout, usage.stdout
);
assert!(
held > 0,
"0 held would satisfy this comparison with any broken meter"
);
// ── the gate: an ordinary user's key is refused, a stranger's key is
// refused differently — Admin.* is gated exactly like the rest ──────
let ran = gunnar(
&home,
&[
"control",
"stats",
"-i",
bob_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(
!ran.ok,
"bob holds no server grant and Admin.Stats must refuse him: {}",
ran.out
);
assert!(ran.says("not allowed to do this"), "{}", ran.out);
// The same gate on the capacity quote: it names the box's free space and
// its disk floor, which is the whole filesystem's business and not a
// tenant's.
let ran = gunnar(
&home,
&[
"control",
"capacity",
"--bytes",
"1",
"-i",
bob_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(
!ran.ok,
"bob holds no server grant and Admin.Capacity must refuse him: {}",
ran.out
);
assert!(ran.says("not allowed to do this"), "{}", ran.out);
let (stranger_key, _) = mint(&keys, "stranger");
let ran = gunnar(
&home,
&[
"control",
"health",
"-i",
stranger_key.to_str().unwrap(),
"--at",
&serving.grpc,
],
);
assert!(!ran.ok, "an unregistered key must be refused: {}", ran.out);
assert!(ran.says("did not accept your key"), "{}", ran.out);
}
/// **The transport red (measure, never assert): `health` against a `--no-grpc`
/// server fails to connect and prints NO page.**
///
/// A verb that answered zeros with nothing listening would be a dashboard
/// drawing a healthy server out of its own defaults — the exact confusion
/// (`0 = never`, `0 = fine`) this whole verb family exists to end.
#[test]
fn health_against_a_no_grpc_server_fails_to_connect_and_prints_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let (data_root, home) = (dir.path().join("data"), dir.path().join("home"));
let keys = home.join("keys");
std::fs::create_dir_all(&keys).expect("mkdir");
let (alice_key, alice_pub) = mint(&keys, "alice");
// A port with NOTHING on it, reserved the honest way: bind :0, read the
// number back, close it. This is where the server's control plane would
// have been.
let dead = {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr").to_string();
drop(listener);
addr
};
// `--admin-key` because a `--no-grpc` server with an empty identity table
// is a box nobody could ever enrol on, and the server says so at boot.
let serving = Serving::start_with(
&data_root,
&home,
&dir.path().join("server.log"),
&[
"--no-grpc",
"--admin-key",
&format!("@{}", alice_pub.display()),
],
);
assert_eq!(
serving.grpc, "disabled",
"a --no-grpc server must announce `grpc disabled`, or this test is pointing its \
refusal at the wrong cause"
);
let ran = gunnar(
&home,
&[
"control",
"health",
"-i",
alice_key.to_str().unwrap(),
"--at",
&dead,
],
);
assert!(
!ran.ok,
"`health` against a server with no control plane must FAIL: {}",
ran.out
);
assert!(
ran.says("could not be reached"),
"…and say the transport is the problem: {}",
ran.out
);
assert!(
ran.stdout.trim().is_empty(),
"…and print NO page — a `serving`/`uptime` line made of zeros here would be a report \
about a server it never spoke to: {:?}",
ran.stdout
);
}