crates/gunnar-cli/tests/control_tour_cli.rs
#![cfg(all(feature = "server", feature = "client", feature = "control"))]
//! **The operator's verbs, driven as the real binary against a real server.**
//!
//! `control_cli.rs` walks the enrolment story; this file walks what an
//! operator does after it: clans, standbys, webhooks, the soft-delete window,
//! the account lockout and purge, employees, and the read-only reports. Each
//! test runs `gunnar control …` as a process and asserts on what it PRINTED
//! and how it exited, because that is what a person acts on. Every allowed
//! verb sits beside a refusal, and a refusal must exit non-zero with the
//! server's (or the parser's) reason in it.
use std::path::PathBuf;
#[path = "common/served_binary.rs"]
mod served_binary;
use served_binary::{gunnar, mint, Ran, Serving};
/// A fresh server with alice (gunnar-admin, by bootstrap) and two ordinary
/// users, bob and carol, each with their own key.
struct Estate {
_dir: tempfile::TempDir,
home: PathBuf,
keys: PathBuf,
serving: Serving,
}
impl Estate {
fn new() -> Estate {
let dir = tempfile::tempdir().expect("a temp dir");
let home = dir.path().join("home");
let keys = home.join("keys");
std::fs::create_dir_all(&keys).expect("mkdir");
let pubs: Vec<PathBuf> = ["alice", "bob", "carol"]
.iter()
.map(|n| mint(&keys, n).1)
.collect();
let serving = Serving::start(
&dir.path().join("data"),
&home,
&dir.path().join("server.log"),
);
let boot = gunnar(
&home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", pubs[0].display()),
"--at",
&serving.grpc,
],
);
assert!(boot.ok, "bootstrap: {}", boot.out);
let estate = Estate {
_dir: dir,
home,
keys,
serving,
};
for (name, public) in [("bob", &pubs[1]), ("carol", &pubs[2])] {
let key = format!("@{}", public.display());
estate.ok("alice", &["user", "add", name, "--key", &key]);
}
estate
}
fn key(&self, who: &str) -> String {
self.keys.join(who).display().to_string()
}
/// `gunnar control <args> -i <who> --at <grpc>`.
fn run(&self, who: &str, args: &[&str]) -> Ran {
let key = self.key(who);
let mut full = vec!["control"];
full.extend_from_slice(args);
full.extend_from_slice(&["-i", &key, "--at", &self.serving.grpc]);
gunnar(&self.home, &full)
}
/// [`Estate::run`], and it must succeed.
fn ok(&self, who: &str, args: &[&str]) -> Ran {
let ran = self.run(who, args);
assert!(ran.ok, "`{}` as {who} failed:\n{}", args.join(" "), ran.out);
ran
}
/// [`Estate::run`], and it must be refused, saying `why`.
fn refused(&self, who: &str, args: &[&str], why: &str) -> Ran {
let ran = self.run(who, args);
assert!(
!ran.ok,
"`{}` as {who} was let through:\n{}",
args.join(" "),
ran.out
);
assert!(
ran.says(why),
"`{}` as {who}: the refusal does not say {why:?}:\n{}",
args.join(" "),
ran.out
);
ran
}
}
fn line_with<'a>(ran: &'a Ran, needle: &str) -> &'a str {
ran.stdout
.lines()
.find(|l| l.contains(needle))
.unwrap_or_else(|| panic!("no line with {needle:?} in:\n{}", ran.stdout))
}
/// A clan from its making to its end: invite, accept, promote and demote,
/// a repository offered out of it and taken, a member removed, an invitation
/// withdrawn, and the empty clan dissolved. Beside each: a non-admin is
/// refused, and a size the CLI cannot read is refused before anything dials.
#[test]
fn a_clan_is_made_joined_offered_out_of_and_dissolved_from_the_cli() {
let e = Estate::new();
let made = e.ok("alice", &["clan", "create", "fellows", "--give", "0"]);
assert!(
made.stdout.contains("clan fellows you: admin room 0 B"),
"{}",
made.stdout
);
assert!(
made.stdout.contains("alice (admin)\tgives 0 B"),
"{}",
made.stdout
);
// the name is one claim: a second clan of that name is refused
e.refused(
"bob",
&["clan", "create", "fellows", "--give", "1MiB"],
"already exists",
);
// a size in a unit gunnar does not print is refused locally, by name
e.refused(
"bob",
&["clan", "create", "others", "--give", "5GB"],
"is not a unit gunnar reads",
);
e.refused(
"bob",
&["clan", "give", "fellows", "ten"],
"\"ten\" is not a size",
);
let invited = e.ok("alice", &["clan", "invite", "fellows", "bob"]);
assert!(
invited.stdout.contains("bob\tinvited by alice"),
"{}",
invited.stdout
);
let listed = e.ok("bob", &["clan", "show"]);
assert!(
listed.stdout.contains("fellows\tinvited\troom 0 B"),
"{}",
listed.stdout
);
let joined = e.ok("bob", &["clan", "accept", "fellows", "--give", "0"]);
assert!(joined.stdout.contains("you: member"), "{}", joined.stdout);
// a plain member does not administer the clan
e.refused("bob", &["clan", "invite", "fellows", "carol"], "gunnar");
// and an outsider does not see it at all
e.refused("carol", &["clan", "show", "fellows"], "not found");
let nobody = e.ok("carol", &["clan", "show"]);
assert!(
nobody
.stdout
.contains("you are in no clan, and invited to none"),
"{}",
nobody.stdout
);
let promoted = e.ok("alice", &["clan", "admin", "fellows", "bob"]);
assert!(
promoted.stdout.contains("bob (admin)"),
"{}",
promoted.stdout
);
let demoted = e.ok("alice", &["clan", "admin", "fellows", "bob", "--no"]);
assert!(
!demoted.stdout.contains("bob (admin)"),
"{}",
demoted.stdout
);
e.ok("alice", &["clan", "give", "fellows", "0"]);
// a repository made in the clan, offered to carol's account, taken by her
e.ok("alice", &["repo", "create", "fellows/tool"]);
let offered = e.ok("alice", &["clan", "offer", "fellows", "tool", "carol"]);
assert!(
offered
.stdout
.contains("fellows/tool\toffered to carol by alice"),
"{}",
offered.stdout
);
let seen = e.ok("carol", &["clan", "show"]);
assert!(
seen.stdout
.contains("offered to you: fellows/tool (by alice)"),
"{}",
seen.stdout
);
assert!(
seen.stdout
.contains("gunnar control clan take fellows tool"),
"{}",
seen.stdout
);
let taken = e.ok("carol", &["clan", "take", "fellows", "tool"]);
assert!(
taken.stdout.contains("fellows/tool moved to carol/tool"),
"{}",
taken.stdout
);
e.refused("carol", &["clan", "take", "fellows", "tool"], "gunnar");
let removed = e.ok("alice", &["clan", "remove", "fellows", "bob"]);
assert!(
!removed.stdout.contains("bob\t"),
"bob is no member any more: {}",
removed.stdout
);
e.refused("bob", &["clan", "leave", "fellows"], "not found");
e.ok("alice", &["clan", "invite", "fellows", "carol"]);
let withdrawn = e.ok(
"alice",
&["clan", "decline", "fellows", "--account", "carol"],
);
assert!(
withdrawn
.stdout
.contains("carol's invitation to fellows is withdrawn"),
"{}",
withdrawn.stdout
);
e.ok("alice", &["clan", "invite", "fellows", "bob"]);
let declined = e.ok("bob", &["clan", "decline", "fellows"]);
assert!(
declined
.stdout
.contains("you declined fellows's invitation"),
"{}",
declined.stdout
);
let gone = e.ok("alice", &["clan", "dissolve", "fellows"]);
assert!(
gone.stdout.contains("clan fellows is dissolved"),
"{}",
gone.stdout
);
e.refused("alice", &["clan", "show", "fellows"], "not found");
}
/// A standby added, listed (with and without the ref coverage), resynced and
/// removed; the flush says NOT caught up and exits non-zero, because a growth
/// reads that exit; demote, standby and promote each say which role the box
/// is in afterwards. Removing a standby that is not there is refused by name.
#[test]
fn a_standby_is_added_resynced_flushed_and_removed_and_the_role_flips_are_reported() {
let e = Estate::new();
let none = e.ok("alice", &["replication", "list"]);
assert!(
none.stdout.contains("replication no standby configured"),
"{}",
none.stdout
);
let added = e.ok(
"alice",
&[
"replication",
"add",
"twin1",
"ssh://git@127.0.0.1:1/",
"--repo",
"alice/x",
],
);
assert!(
added
.stdout
.contains("standby twin1 -> ssh://git@127.0.0.1:1/"),
"{}",
added.stdout
);
assert!(
added.stdout.contains("AUTHORISE THIS LINE ON THE STANDBY"),
"{}",
added.stdout
);
assert!(
line_with(&added, "ssh-ed25519 ")
.trim()
.starts_with("ssh-ed25519 AAAA"),
"{}",
added.stdout
);
let listed = e.ok("alice", &["replication", "list"]);
assert!(
listed
.stdout
.contains("role primary (takes client writes)"),
"{}",
listed.stdout
);
assert!(
listed
.stdout
.contains("twin1 ssh://git@127.0.0.1:1/ [repo alice/x]"),
"{}",
listed.stdout
);
assert!(
listed.stdout.contains("last success NEVER"),
"{}",
listed.stdout
);
assert!(
listed.stdout.contains("behind `--refs`"),
"the plain list says where the ref coverage is: {}",
listed.stdout
);
let refs = e.ok("alice", &["replication", "list", "--refs"]);
assert!(!refs.stdout.contains("behind `--refs`"), "{}", refs.stdout);
let resync = e.ok("alice", &["replication", "resync", "twin1"]);
assert!(
resync.stdout.contains("resync twin1 in scope 0 queued 0"),
"{}",
resync.stdout
);
assert!(
resync.stdout.contains("Nothing is copied yet"),
"{}",
resync.stdout
);
let flushed = e.refused(
"alice",
&["replication", "flush", "--timeout-secs", "1"],
"NOT proven caught up",
);
assert!(
flushed.stdout.contains("caught up NO"),
"{}",
flushed.stdout
);
assert!(
flushed.stdout.contains("standby twin1"),
"{}",
flushed.stdout
);
// an ordinary user neither reads nor changes where the copies go
e.refused("bob", &["replication", "list"], "not allowed");
e.refused("bob", &["replication", "remove", "twin1"], "not allowed");
let removed = e.ok("alice", &["replication", "remove", "twin1"]);
assert!(
removed.stdout.contains("standby twin1 removed"),
"{}",
removed.stdout
);
e.refused(
"alice",
&["replication", "remove", "nope"],
"no replication target named \"nope\"",
);
let demoted = e.ok("alice", &["replication", "demote"]);
assert!(
demoted
.stdout
.contains("demoted — 0 standby target(s) cleared"),
"{}",
demoted.stdout
);
assert!(
demoted.stdout.contains("It STILL TAKES CLIENT WRITES"),
"{}",
demoted.stdout
);
let frozen = e.ok("alice", &["replication", "standby"]);
assert!(
frozen
.stdout
.contains("role standby (read-only to clients)"),
"{}",
frozen.stdout
);
let promoted = e.ok("alice", &["replication", "promote"]);
assert!(
promoted
.stdout
.contains("promoted — this box is now a writable primary"),
"{}",
promoted.stdout
);
assert!(
promoted
.stdout
.contains("role primary (takes client writes)"),
"{}",
promoted.stdout
);
}
/// An endpoint added with a filter and a secret, listed with its counters and
/// the server's signing key, and removed; an unknown event kind, a scheme that
/// is not http(s) and removing a name that is not there are each refused.
#[test]
fn a_webhook_is_added_listed_and_removed_and_bad_endpoints_are_refused() {
let e = Estate::new();
let empty = e.ok("alice", &["webhook", "list"]);
assert!(
empty
.stdout
.contains("webhook no endpoint configured"),
"{}",
empty.stdout
);
assert!(
empty.stdout.contains("ssh-keygen -Y verify"),
"{}",
empty.stdout
);
e.refused(
"alice",
&[
"webhook",
"add",
"hook1",
"http://127.0.0.1:9/hook",
"--kinds",
"push",
],
"unknown event kind \"push\"",
);
e.refused(
"alice",
&["webhook", "add", "bad", "ftp://x/"],
"is not http or https",
);
let added = e.ok(
"alice",
&[
"webhook",
"add",
"hook1",
"http://127.0.0.1:9/hook",
"--kinds",
"ref.updated",
"--kinds",
"repo.created",
"--secret",
"not-for-printing",
"--namespace",
"alice",
],
);
assert!(
added
.stdout
.contains("webhook hook1 -> http://127.0.0.1:9/hook"),
"{}",
added.stdout
);
let listed = e.ok("alice", &["webhook", "list"]);
let row = line_with(&listed, "ref.updated");
assert!(
row.contains("repo.created") && row.contains("namespace:alice") && row.contains("+hmac"),
"{row}"
);
assert!(row.contains("delivered 0 failed 0 dropped 0"), "{row}");
assert!(
!listed.stdout.contains("not-for-printing"),
"the secret is never printed back: {}",
listed.stdout
);
e.refused("bob", &["webhook", "remove", "hook1"], "not allowed");
let removed = e.ok("alice", &["webhook", "remove", "hook1"]);
assert!(
removed.stdout.contains("webhook hook1 removed"),
"{}",
removed.stdout
);
e.refused(
"alice",
&["webhook", "remove", "hook1"],
"no webhook endpoint named \"hook1\"",
);
}
/// The soft-delete window from the CLI: a deleted repository is listed with
/// what it still costs, put back, and its visibility and default branch
/// changed; gc of an empty repository is refused rather than deleting
/// everything; its bytes come off the box with archive-out.
#[test]
fn a_repository_is_deleted_listed_undeleted_archived_and_reshaped() {
let e = Estate::new();
e.ok("alice", &["repo", "create", "alice/proj"]);
let public = e.ok("alice", &["repo", "set-visibility", "alice/proj", "public"]);
assert!(
public.stdout.contains("access public (anonymous READ)"),
"{}",
public.stdout
);
assert!(
public.stdout.contains("Nothing became writable"),
"{}",
public.stdout
);
e.refused(
"alice",
&["repo", "set-visibility", "alice/proj", "nonsense"],
"possible values: private, public",
);
e.refused(
"bob",
&["repo", "set-visibility", "alice/proj", "private"],
"gunnar",
);
let branch = e.ok(
"alice",
&["repo", "set-default-branch", "alice/proj", "trunk"],
);
assert!(
branch.stdout.contains("branch refs/heads/trunk"),
"{}",
branch.stdout
);
let gc = e.ok("alice", &["repo", "gc-status", "alice/proj"]);
assert!(
gc.stdout.contains("gc alice/proj no job"),
"{}",
gc.stdout
);
e.refused(
"alice",
&["repo", "gc", "alice/proj"],
"A GC that would delete everything is refused",
);
let archive = e.home.join("proj.archive");
let out = e.ok(
"alice",
&[
"repo",
"archive-out",
"alice/proj",
"--to",
archive.to_str().unwrap(),
],
);
assert!(
out.stdout.contains("archived alice/proj ->"),
"{}",
out.stdout
);
assert!(
out.stdout.contains("next incremental: --from-generation 0"),
"{}",
out.stdout
);
assert!(
std::fs::metadata(&archive)
.expect("the archive is written")
.len()
> 0
);
// Bob may not take the bytes of a repository he cannot read. The archive
// goes to a temp path, never a host path: archive-out writes `<to>.part`
// and renames it onto `<to>`, so `--to /dev/null` run as root replaced the
// host's /dev/null, and as anyone else it was refused for the wrong reason.
// The repository was made public above, and anonymous READ covers an
// archive; the refusal only proves something on a private repository.
let private = e.ok(
"alice",
&["repo", "set-visibility", "alice/proj", "private"],
);
assert!(private.stdout.contains("private"), "{}", private.stdout);
let bobs = e.home.join("bobs-copy-of-proj.archive");
e.refused(
"bob",
&[
"repo",
"archive-out",
"alice/proj",
"--to",
bobs.to_str().unwrap(),
],
"not allowed",
);
assert!(
!bobs.exists(),
"a refused archive-out left {}",
bobs.display()
);
assert!(
!e.home.join("bobs-copy-of-proj.archive.part").exists(),
"a refused archive-out left its .part file"
);
let orphans = e.ok("alice", &["repo", "list-orphans"]);
assert!(
orphans.stdout.contains("no orphan stores"),
"{}",
orphans.stdout
);
let deleted = e.ok("alice", &["repo", "delete", "alice/proj", "--yes"]);
assert!(
deleted.stdout.contains("deleted alice/proj — its"),
"{}",
deleted.stdout
);
assert!(
deleted
.stdout
.contains("undelete: gunnar control repo undelete alice/proj"),
"{}",
deleted.stdout
);
for args in [
vec!["repo", "list-deleted"],
vec!["repo", "list-deleted", "--namespace", "alice"],
] {
let listed = e.ok("alice", &args);
assert!(listed.stdout.contains("alice/proj\t"), "{}", listed.stdout);
assert!(
listed.stdout.contains("1 deleted repository(ies) holding"),
"{}",
listed.stdout
);
}
let back = e.ok("alice", &["repo", "undelete", "alice/proj"]);
assert!(
back.stdout.contains("undeleted alice/proj"),
"{}",
back.stdout
);
e.refused("alice", &["repo", "undelete", "alice/proj"], "gunnar");
}
/// An account locked out and let back in, as the lockout list and the
/// account itself see it; an ordinary user cannot lock anyone; a purge empties
/// an account and reports the lockout released; an ordinary account enrolls
/// an employee and lists it, and a dry run says whether seats fit.
#[test]
fn an_account_is_locked_out_let_back_in_purged_and_given_employees() {
let e = Estate::new();
let none = e.ok("alice", &["account", "gates"]);
assert!(
none.stdout.contains("no account is locked out"),
"{}",
none.stdout
);
e.refused(
"bob",
&["account", "gate", "carol", "--note", "spite"],
"gunnar",
);
let locked = e.ok("alice", &["account", "gate", "bob", "--note", "unpaid"]);
assert!(
locked.stdout.contains("LOCKED bob (administrative"),
"{}",
locked.stdout
);
assert!(locked.stdout.contains("unpaid"), "{}", locked.stdout);
let gates = e.ok("alice", &["account", "gates"]);
assert!(gates.stdout.contains("LOCKED bob"), "{}", gates.stdout);
let open = e.ok("alice", &["account", "ungate", "bob"]);
assert!(
open.stdout
.contains("OPEN bob (was an administrative lockout"),
"{}",
open.stdout
);
let status = e.ok("alice", &["account", "purge-status", "bob"]);
assert!(
status.stdout.contains("purge bob no job"),
"{}",
status.stdout
);
let me = e.ok("bob", &["whoami"]);
assert!(me.stdout.contains("server level none"), "{}", me.stdout);
assert!(
me.stdout.contains("account bob (individual)"),
"{}",
me.stdout
);
let no_staff = e.ok("alice", &["account", "employees", "bob"]);
assert!(
no_staff
.stdout
.contains("bob: no employees (not an enterprise account)"),
"{}",
no_staff.stdout
);
let enrolled = e.ok(
"carol",
&["account", "enroll", "carol", "eve", "--name", "Eve"],
);
assert!(
enrolled
.stdout
.contains("enrolled eve (Eve) as an employee of carol"),
"{}",
enrolled.stdout
);
let staff = e.ok("carol", &["account", "employees", "carol"]);
assert!(staff.stdout.contains("eve\tEve"), "{}", staff.stdout);
let fits = e.ok(
"alice",
&["account", "enroll", "carol", "--count", "2", "--dry-run"],
);
assert!(
fits.stdout.contains("fits:") && fits.stdout.contains("of carol's room asked"),
"{}",
fits.stdout
);
e.refused(
"alice",
&["account", "enroll", "nobody", "--name", "x"],
"account nobody not found",
);
let purged = e.ok(
"alice",
&["account", "purge", "bob", "--reason", "test", "--yes"],
);
assert!(
purged
.stdout
.contains("this account is being permanently deleted: test"),
"{}",
purged.stdout
);
assert!(
purged.stdout.contains("purge bob done"),
"{}",
purged.stdout
);
assert!(
purged.stdout.contains("lockout released"),
"{}",
purged.stdout
);
let after = e.ok("alice", &["account", "purge-status", "bob"]);
assert!(after.stdout.contains("OPEN bob"), "{}", after.stdout);
}
/// The read-only reports an operator reads: each answers the admin with its
/// own rows, and the server-wide ones refuse an ordinary user.
#[test]
fn the_reports_answer_the_admin_and_refuse_an_ordinary_user() {
let e = Estate::new();
let health = e.ok("alice", &["health"]);
for row in [
"serving yes",
"grpc bind ",
"ssh bind ",
"bound creds REQUIRED",
] {
assert!(
health.stdout.contains(row),
"health lacks {row:?}:\n{}",
health.stdout
);
}
let stats = e.ok("alice", &["stats"]);
assert!(
line_with(&stats, "principals").ends_with('3'),
"{}",
stats.stdout
);
assert!(
stats.stdout.contains("replication not configured"),
"{}",
stats.stdout
);
e.refused(
"bob",
&["stats"],
"principal bob has no read grant on server",
);
let topology = e.ok("alice", &["topology"]);
assert!(
topology.stdout.contains("estate (not paired)"),
"{}",
topology.stdout
);
assert!(
line_with(&topology, "* this box").contains("primary"),
"{}",
topology.stdout
);
let storage = e.ok("alice", &["storage"]);
assert!(
storage.stdout.contains("── at a glance"),
"{}",
storage.stdout
);
assert!(
storage.stdout.contains("config ABSENT"),
"{}",
storage.stdout
);
let usage = e.ok("alice", &["usage"]);
for ns in ["alice\trepos 0", "bob\trepos 0", "carol\trepos 0"] {
assert!(
usage.stdout.contains(ns),
"usage lacks {ns:?}:\n{}",
usage.stdout
);
}
let activity = e.ok("alice", &["activity", "alice"]);
assert!(
activity
.stdout
.contains("alice\tnothing moved in the last 30 day(s)"),
"{}",
activity.stdout
);
let admit = e.ok("alice", &["admit", "show", "alice"]);
for kind in ["clone\t", "fetch\t", "push\t", "request\t"] {
assert!(
admit.stdout.contains(kind),
"admit show lacks {kind:?}:\n{}",
admit.stdout
);
}
let audit = e.ok("alice", &["audit"]);
assert!(audit.stdout.contains("verifies: yes"), "{}", audit.stdout);
let capacity = e.ok("alice", &["capacity", "--bytes", "1000000"]);
assert!(
capacity.stdout.contains("verdict YES"),
"{}",
capacity.stdout
);
let twin = e.ok("alice", &["twin", "status"]);
assert!(twin.stdout.contains("twin no"), "{}", twin.stdout);
let users = e.ok("alice", &["user", "list"]);
for u in ["alice\talice\t", "bob\tbob\t", "carol\tcarol\t"] {
assert!(
users.stdout.contains(u),
"user list lacks {u:?}:\n{}",
users.stdout
);
}
e.ok("alice", &["group", "add", "team"]);
let members = e.ok("alice", &["group", "members", "team"]);
assert!(
members.stdout.contains("group team has no members"),
"{}",
members.stdout
);
let history = e.ok("alice", &["group", "history", "team"]);
assert!(
history
.stdout
.contains("no signed membership changes recorded for team"),
"{}",
history.stdout
);
let settings = e.ok("alice", &["namespace", "settings", "alice"]);
assert!(
settings
.stdout
.contains("alice: namespace private, events private"),
"{}",
settings.stdout
);
}
/// No server at the address: every family of verbs fails with the same
/// "could not reach" class of sentence and a non-zero exit, never a panic.
#[test]
fn a_verb_against_no_server_fails_cleanly() {
let dir = tempfile::tempdir().expect("a temp dir");
let home = dir.path().join("home");
std::fs::create_dir_all(&home).expect("mkdir");
let (key, _) = mint(&home, "alice");
let key = key.display().to_string();
// a port nothing listens on: bind one, read it, drop it
let addr = std::net::TcpListener::bind("127.0.0.1:0")
.expect("bind")
.local_addr()
.expect("addr");
let at = addr.to_string();
for verb in [
vec!["clan", "show"],
vec!["webhook", "list"],
vec!["replication", "list"],
vec!["account", "gates"],
vec!["stats"],
] {
let mut args = vec!["control"];
args.extend_from_slice(&verb);
args.extend_from_slice(&["-i", &key, "--at", &at]);
let ran = gunnar(&home, &args);
assert!(!ran.ok, "{verb:?} against nothing succeeded:\n{}", ran.out);
assert!(!ran.says("panicked"), "{verb:?} panicked:\n{}", ran.out);
}
}