a73x

tests/common/mod.rs

Ref:   Size: 24.0 KiB

#![allow(dead_code)]

use std::env;
use std::ffi::OsString;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::thread;
use std::time::{Duration, Instant};

use ed25519_dalek::SigningKey;
use git2::Repository;
use rand_core::OsRng;
use tempfile::TempDir;

use git_collab::dag;
use git_collab::event::{Action, Author, Event, ReviewVerdict};
use git_collab::signing;

// ===========================================================================
// Library-level helpers (for collab_test / sync_test)
// ===========================================================================

pub fn alice() -> Author {
    Author {
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
    }
}

pub fn bob() -> Author {
    Author {
        name: "Bob".to_string(),
        email: "bob@example.com".to_string(),
    }
}

pub fn now() -> String {
    chrono::Utc::now().to_rfc3339()
}

/// Generate a test signing key and return it. Does NOT write to disk.
pub fn test_signing_key() -> SigningKey {
    SigningKey::generate(&mut OsRng)
}

/// Generate a test signing key and write it to a config dir so that
/// code using `signing::load_signing_key()` can find it.
pub fn setup_signing_key(config_dir: &Path) {
    git_collab::signing::generate_keypair(config_dir).expect("generate test keypair");
}

fn test_env_lock() -> &'static Mutex<()> {
    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(()))
}

fn set_or_remove_env(key: &str, value: Option<OsString>) {
    match value {
        Some(value) => env::set_var(key, value),
        None => env::remove_var(key),
    }
}

pub struct TestHome {
    root: TempDir,
    home_dir: PathBuf,
    xdg_config_home: PathBuf,
    git_config_global: PathBuf,
}

impl TestHome {
    pub fn new() -> Self {
        let root = TempDir::new().unwrap();
        let home_dir = root.path().join("home");
        let xdg_config_home = root.path().join("xdg-config");
        std::fs::create_dir_all(&home_dir).unwrap();
        std::fs::create_dir_all(&xdg_config_home).unwrap();

        let git_config_global = root.path().join("gitconfig");
        std::fs::write(&git_config_global, "").unwrap();

        Self {
            root,
            home_dir,
            xdg_config_home,
            git_config_global,
        }
    }

    pub fn home_dir(&self) -> &Path {
        &self.home_dir
    }

    pub fn config_home(&self) -> &Path {
        &self.xdg_config_home
    }

    pub fn collab_config_dir(&self) -> PathBuf {
        self.xdg_config_home.join("git-collab")
    }

    pub fn ensure_signing_key(&self) {
        let config_dir = self.collab_config_dir();
        if !config_dir.join("signing-key").exists() {
            setup_signing_key(&config_dir);
        }
    }

    pub fn apply_to_command<'a>(&self, cmd: &'a mut Command) -> &'a mut Command {
        let _ = &self.root;
        cmd.env("HOME", &self.home_dir)
            .env("XDG_CONFIG_HOME", &self.xdg_config_home)
            .env("GIT_CONFIG_GLOBAL", &self.git_config_global)
            .env("GIT_CONFIG_NOSYSTEM", "1")
    }

    fn apply_to_process(&self) {
        let _ = &self.root;
        env::set_var("HOME", &self.home_dir);
        env::set_var("XDG_CONFIG_HOME", &self.xdg_config_home);
        env::set_var("GIT_CONFIG_GLOBAL", &self.git_config_global);
        env::set_var("GIT_CONFIG_NOSYSTEM", "1");
    }
}

pub struct ScopedTestConfig {
    _lock: MutexGuard<'static, ()>,
    env: TestHome,
    old_home: Option<OsString>,
    old_xdg_config_home: Option<OsString>,
    old_git_config_global: Option<OsString>,
    old_git_config_nosystem: Option<OsString>,
}

impl ScopedTestConfig {
    pub fn new() -> Self {
        let lock = test_env_lock().lock().unwrap();
        let test_home = TestHome::new();
        let old_home = env::var_os("HOME");
        let old_xdg_config_home = env::var_os("XDG_CONFIG_HOME");
        let old_git_config_global = env::var_os("GIT_CONFIG_GLOBAL");
        let old_git_config_nosystem = env::var_os("GIT_CONFIG_NOSYSTEM");
        test_home.apply_to_process();

        Self {
            _lock: lock,
            env: test_home,
            old_home,
            old_xdg_config_home,
            old_git_config_global,
            old_git_config_nosystem,
        }
    }

    pub fn config_dir(&self) -> PathBuf {
        self.env.collab_config_dir()
    }

    pub fn ensure_signing_key(&self) {
        self.env.ensure_signing_key();
    }
}

impl Drop for ScopedTestConfig {
    fn drop(&mut self) {
        set_or_remove_env("HOME", self.old_home.take());
        set_or_remove_env("XDG_CONFIG_HOME", self.old_xdg_config_home.take());
        set_or_remove_env("GIT_CONFIG_GLOBAL", self.old_git_config_global.take());
        set_or_remove_env("GIT_CONFIG_NOSYSTEM", self.old_git_config_nosystem.take());
    }
}

/// Create a non-bare repo in a directory with user identity configured
/// and an initial empty commit on `main`.
pub fn init_repo(dir: &Path, author: &Author) -> Repository {
    let repo = Repository::init(dir).expect("init repo");
    // Ensure HEAD points to main regardless of the system's default branch name
    repo.set_head("refs/heads/main").expect("set HEAD to main");
    {
        let mut config = repo.config().unwrap();
        config.set_str("user.name", &author.name).unwrap();
        config.set_str("user.email", &author.email).unwrap();
    }
    // Create initial empty commit so that HEAD and refs/heads/main exist
    {
        let sig = git2::Signature::now(&author.name, &author.email).unwrap();
        let tree_oid = repo.treebuilder(None).unwrap().write().unwrap();
        let tree = repo.find_tree(tree_oid).unwrap();
        repo.commit(Some("refs/heads/main"), &sig, &sig, "initial", &tree, &[]).unwrap();
    }
    repo
}

/// Open an issue using DAG primitives. Returns (ref_name, id).
pub fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, String) {
    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueOpen {
            title: title.to_string(),
            body: "".to_string(),
            relates_to: None,
        },
        clock: 0,
    };
    let oid = dag::create_root_event(repo, &event, &sk).unwrap();
    let id = oid.to_string();
    let ref_name = format!("refs/collab/issues/{}", id);
    repo.reference(&ref_name, oid, false, "test open").unwrap();
    (ref_name, id)
}

/// Append a comment event to an issue ref.
pub fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &str) {
    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueComment {
            body: body.to_string(),
        },
        clock: 0,
    };
    dag::append_event(repo, ref_name, &event, &sk).unwrap();
}

/// Append an IssueCommitLink event to an issue ref. Returns the new DAG tip OID.
pub fn add_commit_link(
    repo: &Repository,
    ref_name: &str,
    author: &Author,
    commit_sha: &str,
) -> git2::Oid {
    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueCommitLink {
            commit: commit_sha.to_string(),
        },
        clock: 0,
    };
    dag::append_event(repo, ref_name, &event, &sk).unwrap()
}

/// Append a close event to an issue ref.
pub fn close_issue(repo: &Repository, ref_name: &str, author: &Author) {
    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueClose { reason: None },
        clock: 0,
    };
    dag::append_event(repo, ref_name, &event, &sk).unwrap();
}

/// Append a reopen event to an issue ref.
pub fn reopen_issue(repo: &Repository, ref_name: &str, author: &Author) {
    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueReopen,
        clock: 0,
    };
    dag::append_event(repo, ref_name, &event, &sk).unwrap();
}

/// Create a patch using DAG primitives. Returns (ref_name, id).
pub fn create_patch(repo: &Repository, author: &Author, title: &str) -> (String, String) {
    let sk = test_signing_key();
    // Get branch tip for commit/tree fields
    let head = repo.head().unwrap();
    let commit_oid = head.target().unwrap();
    let commit = repo.find_commit(commit_oid).unwrap();
    let tree_oid = commit.tree().unwrap().id();
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::PatchCreate {
            title: title.to_string(),
            body: "".to_string(),
            base_ref: "main".to_string(),
            branch: "test-branch".to_string(),
            fixes: None,
            commit: commit_oid.to_string(),
            tree: tree_oid.to_string(),
            base_commit: None,
        },
        clock: 0,
    };
    let oid = dag::create_root_event(repo, &event, &sk).unwrap();
    let id = oid.to_string();
    let ref_name = format!("refs/collab/patches/{}", id);
    repo.reference(&ref_name, oid, false, "test patch").unwrap();
    (ref_name, id)
}

/// Append a review event to a patch ref.
pub fn add_review(repo: &Repository, ref_name: &str, author: &Author, verdict: ReviewVerdict) {
    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::PatchReview {
            verdict,
            body: "review comment".to_string(),
            revision: 1,
        },
        clock: 0,
    };
    dag::append_event(repo, ref_name, &event, &sk).unwrap();
}

// ===========================================================================
// CLI-level helpers (for cli_test)
// ===========================================================================

/// A temporary git repository for end-to-end CLI testing.
pub struct TestRepo {
    pub dir: TempDir,
    env: TestHome,
}

impl TestRepo {
    /// Create a new repo with user identity and an initial empty commit on `main`.
    /// Also ensures a signing key exists in an isolated temp config dir.
    pub fn new(name: &str, email: &str) -> Self {
        let dir = TempDir::new().unwrap();
        let env = TestHome::new();
        git_with_env(dir.path(), &["init", "-b", "main"], &env);
        git_with_env(dir.path(), &["config", "user.name", name], &env);
        git_with_env(dir.path(), &["config", "user.email", email], &env);
        // Disable auto-sync for tests so CLI output isn't polluted by sync attempts
        git_with_env(dir.path(), &["config", "collab.autoSync", "false"], &env);
        git_with_env(dir.path(), &["commit", "--allow-empty", "-m", "initial"], &env);
        env.ensure_signing_key();

        TestRepo { dir, env }
    }

    pub fn home_dir(&self) -> &Path {
        self.env.home_dir()
    }

    pub fn config_dir(&self) -> PathBuf {
        self.env.collab_config_dir()
    }

    pub fn apply_env<'a>(&self, cmd: &'a mut Command) -> &'a mut Command {
        self.env.apply_to_command(cmd)
    }

    pub fn cli_command(&self) -> Command {
        let mut cmd = Command::new(env!("CARGO_BIN_EXE_git-collab"));
        self.apply_env(&mut cmd);
        cmd.current_dir(self.dir.path());
        cmd
    }

    /// Run git-collab and return raw output.
    pub fn run(&self, args: &[&str]) -> Output {
        self.cli_command()
            .args(args)
            .output()
            .expect("failed to run git-collab")
    }

    /// Run git-collab, assert success, return stdout.
    pub fn run_ok(&self, args: &[&str]) -> String {
        let output = self.run(args);
        let stdout = String::from_utf8(output.stdout).unwrap();
        let stderr = String::from_utf8(output.stderr).unwrap();
        assert!(
            output.status.success(),
            "git-collab {:?} failed (exit {:?}):\nstdout: {}\nstderr: {}",
            args,
            output.status.code(),
            stdout,
            stderr
        );
        stdout
    }

    /// Run git-collab, assert failure, return stderr.
    pub fn run_err(&self, args: &[&str]) -> String {
        let output = self.run(args);
        assert!(
            !output.status.success(),
            "expected git-collab {:?} to fail but it succeeded:\nstdout: {}",
            args,
            String::from_utf8_lossy(&output.stdout)
        );
        String::from_utf8(output.stderr).unwrap()
    }

    /// Run `git-collab dashboard` in a pseudo-terminal, feed input, and return raw output.
    pub fn run_dashboard_smoke(&self, input: &str) -> Output {
        let mut command = Command::new("script");
        self.apply_env(&mut command);
        let mut child = command
            .args([
                "-qec",
                &format!("{} dashboard", env!("CARGO_BIN_EXE_git-collab")),
                "/dev/null",
            ])
            .current_dir(self.dir.path())
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .expect("failed to launch dashboard in pty");

        if let Some(mut stdin) = child.stdin.take() {
            stdin.write_all(input.as_bytes()).unwrap();
        }

        let deadline = Instant::now() + Duration::from_secs(5);
        loop {
            if let Some(_status) = child.try_wait().expect("failed to poll dashboard process") {
                return child.wait_with_output().expect("failed to collect dashboard output");
            }
            if Instant::now() >= deadline {
                let _ = child.kill();
                return child.wait_with_output().expect("failed to collect timed out dashboard output");
            }
            thread::sleep(Duration::from_millis(20));
        }
    }

    /// Open an issue and return the 8-char short ID.
    pub fn issue_open(&self, title: &str) -> String {
        let out = self.run_ok(&["issue", "open", "-t", title]);
        out.trim()
            .strip_prefix("Opened issue ")
            .unwrap_or_else(|| panic!("unexpected issue open output: {}", out))
            .to_string()
    }

    /// Create a patch from a new branch. Returns the 8-char short ID.
    pub fn patch_create(&self, title: &str) -> String {
        // Create a unique branch for this patch
        let sanitized = title.replace(|c: char| !c.is_alphanumeric(), "-").to_lowercase();
        let branch_name = format!("test/{}", sanitized);
        self.git(&["checkout", "-b", &branch_name]);
        self.commit_file(
            &format!("{}.txt", sanitized),
            &format!("content for {}", title),
            &format!("commit for {}", title),
        );
        let out = self.run_ok(&["patch", "create", "-t", title, "-B", &branch_name]);
        // Go back to main for subsequent operations
        self.git(&["checkout", "main"]);
        out.trim()
            .strip_prefix("Created patch ")
            .unwrap_or_else(|| panic!("unexpected patch create output: {}", out))
            .to_string()
    }

    /// Run a git command in this repo and return stdout.
    pub fn git(&self, args: &[&str]) -> String {
        let mut command = Command::new("git");
        self.apply_env(&mut command);
        let output = command
            .args(args)
            .current_dir(self.dir.path())
            .output()
            .expect("failed to run git");
        assert!(
            output.status.success(),
            "git {:?} failed: {}",
            args,
            String::from_utf8_lossy(&output.stderr)
        );
        String::from_utf8(output.stdout).unwrap()
    }

    /// Create a file, stage it, and commit. Returns the commit OID.
    pub fn commit_file(&self, path: &str, content: &str, message: &str) -> String {
        let full_path = self.dir.path().join(path);
        if let Some(parent) = full_path.parent() {
            std::fs::create_dir_all(parent).unwrap();
        }
        std::fs::write(&full_path, content).unwrap();
        self.git(&["add", path]);
        self.git(&["commit", "-m", message]);
        self.git(&["rev-parse", "HEAD"]).trim().to_string()
    }
}

pub struct HttpResponse {
    pub status_line: String,
    pub body: String,
}

pub struct ServerHarness {
    root: TempDir,
    repo_name: String,
    work_repo: TestRepo,
    server: Child,
    http_addr: SocketAddr,
}

impl ServerHarness {
    pub fn new(repo_name: &str) -> Self {
        let root = TempDir::new().unwrap();
        let repos_dir = root.path().join("repos");
        std::fs::create_dir_all(&repos_dir).unwrap();

        let bare_repo_dir = repos_dir.join(format!("{repo_name}.git"));
        git_cmd(root.path(), &["init", "--bare", bare_repo_dir.to_str().unwrap()]);

        let work_repo = TestRepo::new("Alice", "alice@example.com");
        work_repo.git(&["remote", "add", "origin", bare_repo_dir.to_str().unwrap()]);

        let authorized_keys = root.path().join("authorized_keys");
        std::fs::write(&authorized_keys, "").unwrap();

        let http_addr = pick_loopback_addr();
        let ssh_addr = pick_loopback_addr();
        let config_path = root.path().join("server.toml");
        std::fs::write(
            &config_path,
            format!(
                "repos_dir = {:?}\nhttp_bind = \"{}\"\nssh_bind = \"{}\"\nauthorized_keys = {:?}\nsite_title = \"git-collab test\"\n",
                repos_dir,
                http_addr,
                ssh_addr,
                authorized_keys,
            ),
        )
        .unwrap();

        let server = Command::new(env!("CARGO_BIN_EXE_git-collab-server"))
            .args(["--config", config_path.to_str().unwrap()])
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .expect("failed to start git-collab-server");

        let mut harness = Self {
            root,
            repo_name: repo_name.to_string(),
            work_repo,
            server,
            http_addr,
        };
        harness.wait_until_ready();
        harness
    }

    pub fn repo_name(&self) -> &str {
        &self.repo_name
    }

    pub fn work_repo(&self) -> &TestRepo {
        &self.work_repo
    }

    pub fn push_head(&self) {
        self.work_repo.git(&["push", "-u", "origin", "main"]);
    }

    pub fn push_collab_refs(&self) {
        self.work_repo
            .git(&["push", "origin", "refs/collab/*:refs/collab/*"]);
    }

    pub fn get_ok(&self, path: &str) -> HttpResponse {
        let response = self.get(path);
        assert!(
            response.status_line.contains("200"),
            "expected 200 for {path}, got {}\nbody:\n{}",
            response.status_line,
            response.body
        );
        response
    }

    pub fn get(&self, path: &str) -> HttpResponse {
        let mut stream = TcpStream::connect(self.http_addr)
            .unwrap_or_else(|e| panic!("failed to connect to http server on {}: {}", self.http_addr, e));
        stream
            .write_all(
                format!(
                    "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
                    path, self.http_addr
                )
                .as_bytes(),
            )
            .unwrap();
        let mut raw = Vec::new();
        stream.read_to_end(&mut raw).unwrap();
        let raw = String::from_utf8(raw).unwrap();
        let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((&raw, ""));
        let status_line = head.lines().next().unwrap_or("").to_string();
        HttpResponse {
            status_line,
            body: body.to_string(),
        }
    }

    fn wait_until_ready(&mut self) {
        let deadline = Instant::now() + Duration::from_secs(10);
        loop {
            if let Some(status) = self.server_status() {
                panic!("git-collab-server exited before becoming ready: {status}");
            }

            if let Ok(response) = TcpStream::connect(self.http_addr) {
                drop(response);
                let response = self.get("/");
                if !response.status_line.is_empty() {
                    return;
                }
            }

            if Instant::now() >= deadline {
                panic!("timed out waiting for git-collab-server on {}", self.http_addr);
            }

            thread::sleep(Duration::from_millis(50));
        }
    }

    fn server_status(&mut self) -> Option<String> {
        self.server
            .try_wait()
            .ok()
            .flatten()
            .map(|status| format!("exit status {:?}", status.code()))
    }
}

impl Drop for ServerHarness {
    fn drop(&mut self) {
        let _ = self.server.kill();
        let _ = self.server.wait();
        let _ = &self.root;
    }
}

/// Create an unsigned event commit (plain Event JSON, no signature/pubkey blobs).
/// Returns the commit OID.
pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid {
    let json = serde_json::to_vec_pretty(event).unwrap();
    let blob_oid = repo.blob(&json).unwrap();
    let manifest = br#"{"version":1,"format":"git-collab"}"#;
    let manifest_blob = repo.blob(manifest).unwrap();

    let mut tb = repo.treebuilder(None).unwrap();
    tb.insert("event.json", blob_oid, 0o100644).unwrap();
    tb.insert("manifest.json", manifest_blob, 0o100644).unwrap();
    // No signature or pubkey blobs — this is an unsigned event
    let tree_oid = tb.write().unwrap();
    let tree = repo.find_tree(tree_oid).unwrap();

    let sig = git2::Signature::now(&event.author.name, &event.author.email).unwrap();
    repo.commit(None, &sig, &sig, "unsigned event", &tree, &[])
        .unwrap()
}

/// Create a tampered event commit: sign the event, then modify the event.json but keep
/// the original signature. Returns the commit OID.
pub fn create_tampered_event(repo: &Repository, event: &Event) -> git2::Oid {
    let sk = test_signing_key();
    let detached = signing::sign_event(event, &sk).unwrap();

    // Tamper with the event content while keeping the original signature
    let mut tampered_event = event.clone();
    tampered_event.timestamp = "2099-01-01T00:00:00Z".to_string();
    let json = serde_json::to_vec_pretty(&tampered_event).unwrap();
    let event_blob = repo.blob(&json).unwrap();
    let sig_blob = repo.blob(detached.signature.as_bytes()).unwrap();
    let pubkey_blob = repo.blob(detached.pubkey.as_bytes()).unwrap();
    let manifest = br#"{"version":1,"format":"git-collab"}"#;
    let manifest_blob = repo.blob(manifest).unwrap();

    let mut tb = repo.treebuilder(None).unwrap();
    tb.insert("event.json", event_blob, 0o100644).unwrap();
    tb.insert("signature", sig_blob, 0o100644).unwrap();
    tb.insert("pubkey", pubkey_blob, 0o100644).unwrap();
    tb.insert("manifest.json", manifest_blob, 0o100644).unwrap();
    let tree_oid = tb.write().unwrap();
    let tree = repo.find_tree(tree_oid).unwrap();

    let sig = git2::Signature::now(&event.author.name, &event.author.email).unwrap();
    repo.commit(None, &sig, &sig, "tampered event", &tree, &[])
        .unwrap()
}

/// Run a git command in the given directory (public for use in test files).
pub fn git_cmd(dir: &Path, args: &[&str]) {
    git(dir, args);
}

pub fn git_cmd_with_env(dir: &Path, args: &[&str], env: &TestHome) {
    git_with_env(dir, args, env);
}

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

fn git_with_env(dir: &Path, args: &[&str], test_home: &TestHome) {
    let mut command = Command::new("git");
    test_home.apply_to_command(&mut command);
    let output = command
        .args(args)
        .current_dir(dir)
        .output()
        .expect("failed to run git");
    assert!(
        output.status.success(),
        "git {:?} failed: {}",
        args,
        String::from_utf8_lossy(&output.stderr)
    );
}

fn pick_loopback_addr() -> SocketAddr {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let addr = listener.local_addr().unwrap();
    drop(listener);
    addr
}