a73x

tests/server_setup_test.rs

Ref:   Size: 33.6 KiB   History

//! `git-collab-server setup`: the bootstrap that creates `settings.git`.
//!
//! Governance and the exposure model both shipped with no way to create the
//! repository that turns them on, so governance was present and inert. This
//! is the missing half, and the whole difficulty is in one property:
//!
//! **Creating `settings.git` is what puts the inverted default in force**, so
//! a seed that did not reproduce the server's current effective policy would
//! make every repository invisible the moment it ran. The central test here
//! therefore captures what a live server exposes, runs `setup` against it,
//! and asserts the exposure is byte-for-byte the same decision afterwards.
//! Everything else in this file exists to protect that claim.

mod common;

use common::ServerHarness;
use std::path::Path;
use std::process::{Command, Output};

// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------

fn git_in(dir: &Path, args: &[&str]) -> String {
    let output = Command::new("git")
        .args(args)
        .current_dir(dir)
        .output()
        .expect("failed to run git");
    assert!(
        output.status.success(),
        "git {:?} in {:?} failed: {}",
        args,
        dir,
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8(output.stdout).unwrap()
}

fn stdout(output: &Output) -> String {
    String::from_utf8_lossy(&output.stdout).to_string()
}

fn stderr(output: &Output) -> String {
    String::from_utf8_lossy(&output.stderr).to_string()
}

/// Run `git-collab-server setup` with the given extra arguments.
fn setup(config: &Path, args: &[&str]) -> Output {
    let mut command = Command::new(env!("CARGO_BIN_EXE_git-collab-server"));
    command.args(["setup", "--config", config.to_str().unwrap()]);
    command.args(args);
    command.output().expect("failed to run git-collab-server")
}

fn assert_ok(output: &Output, context: &str) {
    assert!(
        output.status.success(),
        "{context}: expected success, got {:?}\nstdout: {}\nstderr: {}",
        output.status.code(),
        stdout(output),
        stderr(output)
    );
}

fn assert_failed(output: &Output, context: &str) {
    assert!(
        !output.status.success(),
        "{context}: expected a refusal, but it succeeded\nstdout: {}",
        stdout(output)
    );
}

/// Create a bare repository under the server's repos dir, with the given
/// `server.toml` body (empty for none at all).
fn make_repo(repos_dir: &Path, name: &str, server_toml: &str) {
    let path = repos_dir.join(format!("{name}.git"));
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    git_in(repos_dir, &["init", "-q", "--bare", path.to_str().unwrap()]);
    if !server_toml.is_empty() {
        let collab = path.join(".collab");
        std::fs::create_dir_all(&collab).unwrap();
        std::fs::write(collab.join("server.toml"), server_toml).unwrap();
    }
}

/// What an unauthenticated request may see of a repository, asked of the real
/// server rather than of the code that decides it.
#[derive(Debug, PartialEq, Eq)]
struct Exposed {
    listed: bool,
    readable: bool,
    clonable: bool,
}

fn exposure_of(harness: &ServerHarness, repo: &str) -> Exposed {
    Exposed {
        listed: harness.get_ok("/").body.contains(repo),
        readable: harness.get(&format!("/{repo}")).status_line.contains("200"),
        clonable: harness
            .get(&format!("/{repo}.git/info/refs?service=git-upload-pack"))
            .status_line
            .contains("200"),
    }
}

/// The repositories the exposure test stands up, and the policy each carries.
///
/// Deliberately mixed, and deliberately reaching the same closed state by two
/// different routes (`visibility` and the two `anonymous` switches), because
/// the seed reads the *effective* policy rather than any one key.
const REPOS: &[(&str, &str)] = &[
    // No server.toml at all: the default, and public.
    ("alpha", ""),
    ("bravo-private", "visibility = \"private\"\n"),
    (
        "charlie-quiet",
        "visibility = \"public\"\n[ui]\nanonymous = false\n[http]\nanonymous_clone = false\n",
    ),
    // Public to anonymous readers, but its authenticated access is an
    // explicit roster of fingerprints rather than the default `*`.
    (
        "delta-restricted",
        "visibility = \"public\"\n[access]\nread = [\"key:SHA256:somebody\"]\nwrite = [\"key:SHA256:somebody\"]\n",
    ),
    // Nested, because a repo key keeps its `/` and the rule has to match it.
    ("nested/echo", ""),
];

fn names() -> Vec<&'static str> {
    let mut names: Vec<&'static str> = REPOS.iter().map(|(name, _)| *name).collect();
    names.push("hosted");
    names
}

/// Stand up a server with the mixed repository set above.
fn mixed_server() -> ServerHarness {
    let harness = ServerHarness::new("hosted");
    let repos_dir = harness.repos_dir();
    for (name, server_toml) in REPOS {
        make_repo(&repos_dir, name, server_toml);
    }
    harness
}

/// An ed25519 public key file for the admin, under the harness root.
fn admin_key(harness: &ServerHarness, name: &str) -> std::path::PathBuf {
    harness.named_key(name).with_extension("pub")
}

// ---------------------------------------------------------------------------
// The test that matters
// ---------------------------------------------------------------------------

/// Enabling governance is behaviourally a no-op.
///
/// This is the entire safety claim, and the reason `setup` may be run on a
/// live server at all: the seed reproduces the effective policy, so the same
/// repositories are listed, the same ones are readable and clonable by an
/// anonymous request, and the same ones are refused — before and after.
#[test]
fn setup_leaves_the_anonymous_exposure_of_every_repository_unchanged() {
    let harness = mixed_server();
    harness.push_head();

    let before: Vec<(&str, Exposed)> = names()
        .into_iter()
        .map(|name| (name, exposure_of(&harness, name)))
        .collect();

    // Sanity: the fixture is only worth anything if it is actually mixed.
    assert!(
        before.iter().any(|(_, e)| e.readable) && before.iter().any(|(_, e)| !e.readable),
        "the fixture must contain both exposed and hidden repositories, got {before:?}"
    );

    let output = setup(
        &harness.config_path(),
        &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()],
    );
    assert_ok(&output, "setup on a mixed server");

    // Governance really is in force now, or the assertion below is vacuous:
    // on an ungoverned server `settings` would be listed like anything else.
    assert!(
        harness.repos_dir().join("settings.git").exists(),
        "setup must create settings.git"
    );
    assert_eq!(
        exposure_of(&harness, "settings"),
        Exposed {
            listed: false,
            readable: false,
            clonable: false
        },
        "the governance repository must not be on the anonymous surface: \
         it holds the key roster"
    );

    let after: Vec<(&str, Exposed)> = names()
        .into_iter()
        .map(|name| (name, exposure_of(&harness, name)))
        .collect();

    assert_eq!(
        before, after,
        "enabling governance changed what the server exposes"
    );
}

/// Both files land in one commit.
///
/// A roster pushed without rules is refused by the push-time guard, and for
/// good reason — it governs nothing while publishing who has access. Setup
/// must not hand-create by another route the state that guard exists to
/// prevent.
#[test]
fn the_seed_is_a_single_commit_holding_both_the_rules_and_the_roster() {
    let harness = mixed_server();
    let output = setup(
        &harness.config_path(),
        &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()],
    );
    assert_ok(&output, "setup");

    let settings = harness.repos_dir().join("settings.git");
    let count = git_in(&settings, &["rev-list", "--count", "HEAD"]);
    assert_eq!(count.trim(), "1", "the seed must be exactly one commit");

    let files = git_in(&settings, &["show", "--name-only", "--format=", "HEAD"]);
    let mut paths: Vec<&str> = files.lines().filter(|l| !l.is_empty()).collect();
    paths.sort();
    assert_eq!(
        paths,
        vec!["conf/access.conf", "keydir/alex.pub"],
        "one commit must hold both halves; got {files}"
    );
}

/// The identity is the keydir basename, and getting it wrong locks the
/// operator out of the repository setup just created. So: it is reported, and
/// it actually works — the admin can push to `settings` over SSH afterwards,
/// authenticating through `keydir/` rather than `authorized_keys`.
#[test]
fn the_admin_it_names_can_push_the_settings_repository_afterwards() {
    let harness = mixed_server();
    let key = harness.named_key("alex");
    let output = setup(
        &harness.config_path(),
        &["--admin-key", key.with_extension("pub").to_str().unwrap()],
    );
    assert_ok(&output, "setup");
    assert!(
        stdout(&output).contains("alex"),
        "setup must report the identity it derived; got {}",
        stdout(&output)
    );

    // Clone over the filesystem, change the rules, push over SSH as the admin.
    let work = harness.scratch("admin-work");
    git_in(
        &work,
        &[
            "clone",
            "-q",
            harness.repos_dir().join("settings.git").to_str().unwrap(),
            ".",
        ],
    );
    git_in(&work, &["config", "user.email", "alex@example.com"]);
    git_in(&work, &["config", "user.name", "Alex"]);
    let conf = work.join("conf").join("access.conf");
    let text = std::fs::read_to_string(&conf).unwrap();
    std::fs::write(&conf, format!("{text}\n# an edit by the admin\n")).unwrap();
    git_in(&work, &["add", "-A"]);
    git_in(&work, &["commit", "-q", "-m", "edit"]);

    let push = harness.ssh_push_from(&work, &key, "settings", "HEAD:refs/heads/main");
    assert!(
        push.status.success(),
        "the admin setup enrolled must be able to push settings; got {}",
        stderr(&push)
    );
}

// ---------------------------------------------------------------------------
// Refusals and the dry run
// ---------------------------------------------------------------------------

/// An operator's rules are not something to overwrite.
#[test]
fn setup_refuses_when_a_settings_repository_already_exists() {
    let harness = mixed_server();
    let key = admin_key(&harness, "alex");
    assert_ok(
        &setup(
            &harness.config_path(),
            &["--admin-key", key.to_str().unwrap()],
        ),
        "the first setup",
    );

    let settings = harness.repos_dir().join("settings.git");
    let before = git_in(&settings, &["rev-parse", "HEAD"]);

    let second = setup(
        &harness.config_path(),
        &["--admin-key", key.to_str().unwrap()],
    );
    assert_failed(&second, "a second setup");
    assert!(
        stderr(&second).contains("settings.git"),
        "the refusal must name what is in the way; got {}",
        stderr(&second)
    );
    assert_eq!(
        before,
        git_in(&settings, &["rev-parse", "HEAD"]),
        "a refused setup must not touch the existing rules"
    );
}

/// An operator should be able to read the policy before it governs their
/// server.
#[test]
fn dry_run_prints_the_access_conf_and_changes_nothing() {
    let harness = mixed_server();
    let output = setup(
        &harness.config_path(),
        &[
            "--admin-key",
            admin_key(&harness, "alex").to_str().unwrap(),
            "--dry-run",
        ],
    );
    assert_ok(&output, "a dry run");

    let printed = stdout(&output);
    assert!(
        printed.contains("repo settings") && printed.contains("RW+"),
        "the dry run must print the rules that would govern; got {printed}"
    );
    for (name, _) in REPOS {
        assert!(
            printed.contains(&format!("repo {name}")),
            "the dry run must show a block for {name}; got {printed}"
        );
    }
    assert!(
        !harness.repos_dir().join("settings.git").exists(),
        "a dry run must not create anything"
    );

    // And the server is still ungoverned: everything it listed, it still
    // lists.
    assert!(exposure_of(&harness, "alpha").listed);
}

/// The generated rules reproduce the anonymous axis per repository, in the
/// spelling the design uses. Checked as text as well as through the server:
/// the operator reads this file, so its contents are part of the contract.
#[test]
fn the_generated_rules_publish_exactly_the_repositories_that_are_public_today() {
    let harness = mixed_server();
    let output = setup(
        &harness.config_path(),
        &[
            "--admin-key",
            admin_key(&harness, "alex").to_str().unwrap(),
            "--dry-run",
        ],
    );
    assert_ok(&output, "a dry run");
    let printed = stdout(&output);

    /// The lines of the `repo <name>` block, up to the next `repo` line.
    fn block_of<'a>(conf: &'a str, name: &str) -> Vec<&'a str> {
        conf.lines()
            .skip_while(|line| line.trim() != format!("repo {name}"))
            .skip(1)
            .take_while(|line| !line.trim_start().starts_with("repo "))
            .map(|line| line.trim())
            .filter(|line| !line.is_empty() && !line.starts_with('#'))
            .collect()
    }

    // Public today: reachable by name, and advertised.
    let alpha = block_of(&printed, "alpha").join("\n");
    assert!(
        alpha.contains("R                     =   @anonymous")
            || alpha.contains("= @anonymous")
            || alpha.contains("=   @anonymous"),
        "a public repository must keep its anonymous read grant; got {alpha}"
    );
    assert!(
        alpha.contains("listed") && alpha.contains("yes"),
        "and stay listed; got {alpha}"
    );

    // Private today: no anonymous grant at all, in either spelling.
    let private = block_of(&printed, "bravo-private").join("\n");
    assert!(
        !private.contains("@anonymous"),
        "a private repository must not gain an anonymous grant; got {private}"
    );
    assert!(!private.contains("listed"), "nor be listed; got {private}");

    // Public but with both anonymous switches off: same closed result, by a
    // different route through server.toml.
    let quiet = block_of(&printed, "charlie-quiet").join("\n");
    assert!(
        !quiet.contains("@anonymous") && !quiet.contains("listed"),
        "an unlisted repository must stay unlisted; got {quiet}"
    );

    // The settings repository grants the admin RW+ and nothing anonymous.
    let settings = block_of(&printed, "settings").join("\n");
    assert!(
        settings.contains("RW+") && settings.contains("alex"),
        "the admin must hold RW+ on settings; got {settings}"
    );
    assert!(
        !settings.contains("@anonymous"),
        "the key roster must not be published; got {settings}"
    );
}

// ---------------------------------------------------------------------------
// The admin key
// ---------------------------------------------------------------------------

#[test]
fn a_malformed_admin_key_is_refused_and_nothing_is_created() {
    let harness = mixed_server();
    let bad = harness.scratch("keys").join("broken.pub");
    std::fs::write(&bad, "ssh-ed25519 this-is-not-base64 alex@laptop\n").unwrap();

    let output = setup(
        &harness.config_path(),
        &["--admin-key", bad.to_str().unwrap()],
    );
    assert_failed(&output, "a malformed key");
    assert!(
        stderr(&output).to_lowercase().contains("key"),
        "the error must say the key is the problem; got {}",
        stderr(&output)
    );
    assert!(
        !harness.repos_dir().join("settings.git").exists(),
        "a failed setup must leave nothing behind"
    );
}

#[test]
fn a_missing_admin_key_is_refused_and_nothing_is_created() {
    let harness = mixed_server();
    let missing = harness.repos_dir().join("nowhere").join("absent.pub");

    let output = setup(
        &harness.config_path(),
        &["--admin-key", missing.to_str().unwrap()],
    );
    assert_failed(&output, "a missing key file");
    assert!(
        stderr(&output).contains("absent.pub"),
        "the error must name the file it could not read; got {}",
        stderr(&output)
    );
    assert!(!harness.repos_dir().join("settings.git").exists());
}

/// The keydir basename *is* the principal, so a name that cannot be written
/// in `conf/access.conf` has to be refused rather than written and discovered
/// later, when it is the reason nobody can push.
#[test]
fn a_key_whose_filename_cannot_name_a_principal_is_refused() {
    let harness = mixed_server();
    let source = std::fs::read_to_string(admin_key(&harness, "alex")).unwrap();
    let reserved = harness.scratch("keys").join("CREATOR.pub");
    std::fs::write(&reserved, &source).unwrap();

    let output = setup(
        &harness.config_path(),
        &["--admin-key", reserved.to_str().unwrap()],
    );
    assert_failed(&output, "a key named for a reserved keyword");
    assert!(
        stderr(&output).contains("--admin-name"),
        "the refusal must point at the way out; got {}",
        stderr(&output)
    );
    assert!(!harness.repos_dir().join("settings.git").exists());
}

/// `--admin-name` overrides the filename, which is also the only way to name
/// an identity when the key arrives on stdin.
#[test]
fn the_admin_name_can_be_given_explicitly_and_read_from_stdin() {
    let harness = mixed_server();
    let output = setup(
        &harness.config_path(),
        &[
            "--admin-key",
            admin_key(&harness, "alex").to_str().unwrap(),
            "--admin-name",
            "ops",
        ],
    );
    assert_ok(&output, "setup with an explicit name");
    assert!(
        harness.repos_dir().join("settings.git").exists(),
        "setup must have run"
    );
    let files = git_in(
        &harness.repos_dir().join("settings.git"),
        &["show", "--name-only", "--format=", "HEAD"],
    );
    assert!(
        files.contains("keydir/ops.pub"),
        "the explicit name must decide the keydir basename; got {files}"
    );
}

#[test]
fn the_admin_key_may_be_read_from_stdin() {
    use std::io::Write;
    use std::process::Stdio;

    let harness = mixed_server();
    let key = std::fs::read_to_string(admin_key(&harness, "alex")).unwrap();

    let mut child = Command::new(env!("CARGO_BIN_EXE_git-collab-server"))
        .args([
            "setup",
            "--config",
            harness.config_path().to_str().unwrap(),
            "--admin-key",
            "-",
            "--admin-name",
            "ops",
        ])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("failed to spawn git-collab-server");
    child
        .stdin
        .take()
        .unwrap()
        .write_all(key.as_bytes())
        .unwrap();
    let output = child.wait_with_output().unwrap();
    assert_ok(&output, "setup reading the key from stdin");
    assert!(harness.repos_dir().join("settings.git").exists());
}

/// Reading a key from stdin with no name to give it cannot guess, and must
/// say so rather than inventing one.
#[test]
fn a_key_on_stdin_with_no_name_is_refused() {
    use std::io::Write;
    use std::process::Stdio;

    let harness = mixed_server();
    let key = std::fs::read_to_string(admin_key(&harness, "alex")).unwrap();

    let mut child = Command::new(env!("CARGO_BIN_EXE_git-collab-server"))
        .args([
            "setup",
            "--config",
            harness.config_path().to_str().unwrap(),
            "--admin-key",
            "-",
        ])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("failed to spawn git-collab-server");
    child
        .stdin
        .take()
        .unwrap()
        .write_all(key.as_bytes())
        .unwrap();
    let output = child.wait_with_output().unwrap();
    assert_failed(&output, "a key on stdin with no name");
    assert!(
        stderr(&output).contains("--admin-name"),
        "the refusal must name the flag that fixes it; got {}",
        stderr(&output)
    );
}

// ---------------------------------------------------------------------------
// Enrolling the keys that already have access
// ---------------------------------------------------------------------------
//
// `keydir/` supersedes `authorized_keys` the instant `settings.git` exists, so
// a one-key bootstrap would revoke SSH for everybody but the admin. Setup
// therefore enrols the file's entries, and these tests hold it to the same
// standard as the exposure test above: what each key could do before, it can
// do after.

/// The principal string the server derives from a public key, taken from
/// OpenSSH's own fingerprinting rather than from ours — an external oracle,
/// so a change to how we compute principals cannot quietly bless itself.
fn principal_of(pubkey: &Path) -> String {
    let output = Command::new("ssh-keygen")
        .args(["-lf", pubkey.to_str().unwrap()])
        .output()
        .expect("failed to run ssh-keygen");
    assert!(
        output.status.success(),
        "ssh-keygen -lf failed: {}",
        stderr(&output)
    );
    let text = String::from_utf8(output.stdout).unwrap();
    let fingerprint = text
        .split_whitespace()
        .nth(1)
        .expect("ssh-keygen -lf prints the fingerprint second");
    format!("key:{fingerprint}")
}

/// What one key can do to one repository, asked of the running server over
/// real SSH rather than of the code that decides it.
#[derive(Debug, PartialEq, Eq)]
struct Reach {
    read: bool,
    write: bool,
}

fn reach_of(harness: &ServerHarness, key_name: &str, repo: &str, phase: &str) -> Reach {
    let key = harness.named_key(key_name);
    let dir = harness.work_repo().dir.path();
    Reach {
        read: harness.ssh_fetch(dir, &key, repo).status.success(),
        write: harness
            .ssh_push_from(
                dir,
                &key,
                repo,
                &format!("HEAD:refs/heads/probe-{phase}-{key_name}"),
            )
            .status
            .success(),
    }
}

/// The whole who-can-do-what matrix, in a fixed order so before and after are
/// comparable as one value.
fn reach_matrix(
    harness: &ServerHarness,
    keys: &[&str],
    repos: &[&str],
    phase: &str,
) -> Vec<(String, Reach)> {
    let mut matrix = Vec::new();
    for key in keys {
        for repo in repos {
            matrix.push((
                format!("{key} -> {repo}"),
                reach_of(harness, key, repo, phase),
            ));
        }
    }
    matrix
}

/// A server that is already in use: three keys in `authorized_keys`, and a
/// repository set that gives each of them a *different* reach.
///
/// The differences are the point. A fixture where everyone could do everything
/// would be satisfied by a seed that granted everyone everything, which is the
/// widening the no-op principle forbids just as much as the lockout.
fn populated_server(keys: &[&str]) -> ServerHarness {
    let harness = ServerHarness::new("hosted");
    let repos_dir = harness.repos_dir();

    // Generate the keys first: the rosters below name them by fingerprint,
    // exactly as an operator's `server.toml` would.
    let principals: Vec<String> = keys
        .iter()
        .map(|name| principal_of(&harness.named_key(name).with_extension("pub")))
        .collect();
    harness.authorize_named_keys(keys);

    // Anyone who authenticates: the default, and the common case.
    make_repo(&repos_dir, "open", "");
    // An explicit roster of one: only the second key, read and write.
    make_repo(
        &repos_dir,
        "bob-only",
        &format!(
            "visibility = \"private\"\n[access]\nread = [{0:?}]\nwrite = [{0:?}]\n",
            principals[1]
        ),
    );
    // Read for the third key, write for nobody at all.
    make_repo(
        &repos_dir,
        "carol-reads",
        &format!(
            "visibility = \"private\"\n[access]\nread = [{:?}]\nwrite = []\n",
            principals[2]
        ),
    );

    harness.work_repo().commit_file("a.txt", "one", "first");
    harness
}

/// **The test that matters.** Enabling governance does not change who can do
/// what over SSH, any more than it changes what is exposed over HTTP.
///
/// Every key in `authorized_keys` is measured against every repository before
/// setup runs and again afterwards, through the real server. The two matrices
/// must be identical: no key loses access it had, and no key gains access it
/// did not.
#[test]
fn setup_preserves_exactly_what_every_authorized_key_could_already_do() {
    let keys = ["alex", "bob", "carol"];
    let repos = ["open", "bob-only", "carol-reads"];
    let harness = populated_server(&keys);

    let before = reach_matrix(&harness, &keys, &repos, "before");

    // The fixture is only worth anything if it is genuinely mixed: a matrix
    // that was all-true or all-false would be preserved by a seed that got
    // the whole question wrong.
    assert!(
        before.iter().any(|(_, r)| r.read) && before.iter().any(|(_, r)| !r.read),
        "the fixture must contain both readable and unreadable pairs, got {before:?}"
    );
    assert!(
        before.iter().any(|(_, r)| r.write) && before.iter().any(|(_, r)| !r.write),
        "the fixture must contain both writable and unwritable pairs, got {before:?}"
    );

    let output = setup(
        &harness.config_path(),
        &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()],
    );
    assert_ok(&output, "setup on a server with keys already in use");

    // Governance really is in force, or everything below is vacuous: an
    // unenrolled key must now be refused outright.
    assert!(
        harness.repos_dir().join("settings.git").exists(),
        "setup must create settings.git"
    );
    let stranger = harness.named_key("stranger");
    assert!(
        !harness
            .ssh_fetch(harness.work_repo().dir.path(), &stranger, "open")
            .status
            .success(),
        "a key that was never in authorized_keys must not authenticate under governance"
    );

    let after = reach_matrix(&harness, &keys, &repos, "after");
    assert_eq!(
        before, after,
        "enabling governance changed what an already-authorized key can do"
    );
}

/// The admin's own key is normally already in `authorized_keys`, and one key
/// cannot be two principals: enrolling it again under a derived name would
/// make the roster ambiguous and abort the seed.
#[test]
fn the_admin_key_already_in_authorized_keys_is_enrolled_once_under_the_admin_name() {
    let harness = populated_server(&["alex", "bob", "carol"]);
    let output = setup(
        &harness.config_path(),
        &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()],
    );
    assert_ok(&output, "setup");

    let files = git_in(
        &harness.repos_dir().join("settings.git"),
        &["show", "--name-only", "--format=", "HEAD"],
    );
    let enrolled: Vec<&str> = files
        .lines()
        .filter(|line| line.starts_with("keydir/"))
        .collect();
    assert!(
        enrolled.contains(&"keydir/alex.pub"),
        "the admin keeps the name it was given; got {enrolled:?}"
    );
    assert_eq!(
        enrolled.iter().filter(|path| path.contains("alex")).count(),
        1,
        "the admin's key must be enrolled exactly once; got {enrolled:?}"
    );
    assert_eq!(
        enrolled.len(),
        3,
        "one file per distinct key; got {enrolled:?}"
    );
}

/// An entry with no usable comment still gets in, and the operator is told
/// what it was called. A malformed entry is reported and skipped rather than
/// aborting a setup that would otherwise preserve everyone else's access.
#[test]
fn an_unnameable_or_malformed_entry_does_not_abort_the_setup_and_is_reported() {
    let harness = ServerHarness::new("hosted");
    make_repo(&harness.repos_dir(), "open", "");

    // Built by hand, because these are the shapes a real file grows: a key
    // with no comment at all, a comment that cannot be a principal name, and
    // a line that is not a key.
    let read = |name: &str| {
        std::fs::read_to_string(harness.named_key(name).with_extension("pub"))
            .unwrap()
            .trim()
            .to_string()
    };
    let strip_comment = |line: &str| {
        line.split_whitespace()
            .take(2)
            .collect::<Vec<_>>()
            .join(" ")
    };
    let bob = read("bob");
    let dave = strip_comment(&read("dave"));
    let erin = format!("{} erin's spare laptop", strip_comment(&read("erin")));
    std::fs::write(
        harness.authorized_keys_path(),
        format!(
            "# the operator's own notes\n\n{bob}\n{dave}\n{erin}\n\
             ssh-ed25519 not-actually-base64 broken@host\n"
        ),
    )
    .unwrap();

    let output = setup(
        &harness.config_path(),
        &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()],
    );
    assert_ok(&output, "setup with an unnameable and a malformed entry");

    let files = git_in(
        &harness.repos_dir().join("settings.git"),
        &["show", "--name-only", "--format=", "HEAD"],
    );
    let enrolled: Vec<&str> = files
        .lines()
        .filter(|line| line.starts_with("keydir/"))
        .collect();
    assert!(
        enrolled.contains(&"keydir/bob@test.pub"),
        "a comment that can name a principal should name it; got {enrolled:?}"
    );
    assert_eq!(
        enrolled.len(),
        4,
        "the admin and the three well-formed entries; got {enrolled:?}"
    );

    // The two that could not be named got provisional names, and the operator
    // was told which key wears which.
    let report = format!("{}{}", stdout(&output), stderr(&output));
    assert!(
        enrolled.contains(&"keydir/key1.pub") && enrolled.contains(&"keydir/key2.pub"),
        "an entry with no usable comment needs an obviously provisional name; got {enrolled:?}"
    );
    for name in ["bob@test", "key1", "key2"] {
        assert!(
            report.contains(name),
            "setup must report the name it derived for every enrolled key; {name} missing from:\n{report}"
        );
    }
    assert!(
        report.contains(&principal_of(
            &harness.named_key("dave").with_extension("pub")
        )),
        "a provisional name is only usable if the report says which key it is:\n{report}"
    );
    assert!(
        report.to_lowercase().contains("not a well-formed")
            || report.to_lowercase().contains("malformed")
            || report.to_lowercase().contains("could not be read"),
        "the malformed entry must be reported, not silently dropped:\n{report}"
    );

    // And the malformed line did not become a principal.
    assert!(
        !files.contains("broken"),
        "a line that is not a key must not be enrolled; got {files}"
    );
}

/// The derived names are the thing an operator most wants to check before it
/// is too late to change them cheaply, so the dry run has to show them.
#[test]
fn the_dry_run_shows_the_derived_names_before_anything_is_written() {
    let harness = populated_server(&["alex", "bob", "carol"]);
    let output = setup(
        &harness.config_path(),
        &[
            "--admin-key",
            admin_key(&harness, "alex").to_str().unwrap(),
            "--dry-run",
        ],
    );
    assert_ok(&output, "a dry run");

    let report = format!("{}{}", stdout(&output), stderr(&output));
    for name in ["bob@test", "carol@test"] {
        assert!(
            report.contains(name),
            "the dry run must show the name {name} it would enrol; got:\n{report}"
        );
    }
    assert!(
        !harness.repos_dir().join("settings.git").exists(),
        "a dry run must not create anything"
    );
}

/// The opt-out. Enrolment is the default because the safe answer should be,
/// but an operator who wants the clean single-admin bootstrap can say so.
#[test]
fn no_enrol_existing_gives_a_single_admin_bootstrap() {
    let harness = populated_server(&["alex", "bob", "carol"]);
    let output = setup(
        &harness.config_path(),
        &[
            "--admin-key",
            admin_key(&harness, "alex").to_str().unwrap(),
            "--no-enrol-existing",
        ],
    );
    assert_ok(&output, "setup with enrolment declined");

    let files = git_in(
        &harness.repos_dir().join("settings.git"),
        &["show", "--name-only", "--format=", "HEAD"],
    );
    let mut paths: Vec<&str> = files.lines().filter(|l| !l.is_empty()).collect();
    paths.sort();
    assert_eq!(
        paths,
        vec!["conf/access.conf", "keydir/alex.pub"],
        "declining enrolment must leave the admin alone in keydir/; got {files}"
    );

    // And it says what that costs, because it is the lockout the default
    // exists to avoid.
    let report = format!("{}{}", stdout(&output), stderr(&output));
    assert!(
        report.contains("2") && report.to_lowercase().contains("authorized_keys"),
        "declining must say how many keys it just locked out; got:\n{report}"
    );
}

/// `setup` must never touch `authorized_keys`: leaving it alone is what lets
/// an operator turn governance back off by removing `settings.git` and find
/// the server exactly as they left it.
#[test]
fn setup_does_not_touch_the_authorized_keys_file() {
    let harness = mixed_server();
    // The harness writes the file when a client key is generated.
    let key = harness.ssh_client_key();
    let authorized = std::fs::read_to_string(key.with_file_name("authorized_keys")).unwrap();

    assert_ok(
        &setup(
            &harness.config_path(),
            &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()],
        ),
        "setup",
    );

    assert_eq!(
        authorized,
        std::fs::read_to_string(key.with_file_name("authorized_keys")).unwrap(),
        "setup must leave authorized_keys alone"
    );
}