michal/tit

Browse tree · Show commit · Download archive

Blob: tests/serve.rs

Raw · Blame

#[allow(
    dead_code,
    reason = "the server test uses only part of the shared test support"
)]
mod support;

use std::env;
use std::fs;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::{Child, Command, ExitStatus, Output, Stdio};
use std::sync::{Mutex, MutexGuard};
use std::thread;
use std::time::{Duration, Instant};

use support::{create_ssh_key_fixture, free_address};
use tempfile::TempDir;

static SERVER_TEST_LOCK: Mutex<()> = Mutex::new(());

fn server_test_lock() -> MutexGuard<'static, ()> {
    SERVER_TEST_LOCK
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner)
}

#[test]
fn serves_an_imported_repository_through_http_and_ssh() {
    let _server_test = server_test_lock();
    let instance = TempDir::new().expect("create an instance directory");
    let http = free_address();
    let ssh = free_address();
    let config = instance.path().join("config.toml");
    fs::write(
        &config,
        format!(
            "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
            ssh.port()
        ),
    )
    .expect("write the server configuration");
    let private_key = instance.path().join("administrator");
    create_ssh_key_fixture(&private_key);
    let public_key = fs::read_to_string(private_key.with_extension("pub"))
        .expect("read the administrator public key");
    command(
        instance.path(),
        [
            "--config",
            config.to_str().expect("a UTF-8 configuration path"),
            "setup",
            "admin",
            "alice",
            public_key.trim(),
        ],
    );

    let source = create_source_repository(instance.path());
    command(
        instance.path(),
        [
            "--config",
            config.to_str().expect("a UTF-8 configuration path"),
            "admin",
            "repository",
            "import",
            "alice",
            "example",
            source.to_str().expect("a UTF-8 source path"),
        ],
    );
    let _settings_member_key = provision_account(instance.path(), "carol", "active", false);
    let mut server = spawn_server(&config);
    wait_for_listener(http, &mut server);
    wait_for_listener(ssh, &mut server);
    let health = http_get(http, "/healthz");
    assert!(health.starts_with("HTTP/1.1 200"));
    assert!(health.ends_with("\r\n\r\nready\n"));

    let backup_directory = TempDir::new().expect("create a backup directory");
    let backup = backup_directory.path().join("instance.tar");
    let backup_output = Command::new(tit_binary())
        .args([
            "--config",
            config.to_str().expect("a UTF-8 configuration path"),
            "backup",
            backup.to_str().expect("a UTF-8 backup path"),
        ])
        .output()
        .expect("request an online backup");
    assert!(
        backup_output.status.success(),
        "online backup failed: {}",
        String::from_utf8_lossy(&backup_output.stderr)
    );
    assert!(String::from_utf8_lossy(&backup_output.stdout).contains("contains credentials"));
    assert_eq!(
        fs::metadata(&backup)
            .expect("inspect the backup")
            .permissions()
            .mode()
            & 0o777,
        0o600
    );

    let restored = TempDir::new().expect("create a restore target");
    fs::set_permissions(restored.path(), fs::Permissions::from_mode(0o700))
        .expect("make the restore target private");
    let restore_output = Command::new(tit_binary())
        .args([
            "restore",
            backup.to_str().expect("a UTF-8 backup path"),
            restored.path().to_str().expect("a UTF-8 restore path"),
        ])
        .output()
        .expect("restore the online backup");
    assert!(
        restore_output.status.success(),
        "restore failed: {}",
        String::from_utf8_lossy(&restore_output.stderr)
    );
    assert!(String::from_utf8_lossy(&restore_output.stdout).contains("is not active"));
    let restored_database = rusqlite::Connection::open(restored.path().join("tit.sqlite3"))
        .expect("open the restored database");
    let restored_repository: String = restored_database
        .query_row(
            "SELECT id FROM repository WHERE slug = 'example'",
            [],
            |row| row.get(0),
        )
        .expect("read the restored repository");
    drop(restored_database);
    let restored_readme = Command::new("git")
        .args(["--git-dir"])
        .arg(
            restored
                .path()
                .join("repositories")
                .join(format!("{restored_repository}.git")),
        )
        .args(["show", "main:README.md"])
        .output()
        .expect("read the restored Git repository");
    assert!(restored_readme.status.success());
    assert_eq!(restored_readme.stdout, b"serve fixture\n");

    let second = Command::new(tit_binary())
        .args([
            "--config",
            config.to_str().expect("a UTF-8 configuration path"),
            "serve",
        ])
        .output()
        .expect("start a second tit server");
    assert!(!second.status.success());
    assert!(String::from_utf8_lossy(&second.stderr).contains("owns the instance lock"));

    let control_socket = instance.path().join("control.sock");
    assert_eq!(
        fs::symlink_metadata(&control_socket)
            .expect("inspect the control socket")
            .permissions()
            .mode()
            & 0o777,
        0o600
    );

    let anonymous_home = http_get(http, "/");
    assert!(
        anonymous_home.contains("Recently updated public repositories"),
        "anonymous home response:\n{anonymous_home}"
    );
    assert!(anonymous_home.contains(">alice/example</a>"));
    assert!(anonymous_home.contains("<a href=\"/signup\">Create account</a>"));
    assert!(anonymous_home.contains("<a href=\"/recover\">Recover account</a>"));
    assert!(anonymous_home.contains("<a href=\"/login\">Log in</a>"));
    assert!(!anonymous_home.contains("<a href=\"/account\">Account</a>"));
    assert!(http_get(http, "/alice/example").contains("trunk head"));

    let login_approval = http_form(http, "/login/ssh", &[]);
    assert!(login_approval.starts_with("HTTP/1.1 200"));
    let secret = between(&login_approval, "name=\"secret\" value=\"", "\">");
    let login_csrf_cookies = response_cookies(&login_approval);
    let login_csrf = cookie_value(&login_csrf_cookies, "tit-login-csrf");
    let removed_login = ssh_exec(ssh, &private_key, &["login", secret]);
    assert!(!removed_login.status.success());
    assert!(String::from_utf8_lossy(&removed_login.stderr).contains("help"));
    let ssh_approval = ssh_exec(ssh, &private_key, &["auth", secret]);
    assert!(ssh_approval.status.success());
    let ssh_approval_output =
        String::from_utf8(ssh_approval.stdout).expect("read SSH approval output");
    assert!(ssh_approval_output.contains(&format!("Origin: http://{http}")));
    assert!(ssh_approval_output.contains("Account: alice"));
    let rejected_login = http_form_with_headers(
        http,
        "/login/ssh/complete",
        &[("secret", secret), ("login-csrf", &"0".repeat(64))],
        &[("Cookie", &login_csrf_cookies)],
    );
    assert!(rejected_login.starts_with("HTTP/1.1 400"));
    let rejected_login_id = response_header(&rejected_login, "x-request-id").to_owned();
    let login = http_form_with_headers(
        http,
        "/login/ssh/complete",
        &[("secret", secret), ("login-csrf", login_csrf)],
        &[("Cookie", &login_csrf_cookies)],
    );
    assert!(login.starts_with("HTTP/1.1 303"), "{login}");
    let login_id = response_header(&login, "x-request-id").to_owned();
    let cookies = response_cookies(&login);
    let account = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
    assert!(account.starts_with("HTTP/1.1 200"));
    assert!(account.contains("<dd>alice</dd>"));
    assert!(account.contains("<a href=\"/account\">Account</a>"));
    assert!(account.contains("<a href=\"/logout\">Log out</a>"));
    assert!(!account.contains("<a href=\"/login\">Log in</a>"));
    assert!(!account.contains("<a href=\"/signup\">Create account</a>"));
    assert!(!account.contains("<a href=\"/recover\">Recover account</a>"));
    assert!(account.contains("action=\"/account/repositories\""));
    assert!(!account.contains("object-format"));
    assert!(account.contains("<ul class=\"key-list\">"));
    assert!(!account.contains("<table>"));
    for path in ["/login", "/signup", "/recover"] {
        let response = http_get_with_headers(http, path, &[("Cookie", &cookies)]);
        assert!(response.starts_with("HTTP/1.1 303"));
        assert_eq!(response_header(&response, "location"), "/account");
    }
    let signed_in_home = http_get_with_headers(http, "/", &[("Cookie", &cookies)]);
    assert!(signed_in_home.contains("<h1>alice</h1>"));
    assert!(signed_in_home.contains("<h2>Your repositories</h2>"));
    assert!(signed_in_home.contains("<h2>Recently updated public repositories</h2>"));
    assert!(
        signed_in_home
            .contains("</section>\n  <section>\n  <h2>Recently updated public repositories</h2>")
    );
    assert_eq!(signed_in_home.matches(">alice/example</a>").count(), 2);
    assert!(signed_in_home.contains("<a href=\"/account\">Account</a>"));
    assert!(signed_in_home.contains("<a href=\"/logout\">Log out</a>"));
    assert!(!signed_in_home.contains("<a href=\"/signup\">Create account</a>"));
    assert!(!signed_in_home.contains("<a href=\"/recover\">Recover account</a>"));
    assert!(!signed_in_home.contains("<a href=\"/login\">Log in</a>"));
    let signed_in_missing =
        http_get_with_headers(http, "/this-page-does-not-exist", &[("Cookie", &cookies)]);
    assert!(signed_in_missing.starts_with("HTTP/1.1 404"));
    assert!(signed_in_missing.contains("<a href=\"/account\">Account</a>"));
    assert!(signed_in_missing.contains("<a href=\"/logout\">Log out</a>"));
    assert!(!signed_in_missing.contains("<a href=\"/login\">Log in</a>"));
    let csrf = cookie_value(&cookies, "tit-csrf");
    let second_key = instance.path().join("alice-second");
    create_ssh_key_fixture(&second_key);
    let second_public_key =
        fs::read_to_string(second_key.with_extension("pub")).expect("read the second public key");
    let second_fingerprint = ssh_fingerprint(&second_key);
    let begin_add = http_form_with_headers(
        http,
        "/account/keys/add",
        &[
            ("csrf", csrf),
            ("label", "laptop"),
            ("public-key", second_public_key.trim()),
        ],
        &[("Cookie", &cookies)],
    );
    assert!(begin_add.starts_with("HTTP/1.1 200"), "{begin_add}");
    assert!(begin_add.contains("ssh -p"));
    assert!(begin_add.contains(" auth "));
    let add_secret = between(&begin_add, "name=\"secret\" value=\"", "\">");
    assert!(
        ssh_exec(ssh, &private_key, &["auth", add_secret])
            .status
            .success()
    );
    let complete_add = http_form_with_headers(
        http,
        "/account/keys/add/complete",
        &[
            ("csrf", csrf),
            ("secret", add_secret),
            ("label", "laptop"),
            ("public-key", second_public_key.trim()),
        ],
        &[("Cookie", &cookies)],
    );
    assert!(complete_add.starts_with("HTTP/1.1 303"), "{complete_add}");
    let replay_add = http_form_with_headers(
        http,
        "/account/keys/add/complete",
        &[
            ("csrf", csrf),
            ("secret", add_secret),
            ("label", "replay"),
            ("public-key", second_public_key.trim()),
        ],
        &[("Cookie", &cookies)],
    );
    assert!(replay_add.starts_with("HTTP/1.1 400"));
    assert!(ssh_exec(ssh, &second_key, &["help"]).status.success());
    let account_with_key = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
    assert!(account_with_key.contains("laptop"));
    assert!(account_with_key.contains(&second_fingerprint));

    let begin_revoke = http_form_with_headers(
        http,
        "/account/keys/revoke",
        &[("csrf", csrf), ("fingerprint", &second_fingerprint)],
        &[("Cookie", &cookies)],
    );
    assert!(begin_revoke.starts_with("HTTP/1.1 200"));
    let revoke_secret = between(&begin_revoke, "name=\"secret\" value=\"", "\">");
    assert!(
        ssh_exec(ssh, &private_key, &["auth", revoke_secret])
            .status
            .success()
    );
    let complete_revoke = http_form_with_headers(
        http,
        "/account/keys/revoke/complete",
        &[
            ("csrf", csrf),
            ("secret", revoke_secret),
            ("fingerprint", &second_fingerprint),
        ],
        &[("Cookie", &cookies)],
    );
    assert!(
        complete_revoke.starts_with("HTTP/1.1 303"),
        "{complete_revoke}"
    );
    assert!(!ssh_exec(ssh, &second_key, &["help"]).status.success());

    let initial_fingerprint = ssh_fingerprint(&private_key);
    let begin_final_revoke = http_form_with_headers(
        http,
        "/account/keys/revoke",
        &[("csrf", csrf), ("fingerprint", &initial_fingerprint)],
        &[("Cookie", &cookies)],
    );
    let final_secret = between(&begin_final_revoke, "name=\"secret\" value=\"", "\">");
    assert!(
        ssh_exec(ssh, &private_key, &["auth", final_secret])
            .status
            .success()
    );
    let final_revoke = http_form_with_headers(
        http,
        "/account/keys/revoke/complete",
        &[
            ("csrf", csrf),
            ("secret", final_secret),
            ("fingerprint", &initial_fingerprint),
        ],
        &[("Cookie", &cookies)],
    );
    assert!(final_revoke.starts_with("HTTP/1.1 400"));
    assert!(final_revoke.contains("must keep at least one active SSH key"));

    let profile_without_contact_email = http_get(http, "/alice");
    assert!(profile_without_contact_email.starts_with("HTTP/1.1 200"));
    assert!(!profile_without_contact_email.contains("mailto:"));
    let profile_update = http_form_with_headers(
        http,
        "/account/profile",
        &[
            ("csrf", csrf),
            ("bio", "Builds small systems."),
            ("contact-email", "alice@example.test"),
        ],
        &[("Cookie", &cookies)],
    );
    assert!(profile_update.starts_with("HTTP/1.1 303"));
    let public_profile = http_get(http, "/alice");
    assert!(public_profile.starts_with("HTTP/1.1 200"));
    assert!(public_profile.contains("Builds small systems."));
    assert!(public_profile.contains("mailto:alice@example.test"));
    assert!(public_profile.contains("href=\"/alice/example\""));
    let settings = http_get_with_headers(http, "/alice/example/settings", &[("Cookie", &cookies)]);
    assert!(settings.starts_with("HTTP/1.1 200"));
    assert!(settings.contains("<h2>Repository settings</h2>"));
    assert!(settings.contains("<option value=\"refs/heads/trunk\" selected>"));
    let pulls = http_get_with_headers(http, "/alice/example/pulls", &[("Cookie", &cookies)]);
    assert!(pulls.contains("value=\"refs/heads/trunk\" required"));
    for invalid in [
        "refs/heads/missing",
        "refs/tags/v1",
        "main",
        "refs/heads/../bad",
    ] {
        let rejected = http_form_with_headers(
            http,
            "/alice/example/settings/default-branch",
            &[("csrf", csrf), ("default-branch", invalid)],
            &[("Cookie", &cookies)],
        );
        assert!(
            rejected.starts_with("HTTP/1.1 400"),
            "{invalid}: {rejected}"
        );
    }
    let default_branch_update = http_form_with_headers(
        http,
        "/alice/example/settings/default-branch",
        &[("csrf", csrf), ("default-branch", "refs/heads/main")],
        &[("Cookie", &cookies)],
    );
    assert!(default_branch_update.starts_with("HTTP/1.1 303"));
    let updated_pulls =
        http_get_with_headers(http, "/alice/example/pulls", &[("Cookie", &cookies)]);
    assert!(updated_pulls.contains("value=\"refs/heads/main\" required"));
    assert!(!http_get(http, "/alice/example").contains("trunk head"));
    assert_eq!(
        fs::read_to_string(
            instance
                .path()
                .join("repositories")
                .join(format!("{restored_repository}.git"))
                .join("HEAD")
        )
        .expect("read the changed symbolic HEAD"),
        "ref: refs/heads/main\n"
    );
    let settings_update = http_form_with_headers(
        http,
        "/alice/example/settings/general",
        &[
            ("csrf", csrf),
            ("description", "A repository with self-service settings."),
            ("visibility", "public"),
        ],
        &[("Cookie", &cookies)],
    );
    assert!(settings_update.starts_with("HTTP/1.1 303"));
    let collaborator_update = http_form_with_headers(
        http,
        "/alice/example/settings/collaborators",
        &[
            ("csrf", csrf),
            ("username", "carol"),
            ("role", "reader"),
            ("action", "set"),
        ],
        &[("Cookie", &cookies)],
    );
    assert!(collaborator_update.starts_with("HTTP/1.1 303"));
    let updated_settings =
        http_get_with_headers(http, "/alice/example/settings", &[("Cookie", &cookies)]);
    assert!(updated_settings.contains("A repository with self-service settings."));
    assert!(updated_settings.contains("carol · reader"));
    assert!(http_get(http, "/alice/example").contains(
        "<p class=\"repository-description\">A repository with self-service settings.</p>"
    ));
    let logout_page = http_get_with_headers(http, "/logout", &[("Cookie", &cookies)]);
    assert!(logout_page.starts_with("HTTP/1.1 200"));
    assert!(logout_page.contains("<form method=\"post\" action=\"/logout\">"));
    assert!(logout_page.contains(&format!("name=\"csrf\" value=\"{csrf}\"")));
    let rejected_repository = http_form_with_headers(
        http,
        "/account/repositories",
        &[("csrf", &"0".repeat(64)), ("name", "web-created")],
        &[("Cookie", &cookies)],
    );
    assert!(rejected_repository.starts_with("HTTP/1.1 403"));
    let created_repository = http_form_with_headers(
        http,
        "/account/repositories",
        &[("csrf", csrf), ("name", "web-created")],
        &[("Cookie", &cookies)],
    );
    assert!(created_repository.starts_with("HTTP/1.1 303"));
    assert_eq!(
        response_header(&created_repository, "location"),
        "/alice/web-created"
    );
    let web_create_id = response_header(&created_repository, "x-request-id").to_owned();
    assert!(http_get(http, "/alice/web-created").starts_with("HTTP/1.1 200"));
    let rejected_logout = http_form_with_headers(
        http,
        "/logout",
        &[("csrf", &"0".repeat(64))],
        &[("Cookie", &cookies)],
    );
    assert!(rejected_logout.starts_with("HTTP/1.1 403"));
    let cancelled_logout = http_form_with_headers(
        http,
        "/logout",
        &[("csrf", csrf), ("confirm", "no")],
        &[("Cookie", &cookies)],
    );
    assert!(cancelled_logout.starts_with("HTTP/1.1 303"));
    assert_eq!(response_header(&cancelled_logout, "location"), "/account");
    assert!(
        http_get_with_headers(http, "/account", &[("Cookie", &cookies)])
            .starts_with("HTTP/1.1 200")
    );
    let logout = http_form_with_headers(
        http,
        "/logout",
        &[("csrf", csrf), ("confirm", "yes")],
        &[("Cookie", &cookies)],
    );
    assert!(logout.starts_with("HTTP/1.1 303"));
    let ended = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
    assert!(ended.starts_with("HTTP/1.1 303"));

    let upload_challenge_page = http_form(http, "/login", &[("username", "alice")]);
    let upload_challenge = between(
        &upload_challenge_page,
        "<textarea id=\"challenge-display\" readonly rows=\"10\">",
        "</textarea>",
    );
    let upload_signature = sign_challenge(instance.path(), &private_key, upload_challenge);
    let browser_upload_challenge = upload_challenge.replace('\n', "\r\n");
    let upload_csrf_cookies = response_cookies(&upload_challenge_page);
    let upload_csrf = cookie_value(&upload_csrf_cookies, "tit-login-csrf");
    let downloaded_challenge = http_form_with_headers(
        http,
        "/login/challenge.txt",
        &[
            ("username", "alice"),
            ("challenge", &browser_upload_challenge),
            ("login-csrf", upload_csrf),
        ],
        &[("Cookie", &upload_csrf_cookies)],
    );
    assert!(downloaded_challenge.starts_with("HTTP/1.1 200"));
    assert_eq!(
        downloaded_challenge
            .split_once("\r\n\r\n")
            .expect("split the challenge response")
            .1,
        upload_challenge
    );
    let wrong_upload_type = http_form_with_headers(
        http,
        "/login/verify-file",
        &[("signature-file", &upload_signature)],
        &[("Cookie", &upload_csrf_cookies)],
    );
    assert!(wrong_upload_type.starts_with("HTTP/1.1 400"));
    let malformed_upload = http_body(
        http,
        "/login/verify-file",
        "multipart/form-data; boundary=tit-broken-boundary",
        "--tit-broken-boundary\r\ninvalid",
        &[("Cookie", &upload_csrf_cookies)],
    );
    assert!(malformed_upload.starts_with("HTTP/1.1 400"));
    let invalid_signature = http_form_with_headers(
        http,
        "/login/verify",
        &[
            ("username", "alice"),
            ("challenge", &browser_upload_challenge),
            ("signature", "not an SSHSIG envelope"),
            ("login-csrf", upload_csrf),
        ],
        &[("Cookie", &upload_csrf_cookies)],
    );
    assert!(invalid_signature.starts_with("HTTP/1.1 400"));
    assert!(invalid_signature.contains("The signature is not valid"));
    let uploaded = http_multipart(
        http,
        "/login/verify-file",
        &[
            ("username", "alice"),
            ("challenge", &browser_upload_challenge),
            ("signature-file", &upload_signature),
            ("login-csrf", upload_csrf),
        ],
        &[("Cookie", &upload_csrf_cookies)],
    );
    assert!(uploaded.starts_with("HTTP/1.1 303"), "{uploaded}");
    let private_cookies = response_cookies(&uploaded);
    let paste_challenge_page = http_form(http, "/login", &[("username", "alice")]);
    let paste_challenge = between(
        &paste_challenge_page,
        "<textarea id=\"challenge-display\" readonly rows=\"10\">",
        "</textarea>",
    );
    let paste_signature = sign_challenge(instance.path(), &private_key, paste_challenge);
    let paste_cookies = response_cookies(&paste_challenge_page);
    let paste_csrf = cookie_value(&paste_cookies, "tit-login-csrf");
    let pasted = http_form_with_headers(
        http,
        "/login/verify",
        &[
            ("username", "alice"),
            ("challenge", &paste_challenge.replace('\n', "\r\n")),
            ("signature", &paste_signature.replace('\n', "\r\n")),
            ("login-csrf", paste_csrf),
        ],
        &[("Cookie", &paste_cookies)],
    );
    assert!(pasted.starts_with("HTTP/1.1 303"), "{pasted}");
    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
        .expect("open the repository database");
    database
        .execute(
            "UPDATE repository SET visibility = 'private' WHERE slug = 'example'",
            [],
        )
        .expect("make the repository private");
    assert!(http_get(http, "/alice/example").starts_with("HTTP/1.1 404"));
    let public_profile_with_private_repository = http_get(http, "/alice");
    assert!(public_profile_with_private_repository.starts_with("HTTP/1.1 200"));
    assert!(!public_profile_with_private_repository.contains("href=\"/alice/example\""));
    let private_summary =
        http_get_with_headers(http, "/alice/example", &[("Cookie", &private_cookies)]);
    assert!(private_summary.starts_with("HTTP/1.1 200"));
    let private_feed = http_get_with_headers(
        http,
        "/alice/example/rss.xml",
        &[("Cookie", &private_cookies)],
    );
    assert!(private_feed.starts_with("HTTP/1.1 200"));
    assert!(private_feed.contains("cache-control: private, no-store"));
    database
        .execute(
            "UPDATE repository SET visibility = 'public' WHERE slug = 'example'",
            [],
        )
        .expect("make the repository public");
    drop(database);

    let invitation_output = Command::new(tit_binary())
        .args([
            "--config",
            config.to_str().expect("a UTF-8 configuration path"),
            "invite-code",
        ])
        .output()
        .expect("request an invitation");
    assert!(invitation_output.status.success());
    let invitation = String::from_utf8(invitation_output.stdout)
        .expect("read the invitation output")
        .trim()
        .strip_prefix("Signup code: ")
        .expect("read the invitation code")
        .to_owned();
    let member_key = instance.path().join("member");
    create_ssh_key_fixture(&member_key);
    let member_public =
        fs::read_to_string(member_key.with_extension("pub")).expect("read the member public key");
    let signup = http_form(
        http,
        "/signup",
        &[
            ("invitation", invitation.as_str()),
            ("username", "bob"),
            ("public-key", member_public.trim()),
        ],
    );
    assert!(signup.starts_with("HTTP/1.1 200"), "{signup}");
    let recovery = between(&signup, "<pre><code>", "</code></pre>");
    assert!(recovery.starts_with("tit-recovery-v1:"));

    let summary = http_get(http, "/alice/example");
    assert!(summary.starts_with("HTTP/1.1 200"));
    assert!(summary.contains("serve fixture"));

    let http_clone = instance.path().join("http-clone");
    command(
        instance.path(),
        [
            "clone",
            "-q",
            &format!("http://{http}/alice/example.git"),
            http_clone.to_str().expect("a UTF-8 HTTP clone path"),
        ],
    );
    assert_eq!(
        fs::read(http_clone.join("README.md")).expect("read the HTTP clone"),
        b"serve fixture\n"
    );

    assert!(ssh_clone_succeeds(
        ssh,
        &member_key,
        &instance.path().join("member-clone")
    ));

    let replacement_key = instance.path().join("replacement");
    create_ssh_key_fixture(&replacement_key);
    let replacement_public = fs::read_to_string(replacement_key.with_extension("pub"))
        .expect("read the replacement public key");
    let recovered = http_form(
        http,
        "/recover",
        &[
            ("recovery", recovery),
            ("username", "bob"),
            ("public-key", replacement_public.trim()),
        ],
    );
    assert!(recovered.starts_with("HTTP/1.1 200"), "{recovered}");
    let recovery_id = response_header(&recovered, "x-request-id").to_owned();
    assert!(!ssh_clone_succeeds(
        ssh,
        &member_key,
        &instance.path().join("revoked-clone")
    ));
    assert!(ssh_clone_succeeds(
        ssh,
        &replacement_key,
        &instance.path().join("replacement-clone")
    ));

    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
        .expect("open the audit database");
    let mut statement = database
        .prepare(
            "SELECT action, actor, target, outcome, correlation_id
             FROM audit_event ORDER BY id",
        )
        .expect("prepare the audit query");
    let audits = statement
        .query_map([], |row| {
            Ok((
                row.get::<_, String>(0)?,
                row.get::<_, String>(1)?,
                row.get::<_, String>(2)?,
                row.get::<_, String>(3)?,
                row.get::<_, String>(4)?,
            ))
        })
        .expect("query audit history")
        .collect::<Result<Vec<_>, _>>()
        .expect("read audit history");
    assert!(audits.iter().any(|event| {
        event.0 == "login" && event.3 == "failure" && event.4 == rejected_login_id
    }));
    assert!(
        audits
            .iter()
            .any(|event| event.0 == "login" && event.3 == "success" && event.4 == login_id)
    );
    assert!(audits.iter().any(|event| {
        event.0 == "account.recover" && event.3 == "success" && event.4 == recovery_id
    }));
    assert!(audits.iter().any(|event| {
        event.0 == "repository.create"
            && event.1 == "alice"
            && event.2 == "alice/web-created"
            && event.3 == "success"
            && event.4 == web_create_id
    }));
    for event in &audits {
        let visible = format!(
            "{} {} {} {} {}",
            event.0, event.1, event.2, event.3, event.4
        );
        assert!(!visible.contains(recovery));
        assert!(!visible.contains(secret));
        assert!(!visible.contains(upload_challenge));
        assert!(!visible.contains(&upload_signature));
        assert!(!visible.contains(paste_challenge));
        assert!(!visible.contains(&paste_signature));
    }
    drop(statement);
    drop(database);

    let ssh_clone = instance.path().join("ssh-clone");
    let ssh_command = format!(
        "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
        private_key.display()
    );
    let output = Command::new("git")
        .args([
            "clone",
            "-q",
            &format!("ssh://ignored@127.0.0.1:{}/alice/example.git", ssh.port()),
        ])
        .arg(&ssh_clone)
        .env("GIT_SSH_COMMAND", ssh_command)
        .output()
        .expect("clone through the tit SSH server");
    assert!(
        output.status.success(),
        "SSH clone failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert_eq!(
        fs::read(ssh_clone.join("README.md")).expect("read the SSH clone"),
        b"serve fixture\n"
    );

    let locked = Command::new(tit_binary())
        .args([
            "--config",
            config.to_str().expect("a UTF-8 configuration path"),
            "admin",
            "repository",
            "inspect",
            "alice",
            "example",
        ])
        .output()
        .expect("run an offline command while the server owns the instance");
    assert_eq!(locked.status.code(), Some(1));
    assert!(String::from_utf8_lossy(&locked.stderr).contains("owns the instance lock"));

    let metrics = http_get(http, "/metrics");
    assert!(metrics.starts_with("HTTP/1.1 200"));
    assert!(metrics.contains("tit_http_requests_total "));
    assert!(metrics.contains("tit_http_requests_in_flight 1"));
    assert!(metrics.contains("tit_ssh_connections_total "));
    assert!(metrics.contains("tit_ssh_operations_total "));

    let authorization_secret = "Bearer tit-secret-authorization";
    let cookie_secret = "tit-session=tit-secret-cookie";
    let feed_secret = "tit-secret-feed-token";
    let _ = http_get_with_headers(
        http,
        "/",
        &[
            ("Authorization", authorization_secret),
            ("Cookie", cookie_secret),
        ],
    );
    let _ = http_get(http, &format!("/feeds/{feed_secret}.atom"));

    let logs = server.terminate_capture();
    let logs = String::from_utf8(logs).expect("read structured server logs");
    assert!(!logs.contains(authorization_secret));
    assert!(!logs.contains(cookie_secret));
    assert!(!logs.contains(feed_secret));
    for secret in [
        recovery,
        secret,
        upload_challenge,
        paste_challenge,
        invitation.trim(),
        private_cookies.as_str(),
        upload_signature.as_str(),
        paste_signature.as_str(),
    ] {
        assert!(!logs.contains(secret));
    }
    let events: Vec<serde_json::Value> = logs
        .lines()
        .map(|line| serde_json::from_str(line).expect("parse a structured server log"))
        .collect();
    assert!(events.iter().any(|event| {
        event["event"] == "http.request"
            && event["request_id"]
                .as_str()
                .is_some_and(|request_id| request_id.len() == 32)
    }));
    assert!(events.iter().any(|event| {
        event["event"] == "ssh.operation"
            && event["operation_id"]
                .as_str()
                .is_some_and(|operation_id| operation_id.len() == 32)
    }));
    assert!(
        events
            .iter()
            .any(|event| event["event"] == "server.shutdown" && event["outcome"] == "completed")
    );

    assert!(!control_socket.exists());
    let host_key = fs::read(instance.path().join("ssh_host_ed25519_key"))
        .expect("read the generated SSH host key");
    assert_eq!(
        fs::metadata(instance.path().join("ssh_host_ed25519_key"))
            .expect("inspect the generated SSH host key")
            .permissions()
            .mode()
            & 0o777,
        0o600
    );
    let mut restarted = spawn_server(&config);
    wait_for_listener(http, &mut restarted);
    wait_for_listener(ssh, &mut restarted);
    assert_eq!(
        fs::read(instance.path().join("ssh_host_ed25519_key"))
            .expect("read the reused SSH host key"),
        host_key
    );
    restarted.terminate();
}

#[test]
fn keeps_private_git_hidden_from_http_but_allows_its_owner_over_ssh() {
    let _server_test = server_test_lock();
    let instance = TempDir::new().expect("create an instance directory");
    let http = free_address();
    let ssh = free_address();
    let config = instance.path().join("config.toml");
    fs::write(
        &config,
        format!(
            "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
            ssh.port()
        ),
    )
    .expect("write the server configuration");
    let private_key = instance.path().join("administrator");
    create_ssh_key_fixture(&private_key);
    let public_key = fs::read_to_string(private_key.with_extension("pub"))
        .expect("read the administrator public key");
    let config_text = config.to_str().expect("a UTF-8 configuration path");
    command(
        instance.path(),
        [
            "--config",
            config_text,
            "setup",
            "admin",
            "alice",
            public_key.trim(),
        ],
    );
    let source = create_source_repository(instance.path());
    command(
        instance.path(),
        [
            "--config",
            config_text,
            "admin",
            "repository",
            "import",
            "alice",
            "private",
            source.to_str().expect("a UTF-8 source path"),
        ],
    );
    command(
        instance.path(),
        [
            "--config",
            config_text,
            "admin",
            "repository",
            "visibility",
            "alice",
            "private",
            "private",
        ],
    );
    let mut server = spawn_server(&config);
    wait_for_listener(http, &mut server);
    wait_for_listener(ssh, &mut server);
    let discovery = http_get(http, "/alice/private.git/info/refs?service=git-upload-pack");
    assert!(discovery.starts_with("HTTP/1.1 404"), "{discovery}");
    let home = http_get(http, "/");
    assert!(home.starts_with("HTTP/1.1 200"));
    assert!(!home.contains("/alice/private"));
    for route in [
        "/alice/private",
        "/alice/private/refs",
        "/alice/private/rss.xml",
        "/alice/private/search?q=serve&ref=HEAD",
        "/alice/private/commit/main",
        "/alice/private/diff/main/main",
        "/alice/private/tree/main",
        "/alice/private/tree/main/nested",
        "/alice/private/blob/main/README.md",
        "/alice/private/raw/main/README.md",
        "/alice/private/blame/main/README.md",
        "/alice/private/archive/main.tar",
    ] {
        let response = http_get(http, route);
        assert!(
            response.starts_with("HTTP/1.1 404"),
            "route leaked: {route}"
        );
        assert!(!response.contains("serve fixture"), "route leaked: {route}");
    }

    let ssh_command = format!(
        "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
        private_key.display()
    );
    let ssh_discovery = Command::new("git")
        .args([
            "ls-remote",
            &format!("ssh://ignored@127.0.0.1:{}/alice/private.git", ssh.port()),
        ])
        .env("GIT_SSH_COMMAND", ssh_command)
        .output()
        .expect("query the private repository through SSH");
    assert!(ssh_discovery.status.success());

    let unknown_key = instance.path().join("unknown");
    create_ssh_key_fixture(&unknown_key);
    assert!(!ssh_clone_repository_succeeds(
        ssh,
        &unknown_key,
        "alice",
        "private",
        &instance.path().join("unknown-clone")
    ));
    server.terminate();
}

#[test]
fn creates_owned_repositories_with_stable_ssh_command_output() {
    let _server_test = server_test_lock();
    let instance = TempDir::new().expect("create an instance directory");
    let http = free_address();
    let ssh = free_address();
    let config = instance.path().join("config.toml");
    fs::write(
        &config,
        format!(
            "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
            ssh.port()
        ),
    )
    .expect("write the server configuration");
    let private_key = instance.path().join("administrator");
    create_ssh_key_fixture(&private_key);
    let public_key = fs::read_to_string(private_key.with_extension("pub"))
        .expect("read the administrator public key");
    let config_text = config.to_str().expect("a UTF-8 configuration path");
    command(
        instance.path(),
        [
            "--config",
            config_text,
            "setup",
            "admin",
            "alice",
            public_key.trim(),
        ],
    );
    let member_key = provision_account(instance.path(), "bob", "active", false);

    let mut server = spawn_server(&config);
    wait_for_listener(http, &mut server);
    wait_for_listener(ssh, &mut server);

    let human = ssh_exec(ssh, &member_key, &["repo", "create", "example"]);
    assert!(human.status.success());
    assert_eq!(
        String::from_utf8(human.stdout).expect("read human command output"),
        "Created repository bob/example.\n"
    );
    assert!(human.stderr.is_empty());
    assert!(ssh_clone_repository_succeeds(
        ssh,
        &member_key,
        "bob",
        "example",
        &instance.path().join("created-clone")
    ));
    let created_clone = instance.path().join("created-clone");
    command(&created_clone, ["symbolic-ref", "HEAD", "refs/heads/main"]);
    fs::write(created_clone.join("README.md"), b"base\n").expect("write pull-request base");
    git_commit(&created_clone, "create main");
    assert!(git_push(&member_key, &created_clone, &["main"]).success());
    command(&created_clone, ["switch", "-q", "-c", "feature"]);
    fs::write(created_clone.join("feature.txt"), b"feature\n").expect("write pull-request feature");
    git_commit(&created_clone, "create feature");
    assert!(git_push(&member_key, &created_clone, &["feature"]).success());
    let base = git_revision(&created_clone, "main");
    let head = git_revision(&created_clone, "feature");
    command(&created_clone, ["switch", "-q", "main"]);
    command(&created_clone, ["switch", "-q", "-c", "feature-two"]);
    fs::write(created_clone.join("feature-two.txt"), b"second feature\n")
        .expect("write the second pull-request feature");
    git_commit(&created_clone, "create second feature");
    assert!(git_push(&member_key, &created_clone, &["feature-two"]).success());
    let pull_request_database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
        .expect("open the pull-request command database");
    let repository_id: String = pull_request_database
        .query_row(
            "SELECT id FROM repository WHERE slug = 'example'",
            [],
            |row| row.get(0),
        )
        .expect("read the pull-request repository ID");
    let bare = instance
        .path()
        .join("repositories")
        .join(format!("{repository_id}.git"));
    command(
        instance.path(),
        [
            "--git-dir",
            bare.to_str().expect("a UTF-8 bare path"),
            "update-ref",
            "refs/pull/1/head",
            &head,
        ],
    );
    pull_request_database
        .execute(
            "INSERT INTO pull_request
             (id, repository_id, number, title, body, state, author_account_id,
              base_ref, head_ref, base_object_id, head_object_id, created_at, updated_at)
             SELECT '11111111111111111111111111111111', ?1, 1, 'Feature', '', 'open',
                    account.id, 'refs/heads/main', 'refs/heads/feature', ?2, ?3, 10, 10
             FROM account WHERE username = 'bob'",
            rusqlite::params![repository_id, base, head],
        )
        .expect("create a pull-request command fixture");
    pull_request_database
        .execute(
            "INSERT INTO pull_request_revision
             (id, pull_request_id, number, author_account_id, base_object_id,
              head_object_id, created_at)
             SELECT '22222222222222222222222222222222',
                    '11111111111111111111111111111111', 1, account.id, ?1, ?2, 10
             FROM account WHERE username = 'bob'",
            rusqlite::params![base, head],
        )
        .expect("create a pull-request revision fixture");
    pull_request_database
        .execute(
            "INSERT INTO repository_counter (repository_id, next_pull_request_number)
             VALUES (?1, 2)
             ON CONFLICT (repository_id)
             DO UPDATE SET next_pull_request_number = 2",
            [&repository_id],
        )
        .expect("advance the pull-request number fixture");
    drop(pull_request_database);

    let human_checkout = ssh_exec(ssh, &member_key, &["pr", "checkout", "bob/example", "1"]);
    assert!(human_checkout.status.success());
    assert_eq!(
        String::from_utf8(human_checkout.stdout).expect("read human checkout output"),
        "git fetch origin refs/pull/1/head:refs/heads/pr-1\ngit checkout pr-1\n"
    );
    assert!(human_checkout.stderr.is_empty());
    let machine_checkout = ssh_exec(
        ssh,
        &member_key,
        &["pr", "checkout", "bob/example", "1", "--output", "json"],
    );
    assert!(machine_checkout.status.success());
    let machine_checkout: serde_json::Value =
        serde_json::from_slice(&machine_checkout.stdout).expect("parse machine checkout output");
    assert_eq!(machine_checkout["version"], 1);
    assert_eq!(machine_checkout["status"], "success");
    assert_eq!(machine_checkout["repository"]["owner"], "bob");
    assert_eq!(machine_checkout["repository"]["name"], "example");
    assert_eq!(machine_checkout["pull_request"]["number"], 1);
    assert_eq!(machine_checkout["pull_request"]["ref"], "refs/pull/1/head");
    assert_eq!(
        machine_checkout["commands"]["fetch"],
        "git fetch origin refs/pull/1/head:refs/heads/pr-1"
    );
    let listed_pull_requests = ssh_exec(
        ssh,
        &member_key,
        &[
            "pr",
            "list",
            "bob/example",
            "--state",
            "open",
            "--output",
            "json",
        ],
    );
    assert!(listed_pull_requests.status.success());
    let listed_pull_requests: serde_json::Value =
        serde_json::from_slice(&listed_pull_requests.stdout).expect("parse pull-request list");
    assert_eq!(listed_pull_requests["version"], 1);
    assert_eq!(listed_pull_requests["pull_requests"][0]["number"], 1);
    let closed_pull_request = ssh_exec(ssh, &member_key, &["pr", "close", "bob/example", "1"]);
    assert!(closed_pull_request.status.success());
    assert_eq!(
        String::from_utf8(closed_pull_request.stdout).expect("read pull-request close output"),
        "Closed pull request bob/example#1.\n"
    );
    assert!(
        ssh_exec(ssh, &member_key, &["pr", "reopen", "bob/example", "1"])
            .status
            .success()
    );
    let created_pull_request = ssh_exec_with_input(
        ssh,
        &member_key,
        &[
            "pr",
            "create",
            "bob/example",
            "refs/heads/main",
            "refs/heads/feature-two",
            "--output",
            "json",
        ],
        b"Second feature\nOpened through SSH.\n",
    );
    assert!(
        created_pull_request.status.success(),
        "pr create failed: stdout={} stderr={}",
        String::from_utf8_lossy(&created_pull_request.stdout),
        String::from_utf8_lossy(&created_pull_request.stderr)
    );
    let created_pull_request: serde_json::Value =
        serde_json::from_slice(&created_pull_request.stdout)
            .expect("parse pull-request create output");
    assert_eq!(created_pull_request["version"], 1);
    assert_eq!(created_pull_request["pull_request"]["number"], 2);
    let checkout_clone = instance.path().join("pull-request-checkout");
    assert!(ssh_clone_repository_succeeds(
        ssh,
        &member_key,
        "bob",
        "example",
        &checkout_clone
    ));
    assert!(
        git_fetch_ref(
            &member_key,
            &checkout_clone,
            "refs/pull/1/head:refs/heads/pr-1"
        )
        .success()
    );
    command(&checkout_clone, ["checkout", "-q", "pr-1"]);
    assert_eq!(git_revision(&checkout_clone, "HEAD"), head);

    let machine = ssh_exec(
        ssh,
        &private_key,
        &["repo", "create", "hash-agile", "--output", "json"],
    );
    assert!(machine.status.success());
    assert_eq!(
        String::from_utf8(machine.stdout).expect("read machine command output"),
        "{\"version\":1,\"status\":\"success\",\"repository\":{\"owner\":\"alice\",\"name\":\"hash-agile\"}}\n"
    );
    assert!(machine.stderr.is_empty());

    let created_issue = ssh_exec_with_input(
        ssh,
        &member_key,
        &["issue", "create", "bob/example"],
        b"First issue\nBody with **Markdown**.\n",
    );
    assert!(created_issue.status.success());
    assert_eq!(
        String::from_utf8(created_issue.stdout).expect("read issue create output"),
        "Created issue bob/example#1.\n"
    );
    assert!(created_issue.stderr.is_empty());
    let human_issues = ssh_exec(ssh, &member_key, &["issue", "list", "bob/example"]);
    assert!(human_issues.status.success());
    assert_eq!(
        String::from_utf8(human_issues.stdout).expect("read human issue list"),
        "#1 open First issue\n"
    );
    let machine_issues = ssh_exec(
        ssh,
        &member_key,
        &["issue", "list", "bob/example", "--output", "json"],
    );
    assert!(machine_issues.status.success());
    let machine_issues: serde_json::Value =
        serde_json::from_slice(&machine_issues.stdout).expect("parse machine issue list");
    assert_eq!(machine_issues["version"], 1);
    assert_eq!(machine_issues["status"], "success");
    assert_eq!(machine_issues["repository"]["owner"], "bob");
    assert_eq!(machine_issues["repository"]["name"], "example");
    assert_eq!(machine_issues["issues"][0]["number"], 1);
    assert_eq!(machine_issues["issues"][0]["title"], "First issue");
    let issue_comment = ssh_exec_with_input(
        ssh,
        &member_key,
        &["issue", "comment", "bob/example", "1", "--output", "json"],
        b"A bounded SSH comment.",
    );
    assert!(issue_comment.status.success());
    let issue_comment: serde_json::Value =
        serde_json::from_slice(&issue_comment.stdout).expect("parse issue comment");
    assert_eq!(issue_comment["operation"], "commented");
    assert!(issue_comment["comment_id"].is_string());
    assert!(
        ssh_exec(ssh, &member_key, &["issue", "close", "bob/example", "1"])
            .status
            .success()
    );
    let reopened_issue = ssh_exec(
        ssh,
        &member_key,
        &["issue", "reopen", "bob/example", "1", "--output", "json"],
    );
    assert!(reopened_issue.status.success());
    assert_eq!(
        serde_json::from_slice::<serde_json::Value>(&reopened_issue.stdout)
            .expect("parse issue reopen")["operation"],
        "reopened"
    );

    let access_database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
        .expect("open the issue access database");
    access_database
        .execute_batch("UPDATE repository SET visibility = 'private' WHERE slug = 'example';")
        .expect("make the issue repository private");
    let hidden = ssh_exec(
        ssh,
        &private_key,
        &["issue", "list", "bob/example", "--output", "json"],
    );
    assert!(!hidden.status.success());
    assert_eq!(
        serde_json::from_slice::<serde_json::Value>(&hidden.stdout)
            .expect("parse hidden issue error")["error"]["code"],
        "repository-unavailable"
    );
    let hidden_checkout = ssh_exec(
        ssh,
        &private_key,
        &["pr", "checkout", "bob/example", "1", "--output", "json"],
    );
    assert!(!hidden_checkout.status.success());
    assert_eq!(
        serde_json::from_slice::<serde_json::Value>(&hidden_checkout.stdout)
            .expect("parse hidden pull-request error")["error"]["code"],
        "pull-request-unavailable"
    );
    access_database
        .execute(
            "INSERT INTO repository_collaborator
                 (repository_id, account_id, role, created_at)
             SELECT repository.id, account.id, 'reader', 10
             FROM repository, account
             WHERE repository.slug = 'example' AND account.username = 'alice'",
            [],
        )
        .expect("give the administrator reader access");
    drop(access_database);
    let denied_pull_request = ssh_exec(
        ssh,
        &private_key,
        &["pr", "close", "bob/example", "1", "--output", "json"],
    );
    assert!(!denied_pull_request.status.success());
    assert_eq!(
        serde_json::from_slice::<serde_json::Value>(&denied_pull_request.stdout)
            .expect("parse denied pull-request mutation")["error"]["code"],
        "permission-denied"
    );
    let reader_create = ssh_exec_with_input(
        ssh,
        &private_key,
        &["issue", "create", "bob/example", "--output", "json"],
        b"Reader issue\nCreated through the shared service.",
    );
    assert!(reader_create.status.success());
    let reader_create: serde_json::Value =
        serde_json::from_slice(&reader_create.stdout).expect("parse machine issue create");
    assert_eq!(reader_create["version"], 1);
    assert_eq!(reader_create["status"], "success");
    assert_eq!(reader_create["issue"]["number"], 2);
    assert_eq!(reader_create["issue"]["author"], "alice");

    let invalid_issue = ssh_exec_with_input(
        ssh,
        &private_key,
        &["issue", "create", "bob/example", "--output", "json"],
        b"\nbody without a title",
    );
    assert!(!invalid_issue.status.success());
    assert_eq!(
        serde_json::from_slice::<serde_json::Value>(&invalid_issue.stdout)
            .expect("parse invalid issue input")["error"]["code"],
        "invalid-input"
    );

    let duplicate = ssh_exec(
        ssh,
        &member_key,
        &["repo", "create", "example", "--output", "json"],
    );
    assert!(!duplicate.status.success());
    assert_eq!(
        String::from_utf8(duplicate.stdout).expect("read machine error output"),
        "{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"repository-exists\"}}\n"
    );
    assert!(duplicate.stderr.is_empty());

    let invalid = ssh_exec(ssh, &member_key, &["repo", "create", "../bad"]);
    assert!(!invalid.status.success());
    assert!(invalid.stdout.is_empty());
    assert_eq!(
        String::from_utf8(invalid.stderr).expect("read human error output"),
        "tit: The repository name is not valid.\n"
    );
    let malformed = ssh_exec(ssh, &member_key, &["repo", "create", "--output", "json"]);
    assert!(!malformed.status.success());
    assert_eq!(
        String::from_utf8(malformed.stdout).expect("read invalid command output"),
        "{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"invalid-command\"}}\n"
    );
    assert!(malformed.stderr.is_empty());

    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
        .expect("open the account database");
    database
        .execute(
            "UPDATE account SET state = 'suspended' WHERE username = 'bob'",
            [],
        )
        .expect("suspend the command account");
    drop(database);
    let suspended = ssh_exec(
        ssh,
        &member_key,
        &["repo", "create", "blocked", "--output", "json"],
    );
    assert!(!suspended.status.success());
    assert_eq!(
        String::from_utf8(suspended.stdout).expect("read suspended account output"),
        "{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"account-unavailable\"}}\n"
    );
    assert!(suspended.stderr.is_empty());
    let suspended_issues = ssh_exec(
        ssh,
        &member_key,
        &["issue", "list", "bob/example", "--output", "json"],
    );
    assert!(!suspended_issues.status.success());
    assert_eq!(
        serde_json::from_slice::<serde_json::Value>(&suspended_issues.stdout)
            .expect("parse suspended issue output")["error"]["code"],
        "repository-unavailable"
    );
    server.terminate();

    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
        .expect("open the repository database");
    let repositories: Vec<(String, String, String)> = database
        .prepare(
            "SELECT account.username, repository.slug, repository.object_format
             FROM repository JOIN account ON account.id = repository.owner_account_id
             ORDER BY repository.slug",
        )
        .expect("prepare the repository query")
        .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
        .expect("query created repositories")
        .collect::<Result<_, _>>()
        .expect("read created repositories");
    assert_eq!(
        repositories,
        vec![
            ("bob".to_owned(), "example".to_owned(), "sha1".to_owned()),
            (
                "alice".to_owned(),
                "hash-agile".to_owned(),
                "sha1".to_owned()
            ),
        ]
    );
    let issues: Vec<(i64, String, String, String)> = database
        .prepare(
            "SELECT issue.number, issue.title, issue.body, account.username
             FROM issue JOIN account ON account.id = issue.author_account_id
             ORDER BY issue.number",
        )
        .expect("prepare the issue query")
        .query_map([], |row| {
            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
        })
        .expect("query created issues")
        .collect::<Result<_, _>>()
        .expect("read created issues");
    assert_eq!(
        issues,
        vec![
            (
                1,
                "First issue".to_owned(),
                "Body with **Markdown**.\n".to_owned(),
                "bob".to_owned(),
            ),
            (
                2,
                "Reader issue".to_owned(),
                "Created through the shared service.".to_owned(),
                "alice".to_owned(),
            ),
        ]
    );
    let issue_events: i64 = database
        .query_row(
            "SELECT count(*) FROM repository_event WHERE kind = 'issue-created'",
            [],
            |row| row.get(0),
        )
        .expect("count issue create events");
    assert_eq!(issue_events, 2);
    let audit: Vec<(String, String)> = database
        .prepare(
            "SELECT target, outcome FROM audit_event
             WHERE action = 'repository.create' AND actor IN ('alice', 'bob')
             ORDER BY id",
        )
        .expect("prepare the repository audit query")
        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
        .expect("query repository audit events")
        .collect::<Result<_, _>>()
        .expect("read repository audit events");
    assert_eq!(
        audit,
        vec![
            ("bob/example".to_owned(), "success".to_owned()),
            ("alice/hash-agile".to_owned(), "success".to_owned()),
            ("bob/example".to_owned(), "failure".to_owned()),
            ("bob/blocked".to_owned(), "failure".to_owned()),
        ]
    );
}

#[test]
fn enforces_account_roles_and_ref_policy_through_the_production_ssh_server() {
    let _server_test = server_test_lock();
    let instance = TempDir::new().expect("create an instance directory");
    let http = free_address();
    let ssh = free_address();
    let config = instance.path().join("config.toml");
    fs::write(
        &config,
        format!(
            "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
            ssh.port()
        ),
    )
    .expect("write the server configuration");
    let config_text = config.to_str().expect("a UTF-8 configuration path");
    let owner_key = instance.path().join("owner");
    create_ssh_key_fixture(&owner_key);
    let owner_public =
        fs::read_to_string(owner_key.with_extension("pub")).expect("read the owner public key");
    command(
        instance.path(),
        [
            "--config",
            config_text,
            "setup",
            "admin",
            "alice",
            owner_public.trim(),
        ],
    );

    let source = create_source_repository(instance.path());
    for repository in ["private", "public"] {
        command(
            instance.path(),
            [
                "--config",
                config_text,
                "admin",
                "repository",
                "import",
                "alice",
                repository,
                source.to_str().expect("a UTF-8 source path"),
            ],
        );
    }
    command(
        instance.path(),
        [
            "--config",
            config_text,
            "admin",
            "repository",
            "visibility",
            "alice",
            "private",
            "private",
        ],
    );

    let maintainer_key = provision_account(instance.path(), "maintainer", "active", false);
    let writer_key = provision_account(instance.path(), "writer", "active", false);
    let reader_key = provision_account(instance.path(), "reader", "active", false);
    let outsider_key = provision_account(instance.path(), "outsider", "active", false);
    let suspended_key = provision_account(instance.path(), "suspended", "active", false);
    let revoked_key = provision_account(instance.path(), "revoked", "active", true);
    for (username, role) in [
        ("maintainer", "maintainer"),
        ("writer", "writer"),
        ("reader", "reader"),
        ("suspended", "writer"),
        ("revoked", "writer"),
    ] {
        command(
            instance.path(),
            [
                "--config",
                config_text,
                "admin",
                "repository",
                "collaborator-set",
                "alice",
                "private",
                username,
                role,
            ],
        );
    }
    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
        .expect("open the repository database");
    database
        .execute(
            "UPDATE account SET state = 'suspended' WHERE username = 'suspended'",
            [],
        )
        .expect("suspend an account fixture");
    drop(database);

    let mut server = spawn_server(&config);
    wait_for_listener(http, &mut server);
    wait_for_listener(ssh, &mut server);
    for (name, key) in [
        ("owner", &owner_key),
        ("maintainer", &maintainer_key),
        ("writer", &writer_key),
        ("reader", &reader_key),
    ] {
        assert!(ssh_clone_repository_succeeds(
            ssh,
            key,
            "alice",
            "private",
            &instance.path().join(format!("{name}-private"))
        ));
    }
    for (name, key) in [
        ("outsider", &outsider_key),
        ("suspended", &suspended_key),
        ("revoked", &revoked_key),
    ] {
        assert!(!ssh_clone_repository_succeeds(
            ssh,
            key,
            "alice",
            "private",
            &instance.path().join(format!("{name}-private"))
        ));
    }
    assert!(ssh_clone_repository_succeeds(
        ssh,
        &outsider_key,
        "alice",
        "public",
        &instance.path().join("outsider-public")
    ));

    let writer_clone = instance.path().join("writer-private");
    command(&writer_clone, ["switch", "-q", "-c", "topic"]);
    fs::write(writer_clone.join("writer.txt"), b"writer update\n").expect("write a writer change");
    git_commit(&writer_clone, "writer update");
    assert!(git_push(&writer_key, &writer_clone, &["topic"]).success());
    fs::write(writer_clone.join("writer-2.txt"), b"second writer update\n")
        .expect("write a second writer change");
    git_commit(&writer_clone, "second writer update");
    assert!(git_push(&writer_key, &writer_clone, &["topic"]).success());
    assert!(git_push(&writer_key, &writer_clone, &["--delete", "topic"]).success());
    assert!(!git_push(&writer_key, &writer_clone, &["HEAD:trunk"]).success());
    assert!(!git_push(&writer_key, &writer_clone, &["HEAD:refs/notes/test"]).success());

    let owner_clone = instance.path().join("owner-private");
    fs::write(owner_clone.join("owner.txt"), b"owner update\n").expect("write an owner change");
    git_commit(&owner_clone, "owner update");
    assert!(git_push(&owner_key, &owner_clone, &["trunk"]).success());
    assert!(!git_push(&owner_key, &owner_clone, &["--delete", "trunk"]).success());
    command(&owner_clone, ["switch", "-q", "-c", "force-test"]);
    fs::write(owner_clone.join("force.txt"), b"first history\n").expect("write a branch change");
    git_commit(&owner_clone, "first branch history");
    assert!(git_push(&owner_key, &owner_clone, &["force-test"]).success());
    command(&owner_clone, ["reset", "--hard", "HEAD~1"]);
    fs::write(owner_clone.join("force.txt"), b"replacement history\n")
        .expect("write replacement history");
    git_commit(&owner_clone, "replacement branch history");
    let force_result = git_push_output(&owner_key, &owner_clone, &["--force", "force-test"]);
    assert!(!force_result.status.success());
    assert!(String::from_utf8_lossy(&force_result.stderr).contains("non-fast-forward"));

    let reader_clone = instance.path().join("reader-write-private");
    assert!(ssh_clone_repository_succeeds(
        ssh,
        &reader_key,
        "alice",
        "private",
        &reader_clone
    ));
    fs::write(reader_clone.join("reader.txt"), b"reader update\n").expect("write a reader change");
    git_commit(&reader_clone, "reader update");
    assert!(!git_push(&reader_key, &reader_clone, &["main"]).success());

    command(&writer_clone, ["switch", "-q", "main"]);
    fs::write(writer_clone.join("removed-role.txt"), b"removed role\n")
        .expect("write a change before role removal");
    git_commit(&writer_clone, "change before role removal");
    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
        .expect("open the repository database");
    database
        .execute(
            "DELETE FROM repository_collaborator
             WHERE account_id = (SELECT id FROM account WHERE username = 'writer')",
            [],
        )
        .expect("remove the writer role");
    drop(database);
    assert!(
        !git_push(
            &writer_key,
            &writer_clone,
            &["HEAD:refs/heads/removed-role"]
        )
        .success()
    );
    server.terminate();
    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
        .expect("open the push audit database");
    let successful: i64 = database
        .query_row(
            "SELECT count(*) FROM audit_event
             WHERE action = 'ref.update' AND actor = 'writer' AND outcome = 'success'",
            [],
            |row| row.get(0),
        )
        .expect("count successful push audit events");
    let failed: i64 = database
        .query_row(
            "SELECT count(*) FROM audit_event
             WHERE action = 'ref.update' AND actor = 'writer' AND outcome = 'failure'",
            [],
            |row| row.get(0),
        )
        .expect("count failed push audit events");
    assert_eq!(successful, 3);
    assert!(failed >= 2);
}

fn spawn_server(config: &Path) -> ChildGuard {
    let child = Command::new(tit_binary())
        .args([
            "--config",
            config.to_str().expect("a UTF-8 configuration path"),
            "serve",
        ])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("start the tit server");
    ChildGuard(Some(child))
}

fn create_source_repository(parent: &Path) -> std::path::PathBuf {
    let worktree = parent.join("source-worktree");
    command(
        parent,
        [
            "init",
            "-q",
            "-b",
            "main",
            worktree.to_str().expect("a UTF-8 worktree path"),
        ],
    );
    fs::write(worktree.join("README.md"), b"serve fixture\n").expect("write source content");
    command(&worktree, ["add", "."]);
    let output = Command::new("git")
        .args(["commit", "-q", "-m", "initial"])
        .env("GIT_AUTHOR_NAME", "Tit Test")
        .env("GIT_AUTHOR_EMAIL", "tit@example.test")
        .env("GIT_COMMITTER_NAME", "Tit Test")
        .env("GIT_COMMITTER_EMAIL", "tit@example.test")
        .env("GIT_CONFIG_COUNT", "1")
        .env("GIT_CONFIG_KEY_0", "commit.gpgsign")
        .env("GIT_CONFIG_VALUE_0", "false")
        .current_dir(&worktree)
        .output()
        .expect("commit source content");
    assert!(output.status.success(), "Git commit failed");
    command(&worktree, ["branch", "trunk"]);
    command(&worktree, ["switch", "-q", "trunk"]);
    fs::write(worktree.join("TRUNK.md"), b"trunk default\n").expect("write trunk content");
    git_commit(&worktree, "trunk head");
    let bare = parent.join("source.git");
    command(
        parent,
        [
            "clone",
            "-q",
            "--bare",
            worktree.to_str().expect("a UTF-8 worktree path"),
            bare.to_str().expect("a UTF-8 bare path"),
        ],
    );
    bare
}

fn command<const N: usize>(directory: &Path, arguments: [&str; N]) {
    let executable = if matches!(arguments.first(), Some(&"--config")) {
        tit_binary()
    } else {
        "git".into()
    };
    let output = Command::new(executable)
        .args(arguments)
        .current_dir(directory)
        .output()
        .expect("run a fixture command");
    assert!(
        output.status.success(),
        "fixture command failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
}

fn tit_binary() -> std::path::PathBuf {
    env::var_os("TIT_RELEASE_BINARY")
        .map(Into::into)
        .unwrap_or_else(|| env!("CARGO_BIN_EXE_tit").into())
}

fn wait_for_listener(address: SocketAddr, server: &mut ChildGuard) {
    let deadline = Instant::now() + Duration::from_secs(10);
    loop {
        if TcpStream::connect(address).is_ok() {
            return;
        }
        if let Some(status) = server
            .0
            .as_mut()
            .expect("a running server")
            .try_wait()
            .expect("check the server")
        {
            let mut stderr = String::new();
            if let Some(pipe) = server.0.as_mut().expect("a stopped server").stderr.as_mut() {
                pipe.read_to_string(&mut stderr)
                    .expect("read the stopped server error output");
            }
            panic!("tit serve stopped early with {status}: {stderr}");
        }
        assert!(
            Instant::now() < deadline,
            "listener {address} did not start"
        );
        thread::sleep(Duration::from_millis(20));
    }
}

fn http_get(address: SocketAddr, path: &str) -> String {
    http_get_with_headers(address, path, &[])
}

fn http_get_with_headers(address: SocketAddr, path: &str, headers: &[(&str, &str)]) -> String {
    let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
    let mut request = format!("GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n");
    for (name, value) in headers {
        request.push_str(&format!("{name}: {value}\r\n"));
    }
    request.push_str("\r\n");
    stream
        .write_all(request.as_bytes())
        .expect("write an HTTP request");
    let mut response = String::new();
    stream
        .read_to_string(&mut response)
        .expect("read an HTTP response");
    response
}

fn http_form(address: SocketAddr, path: &str, fields: &[(&str, &str)]) -> String {
    http_form_with_headers(address, path, fields, &[])
}

fn http_form_with_headers(
    address: SocketAddr,
    path: &str,
    fields: &[(&str, &str)],
    headers: &[(&str, &str)],
) -> String {
    let body = url::form_urlencoded::Serializer::new(String::new())
        .extend_pairs(fields.iter().copied())
        .finish();
    let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
    let mut request = format!(
        "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n",
        body.len()
    );
    for (name, value) in headers {
        request.push_str(&format!("{name}: {value}\r\n"));
    }
    request.push_str("\r\n");
    request.push_str(&body);
    stream
        .write_all(request.as_bytes())
        .expect("write an HTTP form");
    let mut response = String::new();
    stream
        .read_to_string(&mut response)
        .expect("read an HTTP response");
    response
}

fn response_cookies(response: &str) -> String {
    response
        .lines()
        .filter_map(|line| line.split_once(':'))
        .filter(|(name, _)| name.eq_ignore_ascii_case("set-cookie"))
        .map(|(_, value)| {
            value
                .trim()
                .split_once(';')
                .map_or(value.trim(), |(cookie, _)| cookie)
        })
        .collect::<Vec<_>>()
        .join("; ")
}

fn response_header<'a>(response: &'a str, name: &str) -> &'a str {
    response
        .lines()
        .filter_map(|line| line.split_once(':'))
        .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
        .map(|(_, value)| value.trim())
        .expect("read a response header")
}

fn http_multipart(
    address: SocketAddr,
    path: &str,
    fields: &[(&str, &str)],
    headers: &[(&str, &str)],
) -> String {
    let boundary = "tit-test-boundary";
    let mut body = String::new();
    for (name, value) in fields {
        if *name == "signature-file" {
            body.push_str(&format!(
                "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"signature.sig\"\r\nContent-Type: application/octet-stream\r\n\r\n{value}\r\n"
            ));
        } else {
            body.push_str(&format!(
                "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n"
            ));
        }
    }
    body.push_str(&format!("--{boundary}--\r\n"));
    let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
    let mut request = format!(
        "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: multipart/form-data; boundary={boundary}\r\nContent-Length: {}\r\nConnection: close\r\n",
        body.len()
    );
    for (name, value) in headers {
        request.push_str(&format!("{name}: {value}\r\n"));
    }
    request.push_str("\r\n");
    request.push_str(&body);
    stream
        .write_all(request.as_bytes())
        .expect("write a multipart HTTP form");
    let mut response = String::new();
    stream
        .read_to_string(&mut response)
        .expect("read an HTTP response");
    response
}

fn http_body(
    address: SocketAddr,
    path: &str,
    content_type: &str,
    body: &str,
    headers: &[(&str, &str)],
) -> String {
    let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
    let mut request = format!(
        "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n",
        body.len()
    );
    for (name, value) in headers {
        request.push_str(&format!("{name}: {value}\r\n"));
    }
    request.push_str("\r\n");
    request.push_str(body);
    stream
        .write_all(request.as_bytes())
        .expect("write an HTTP request body");
    let mut response = String::new();
    stream
        .read_to_string(&mut response)
        .expect("read an HTTP response");
    response
}

fn cookie_value<'a>(cookies: &'a str, name: &str) -> &'a str {
    cookies
        .split("; ")
        .find_map(|cookie| cookie.strip_prefix(&format!("{name}=")))
        .expect("find the cookie")
}

fn sign_challenge(directory: &Path, private_key: &Path, challenge: &str) -> String {
    let nonce = challenge
        .lines()
        .find_map(|line| line.strip_prefix("nonce="))
        .expect("find the Web login nonce");
    let path = directory.join(format!("web-login-{nonce}.challenge"));
    fs::write(&path, challenge).expect("write the Web login challenge");
    let output = Command::new("ssh-keygen")
        .args(["-q", "-Y", "sign", "-f"])
        .arg(private_key)
        .args(["-n", "tit-auth"])
        .arg(&path)
        .output()
        .expect("sign the Web login challenge");
    assert!(
        output.status.success(),
        "cannot sign the Web login challenge: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    fs::read_to_string(path.with_extension("challenge.sig")).expect("read the Web login signature")
}

fn between<'a>(value: &'a str, start: &str, end: &str) -> &'a str {
    value
        .split_once(start)
        .and_then(|(_, tail)| tail.split_once(end))
        .map(|(value, _)| value)
        .expect("find the response value")
}

fn ssh_clone_succeeds(address: SocketAddr, private_key: &Path, target: &Path) -> bool {
    ssh_clone_repository_succeeds(address, private_key, "alice", "example", target)
}

fn ssh_clone_repository_succeeds(
    address: SocketAddr,
    private_key: &Path,
    owner: &str,
    repository: &str,
    target: &Path,
) -> bool {
    let ssh_command = format!(
        "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
        private_key.display()
    );
    Command::new("git")
        .args([
            "clone",
            "-q",
            &format!(
                "ssh://ignored@127.0.0.1:{}/{owner}/{repository}.git",
                address.port(),
            ),
        ])
        .arg(target)
        .env("GIT_SSH_COMMAND", ssh_command)
        .output()
        .expect("clone through the tit SSH server")
        .status
        .success()
}

fn ssh_exec(address: SocketAddr, private_key: &Path, command: &[&str]) -> Output {
    Command::new("ssh")
        .args([
            "-F",
            "/dev/null",
            "-o",
            "BatchMode=yes",
            "-o",
            "IdentitiesOnly=yes",
            "-o",
            "StrictHostKeyChecking=no",
            "-o",
            "UserKnownHostsFile=/dev/null",
            "-o",
            "LogLevel=ERROR",
            "-i",
        ])
        .arg(private_key)
        .args(["-p", &address.port().to_string()])
        .arg(format!("ignored@{}", address.ip()))
        .args(command)
        .output()
        .expect("run an SSH repository command")
}

fn ssh_exec_with_input(
    address: SocketAddr,
    private_key: &Path,
    command: &[&str],
    input: &[u8],
) -> Output {
    let mut child = Command::new("ssh")
        .args([
            "-F",
            "/dev/null",
            "-o",
            "BatchMode=yes",
            "-o",
            "IdentitiesOnly=yes",
            "-o",
            "StrictHostKeyChecking=no",
            "-o",
            "UserKnownHostsFile=/dev/null",
            "-o",
            "LogLevel=ERROR",
            "-i",
        ])
        .arg(private_key)
        .args(["-p", &address.port().to_string()])
        .arg(format!("ignored@{}", address.ip()))
        .args(command)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("start an SSH issue command");
    child
        .stdin
        .take()
        .expect("open SSH issue input")
        .write_all(input)
        .expect("write SSH issue input");
    child
        .wait_with_output()
        .expect("finish an SSH issue command")
}

fn provision_account(
    instance: &Path,
    username: &str,
    state: &str,
    revoked: bool,
) -> std::path::PathBuf {
    let private_key = instance.join(username);
    create_ssh_key_fixture(&private_key);
    let public_key =
        fs::read_to_string(private_key.with_extension("pub")).expect("read an account public key");
    let mut fields = public_key.split_whitespace();
    let canonical = format!(
        "{} {}",
        fields.next().expect("read the key algorithm"),
        fields.next().expect("read the key data")
    );
    let fingerprint_output = Command::new("ssh-keygen")
        .args(["-E", "sha256", "-lf"])
        .arg(private_key.with_extension("pub"))
        .output()
        .expect("read an SSH key fingerprint");
    assert!(fingerprint_output.status.success());
    let fingerprint_text =
        String::from_utf8(fingerprint_output.stdout).expect("read a UTF-8 SSH key fingerprint");
    let fingerprint = fingerprint_text
        .split_whitespace()
        .nth(1)
        .expect("read the SSH key fingerprint");
    let database = rusqlite::Connection::open(instance.join("tit.sqlite3"))
        .expect("open the repository database");
    database
        .execute(
            "INSERT INTO account (username, is_administrator, state, created_at)
             VALUES (?1, 0, ?2, 1)",
            rusqlite::params![username, state],
        )
        .expect("create an account fixture");
    let account_id = database.last_insert_rowid();
    database
        .execute(
            "INSERT INTO ssh_public_key
             (account_id, canonical_key, fingerprint, created_at, label, revoked_at)
             VALUES (?1, ?2, ?3, 1, 'initial', ?4)",
            rusqlite::params![account_id, canonical, fingerprint, revoked.then_some(2)],
        )
        .expect("create an SSH key fixture");
    private_key
}

fn ssh_fingerprint(private_key: &Path) -> String {
    let output = Command::new("ssh-keygen")
        .args(["-E", "sha256", "-lf"])
        .arg(private_key.with_extension("pub"))
        .output()
        .expect("read an SSH key fingerprint");
    assert!(output.status.success());
    String::from_utf8(output.stdout)
        .expect("read a UTF-8 SSH key fingerprint")
        .split_whitespace()
        .nth(1)
        .expect("read the SSH key fingerprint")
        .to_owned()
}

fn git_commit(worktree: &Path, message: &str) {
    command(worktree, ["add", "."]);
    let output = Command::new("git")
        .args(["commit", "-q", "-m", message])
        .env("GIT_AUTHOR_NAME", "Tit Test")
        .env("GIT_AUTHOR_EMAIL", "tit@example.test")
        .env("GIT_COMMITTER_NAME", "Tit Test")
        .env("GIT_COMMITTER_EMAIL", "tit@example.test")
        .env("GIT_CONFIG_COUNT", "1")
        .env("GIT_CONFIG_KEY_0", "commit.gpgsign")
        .env("GIT_CONFIG_VALUE_0", "false")
        .current_dir(worktree)
        .output()
        .expect("commit a Git change");
    assert!(
        output.status.success(),
        "Git commit failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
}

fn git_push(private_key: &Path, worktree: &Path, refspecs: &[&str]) -> ExitStatus {
    git_push_output(private_key, worktree, refspecs).status
}

fn git_push_output(private_key: &Path, worktree: &Path, refspecs: &[&str]) -> Output {
    let ssh_command = format!(
        "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
        private_key.display()
    );
    Command::new("git")
        .arg("push")
        .arg("origin")
        .args(refspecs)
        .env("GIT_SSH_COMMAND", ssh_command)
        .current_dir(worktree)
        .output()
        .expect("push through the tit SSH server")
}

fn git_fetch_ref(private_key: &Path, worktree: &Path, refspec: &str) -> ExitStatus {
    let ssh_command = format!(
        "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
        private_key.display()
    );
    Command::new("git")
        .args(["fetch", "origin", refspec])
        .env("GIT_SSH_COMMAND", ssh_command)
        .current_dir(worktree)
        .status()
        .expect("fetch through the tit SSH server")
}

fn git_revision(worktree: &Path, revision: &str) -> String {
    let output = Command::new("git")
        .args(["rev-parse", revision])
        .current_dir(worktree)
        .output()
        .expect("resolve a Git revision");
    assert!(
        output.status.success(),
        "cannot resolve Git revision: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8(output.stdout)
        .expect("read a Git revision")
        .trim()
        .to_owned()
}

struct ChildGuard(Option<Child>);

impl ChildGuard {
    fn terminate_capture(&mut self) -> Vec<u8> {
        let child = self.0.take().expect("the server process is active");
        let signal = Command::new("kill")
            .args(["-TERM", &child.id().to_string()])
            .output()
            .expect("send SIGTERM to the tit server");
        assert!(signal.status.success(), "cannot send SIGTERM");
        let output = child
            .wait_with_output()
            .expect("wait for the tit server and read its output");
        assert!(
            output.status.success(),
            "tit serve did not stop cleanly: {}",
            output.status
        );
        output.stderr
    }

    fn terminate(&mut self) {
        if let Some(mut child) = self.0.take() {
            let signal = Command::new("kill")
                .args(["-TERM", &child.id().to_string()])
                .output()
                .expect("send SIGTERM to the tit server");
            assert!(signal.status.success(), "cannot send SIGTERM");
            let status = child.wait().expect("wait for the tit server");
            assert!(status.success(), "tit serve did not stop cleanly: {status}");
        }
    }

    fn stop(&mut self) {
        if let Some(mut child) = self.0.take() {
            child.kill().expect("stop the tit server");
            child.wait().expect("wait for the tit server");
        }
    }
}

impl Drop for ChildGuard {
    fn drop(&mut self) {
        self.stop();
    }
}