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
}