//! End-to-end: pull requests against the real `forge` binary, with real `git` pushes. //! //! Each test boots `forge serve` on random ports with a temp data dir, signs in through the //! `/login` form, creates tokens, and pushes over HTTPS. Pages are scraped for CSRF tokens. The //! scenarios cover a same-repo merge that closes an issue, a push that updates an open pull, //! a fork pull merged by squash, and a conflict that blocks the merge. mod common; use common::{BIN, Server, launch, pick_ports, tool_on_path, write_config}; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::time::{Duration, Instant}; use reqwest::header::{LOCATION, SET_COOKIE}; const ALICE_PW: &str = "alice-password-123"; const BOB_PW: &str = "bob-password-4567"; const BOB_EMAIL: &str = "bob@users.noreply.127.0.0.1"; const REPO_PATH: &str = "/alice/r"; struct Resp { status: u16, location: Option, set_cookies: Vec, body: String, } struct Http { client: reqwest::Client, base: String, } impl Http { fn new(port: u16) -> Self { let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .timeout(Duration::from_secs(60)) .build() .expect("http client"); Self { client, base: format!("http://127.0.0.1:{port}"), } } async fn get(&self, path: &str) -> Resp { send(self.client.get(format!("{}{path}", self.base))).await } async fn get_cookie(&self, path: &str, cookie: &str) -> Resp { let req = self .client .get(format!("{}{path}", self.base)) .header(reqwest::header::COOKIE, cookie); send(req).await } async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp { let mut req = self.client.post(format!("{}{path}", self.base)).form(form); if let Some(cookie) = cookie { req = req.header(reqwest::header::COOKIE, cookie); } send(req).await } } async fn send(req: reqwest::RequestBuilder) -> Resp { let res = req.send().await.expect("http request"); let status = res.status().as_u16(); let location = res .headers() .get(LOCATION) .and_then(|v| v.to_str().ok()) .map(str::to_owned); let set_cookies = res .headers() .get_all(SET_COOKIE) .iter() .filter_map(|v| v.to_str().ok().map(str::to_owned)) .collect(); let body = res.text().await.unwrap_or_default(); Resp { status, location, set_cookies, body, } } /// Returns `name=value` for the cookie whose name ends with `suffix`. Cleared cookies are ignored. fn cookie_named(resp: &Resp, suffix: &str) -> Option { resp.set_cookies.iter().find_map(|set| { let pair = set.split(';').next()?; let (name, value) = pair.split_once('=')?; (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}")) }) } /// The value of the first `` on a page. fn input_value(body: &str, name: &str) -> String { let doc = scraper::Html::parse_document(body); let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector"); doc.select(&selector) .next() .and_then(|el| el.value().attr("value")) .unwrap_or_else(|| panic!("no input named {name} on page")) .to_owned() } /// Text of the first state badge on a page, e.g. `Open`, `Closed` or `Merged`. fn state_badge(body: &str) -> String { let doc = scraper::Html::parse_document(body); let selector = scraper::Selector::parse(".issue-badge").expect("selector"); doc.select(&selector) .next() .map(|el| el.text().collect::().trim().to_owned()) .unwrap_or_default() } fn run_admin(config: &Path, username: &str, extra: &[&str], password: &str) { let out = Command::new(BIN) .arg("--config") .arg(config) .args(["admin", "create-user", username]) .args(extra) .env("FORGE_PASSWORD", password) .output() .expect("run admin create-user"); assert!( out.status.success(), "create-user {username} failed:\n{}", String::from_utf8_lossy(&out.stderr) ); } /// Runs git with credential helpers disabled and extra env, returning stdout trimmed. Panics on /// failure. fn git_env(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> String { let mut cmd = Command::new("git"); cmd.current_dir(cwd) .args(["-c", "credential.helper="]) .args(args) .env("GIT_TERMINAL_PROMPT", "0"); for (key, value) in env { cmd.env(key, value); } let out: Output = cmd.output().expect("run git"); assert!( out.status.success(), "git {args:?} failed:\n{}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_owned() } fn git(cwd: &Path, args: &[&str]) -> String { git_env(cwd, args, &[]) } /// Writes `file`, commits it with `who` as author and committer, and returns the new HEAD. fn commit( repo: &Path, file: &str, contents: &str, message: &str, who: &str, email: &str, ) -> String { fs::write(repo.join(file), contents).expect("write file"); git(repo, &["add", file]); git_env( repo, &["-c", "commit.gpgsign=false", "commit", "-q", "-m", message], &[ ("GIT_AUTHOR_NAME", who), ("GIT_AUTHOR_EMAIL", email), ("GIT_COMMITTER_NAME", who), ("GIT_COMMITTER_EMAIL", email), ], ); git(repo, &["rev-parse", "HEAD"]) } /// Clones `url` into `into` (a path that does not exist yet) and returns it. fn clone(url: &str, into: &Path) -> PathBuf { let parent = into.parent().expect("clone target has a parent"); git( parent, &["clone", "-q", url, into.to_str().expect("utf-8 path")], ); into.to_path_buf() } /// The HTTPS remote for `owner/repo`, authenticated with a token. fn remote(port: u16, user: &str, token: &str, owner: &str, repo: &str) -> String { format!("http://{user}:{token}@127.0.0.1:{port}/{owner}/{repo}.git") } /// The commit a remote branch points at, from `git ls-remote`. fn remote_sha(dir: &Path, url: &str, branch: &str) -> String { let out = git(dir, &["ls-remote", url, &format!("refs/heads/{branch}")]); out.split_whitespace() .next() .unwrap_or_else(|| panic!("branch {branch} missing on remote")) .to_owned() } /// Number of parents of `commit` in the repository at `dir`. fn parent_count(dir: &Path, commit: &str) -> usize { let line = git(dir, &["rev-list", "--parents", "-n1", commit]); line.split_whitespace().count() - 1 } /// Logs in through the real `/login` form and returns the session cookie header. async fn login(http: &Http, username: &str, password: &str) -> String { let page = http.get("/login").await; assert_eq!(page.status, 200, "GET /login"); let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie"); let precsrf = input_value(&page.body, "precsrf"); let res = http .post_form( "/login", &[ ("username", username), ("password", password), ("next", "/"), ("precsrf", &precsrf), ], Some(&precsrf_cookie), ) .await; assert_eq!(res.status, 303, "login as {username}: {}", res.body); cookie_named(&res, "forge_session").expect("session cookie after login") } /// The session's CSRF token, scraped from the token settings page. async fn csrf_token(http: &Http, session: &str) -> String { let page = http.get_cookie("/settings/tokens", session).await; assert_eq!(page.status, 200, "GET /settings/tokens"); input_value(&page.body, "csrf") } /// Creates an access token through `/settings/tokens` and returns the token shown once. async fn create_token(http: &Http, session: &str, name: &str) -> String { let csrf = csrf_token(http, session).await; let res = http .post_form( "/settings/tokens", &[("csrf", &csrf), ("name", name), ("expires", "30")], Some(session), ) .await; assert_eq!(res.status, 200, "create token: {}", res.body); let doc = scraper::Html::parse_document(&res.body); let selector = scraper::Selector::parse("pre.code code").expect("selector"); let token: String = doc .select(&selector) .next() .expect("token shown after creation") .text() .collect(); let token = token.trim().to_owned(); assert!(token.starts_with("forge_"), "unexpected token shape"); token } /// Creates a public repo `name` owned by alice through `/new`. async fn create_repo(http: &Http, session: &str, name: &str) { let page = http.get_cookie("/new", session).await; assert_eq!(page.status, 200, "GET /new"); let csrf = input_value(&page.body, "csrf"); let res = http .post_form( "/new", &[ ("csrf", &csrf), ("name", name), ("description", ""), ("visibility", "public"), ("default_branch", "main"), ], Some(session), ) .await; assert_eq!(res.status, 303, "create repo {name}: {}", res.body); assert_eq!( res.location.as_deref(), Some(format!("/alice/{name}").as_str()) ); } /// A running server with its own temp data dir. Field order matters: the server is dropped (and /// killed) before the temp dir is removed. struct Booted { _server: Server, _root: tempfile::TempDir, dir: PathBuf, http: Http, http_port: u16, } /// The server's stdout and stderr, for assertion messages. fn server_log(b: &Booted) -> String { fs::read_to_string(b.dir.join("serve.log")).unwrap_or_default() } /// Creates alice (admin) and bob, then boots the server on fresh ports. async fn boot() -> Booted { if !tool_on_path("git") { panic!("`git` was not found on PATH"); } // Keep the path short: the internal unix socket path is limited to 108 bytes. let root = tempfile::Builder::new() .prefix("pr2e") .tempdir_in("/tmp") .expect("temp dir"); let dir = root.path().to_path_buf(); let ports = pick_ports(); let config = write_config(&dir, ports); run_admin(&config, "alice", &["--admin"], ALICE_PW); run_admin(&config, "bob", &[], BOB_PW); let (server, ports) = launch(&dir, ports).await; Booted { _server: server, _root: root, dir, http: Http::new(ports.http), http_port: ports.http, } } /// Creates `alice/r` with `main` pushed as alice, and returns alice's session and token. async fn alice_repo_with_main(b: &Booted) -> (String, String, PathBuf) { let alice = login(&b.http, "alice", ALICE_PW).await; let token = create_token(&b.http, &alice, "pulls-token").await; create_repo(&b.http, &alice, "r").await; let work = clone( &remote(b.http_port, "alice", &token, "alice", "r"), &b.dir.join("alice-work"), ); commit( &work, "base.txt", "base\n", "base commit", "alice", "alice@example.test", ); git(&work, &["push", "-q", "origin", "HEAD:refs/heads/main"]); (alice, token, work) } /// Opens a pull request `base...head` on alice/r through the compare form. Returns its number. async fn open_pull(http: &Http, session: &str, base: &str, head: &str, title: &str) -> u32 { let compare = http .get_cookie(&format!("{REPO_PATH}/compare/{base}...{head}"), session) .await; assert_eq!(compare.status, 200, "GET compare {base}...{head}"); let csrf = csrf_token(http, session).await; let res = http .post_form( &format!("{REPO_PATH}/pulls"), &[ ("csrf", &csrf), ("title", title), ("body", ""), ("base", base), ("head", head), ], Some(session), ) .await; assert_eq!(res.status, 303, "open pull {head}: {}", res.body); let location = res.location.expect("redirect to the pull"); location .rsplit('/') .next() .and_then(|n| n.parse().ok()) .unwrap_or_else(|| panic!("no pull number in {location}")) } /// Creates an issue on alice/r through the web form. Returns its number. async fn open_issue(http: &Http, session: &str, title: &str) -> u32 { let page = http .get_cookie(&format!("{REPO_PATH}/issues/new"), session) .await; assert_eq!(page.status, 200, "GET issues/new"); let csrf = input_value(&page.body, "csrf"); let res = http .post_form( &format!("{REPO_PATH}/issues/new"), &[("csrf", &csrf), ("title", title), ("body", "details")], Some(session), ) .await; assert_eq!(res.status, 303, "create issue: {}", res.body); let location = res.location.expect("redirect to the issue"); location .rsplit('/') .next() .and_then(|n| n.parse().ok()) .unwrap_or_else(|| panic!("no issue number in {location}")) } /// Polls `path` until `done` accepts the body, failing after 10 seconds. async fn wait_page( http: &Http, session: &str, path: &str, what: &str, done: impl Fn(&str) -> bool, ) -> String { let deadline = Instant::now() + Duration::from_secs(10); loop { let res = http.get_cookie(path, session).await; assert_eq!(res.status, 200, "GET {path}"); if done(&res.body) { return res.body; } assert!( Instant::now() < deadline, "timed out after 10s waiting for {what} on {path}" ); tokio::time::sleep(Duration::from_millis(200)).await; } } /// Posts a merge for pull `number` with `style` as `session`. async fn merge(http: &Http, session: &str, number: u32, style: &str) -> Resp { let csrf = csrf_token(http, session).await; http.post_form( &format!("{REPO_PATH}/pulls/{number}/merge"), &[("csrf", &csrf), ("style", style), ("message", "")], Some(session), ) .await } #[tokio::test] async fn same_repo_pull_merges_and_closes_issue() { let b = boot().await; let (alice, token, work) = alice_repo_with_main(&b).await; let issue = open_issue(&b.http, &alice, "add x").await; assert_eq!(issue, 1); git(&work, &["checkout", "-q", "-b", "feature"]); let feature_head = commit( &work, "x.txt", "x\n", "Add x\n\nFixes #1", "alice", "alice@example.test", ); git(&work, &["push", "-q", "origin", "HEAD:refs/heads/feature"]); let compare = b .http .get_cookie(&format!("{REPO_PATH}/compare/main...feature"), &alice) .await; assert_eq!(compare.status, 200, "GET compare"); assert!(compare.body.contains("Add x"), "compare lists the commit"); let number = open_pull(&b.http, &alice, "main", "feature", "Add x").await; assert_eq!(number, 2, "the pull shares the issue number sequence"); let files = b .http .get_cookie(&format!("{REPO_PATH}/pulls/2/files"), &alice) .await; assert_eq!(files.status, 200, "GET pull files"); assert!(files.body.contains("x.txt"), "files tab shows x.txt"); let res = merge(&b.http, &alice, 2, "merge").await; assert_eq!(res.status, 303, "merge: {}", res.body); assert_eq!(res.location.as_deref(), Some("/alice/r/pulls/2")); let url = remote(b.http_port, "alice", &token, "alice", "r"); let main_sha = remote_sha(&b.dir, &url, "main"); assert_ne!(main_sha, feature_head, "main is a new merge commit"); let check = clone(&url, &b.dir.join("check")); assert_eq!( parent_count(&check, "HEAD"), 2, "main tip is a merge commit with two parents" ); assert!( git(&check, &["log", "--format=%s", "-n", "5"]).contains("Add x"), "feature commit reachable from main" ); let pr = b .http .get_cookie(&format!("{REPO_PATH}/pulls/2"), &alice) .await; assert_eq!(state_badge(&pr.body), "Merged"); wait_page( &b.http, &alice, &format!("{REPO_PATH}/issues/1"), "issue closed", |body| state_badge(body) == "Closed", ) .await; } #[tokio::test] async fn push_to_head_branch_updates_open_pull() { let b = boot().await; let (alice, _token, work) = alice_repo_with_main(&b).await; git(&work, &["checkout", "-q", "-b", "feature2"]); commit( &work, "one.txt", "1\n", "first on feature2", "alice", "alice@example.test", ); git(&work, &["push", "-q", "origin", "HEAD:refs/heads/feature2"]); let number = open_pull(&b.http, &alice, "main", "feature2", "Feature two").await; let second = commit( &work, "two.txt", "2\n", "second on feature2", "alice", "alice@example.test", ); git(&work, &["push", "-q", "origin", "HEAD:refs/heads/feature2"]); let path = format!("{REPO_PATH}/pulls/{number}"); let page = wait_page(&b.http, &alice, &path, "head_updated event", |body| { body.contains("pushed new commits") }) .await; assert!(page.contains("pushed new commits")); let commits = b.http.get_cookie(&format!("{path}/commits"), &alice).await; assert_eq!(commits.status, 200, "GET pull commits"); assert!( commits.body.contains("second on feature2"), "new commit listed" ); assert!( commits.body.contains("first on feature2"), "old commit listed" ); assert!(commits.body.contains(&second[..7]), "new commit sha shown"); } #[tokio::test] async fn fork_pull_squash_merge_credits_contributor() { let b = boot().await; let (alice, alice_token, _work) = alice_repo_with_main(&b).await; let bob = login(&b.http, "bob", BOB_PW).await; let bob_token = create_token(&b.http, &bob, "bob-token").await; // Bob forks alice/r. The fork keeps the source name under bob's owner. let csrf = csrf_token(&b.http, &bob).await; let fork = b .http .post_form(&format!("{REPO_PATH}/fork"), &[("csrf", &csrf)], Some(&bob)) .await; assert_eq!(fork.status, 303, "fork: {}\n{}", fork.body, server_log(&b)); assert_eq!(fork.location.as_deref(), Some("/bob/r")); let bob_url = remote(b.http_port, "bob", &bob_token, "bob", "r"); let bob_work = clone(&bob_url, &b.dir.join("bob-work")); git(&bob_work, &["checkout", "-q", "-b", "bob-feature"]); let bob_head = commit( &bob_work, "bob.txt", "bob\n", "Bob change", "bob", BOB_EMAIL, ); git( &bob_work, &["push", "-q", "origin", "HEAD:refs/heads/bob-feature"], ); let number = open_pull(&b.http, &bob, "main", "bob:bob-feature", "Bob change").await; // Bob can open the pull but cannot merge it. let denied = merge(&b.http, &bob, number, "merge").await; assert_eq!(denied.status, 403, "bob must not merge"); let res = merge(&b.http, &alice, number, "squash").await; assert_eq!(res.status, 303, "alice squash merge: {}", res.body); let alice_url = remote(b.http_port, "alice", &alice_token, "alice", "r"); let check = clone(&alice_url, &b.dir.join("check")); assert_eq!(parent_count(&check, "HEAD"), 1, "squash makes one parent"); assert_ne!( git(&check, &["rev-parse", "HEAD"]), bob_head, "main has a new squash commit, not bob's commit" ); assert_eq!(git(&check, &["log", "-1", "--format=%an"]), "bob"); assert_eq!(git(&check, &["log", "-1", "--format=%ae"]), BOB_EMAIL); } #[tokio::test] async fn conflicting_pull_cannot_merge() { let b = boot().await; let (alice, token, work) = alice_repo_with_main(&b).await; // Both sides edit the same line. The pull is opened while main still matches its base. git(&work, &["checkout", "-q", "-b", "conflict"]); commit( &work, "shared.txt", "branch side\n", "branch edit", "alice", "alice@example.test", ); git(&work, &["push", "-q", "origin", "HEAD:refs/heads/conflict"]); let number = open_pull(&b.http, &alice, "main", "conflict", "Conflicting").await; git(&work, &["checkout", "-q", "main"]); let main_head = commit( &work, "shared.txt", "main side\n", "main edit", "alice", "alice@example.test", ); git(&work, &["push", "-q", "origin", "HEAD:refs/heads/main"]); let path = format!("{REPO_PATH}/pulls/{number}"); let page = wait_page(&b.http, &alice, &path, "conflict detection", |body| { body.contains("This branch has conflicts that must be resolved") }) .await; assert!(page.contains("shared.txt"), "conflict lists the file"); assert!( !page.contains("merge-form"), "no merge form on a conflicting pull" ); let res = merge(&b.http, &alice, number, "merge").await; assert_eq!( res.status, 303, "conflicting merge: {}\n{}", res.body, server_log(&b) ); assert_eq!( res.location.as_deref(), Some(format!("{REPO_PATH}/pulls/{number}?merge_error=conflict").as_str()), "refusal redirects back to the pull with the conflict code" ); let url = remote(b.http_port, "alice", &token, "alice", "r"); assert_eq!( remote_sha(&b.dir, &url, "main"), main_head, "main is unchanged by the refused merge" ); }