Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-cli/tests/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);
    }
}