//! End-to-end: pushes over HTTPS close and reference issues from commit messages. //! //! The server runs as a subprocess. Issues are created through the store on the same database //! file, and every push is a real `git push` through the git HTTP endpoints. mod common; use common::{BIN, launch, pick_ports, relaunch, tool_on_path, write_config}; use std::fs; use std::future::Future; use std::path::Path; use std::process::{Command, Output}; use std::time::{Duration, Instant}; use forge_core::Store; use reqwest::header::{LOCATION, SET_COOKIE}; const ALICE_PW: &str = "alice-password-123"; const BOB_PW: &str = "bob-password-4567"; 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_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 get(&self, path: &str) -> Resp { send(self.client.get(format!("{}{path}", self.base))).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() } 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 returns stdout, trimmed. Panics on failure. fn git(cwd: &Path, args: &[&str]) -> String { let out = git_output(cwd, args, &[]); 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_output(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> Output { 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); } cmd.output().expect("run git") } /// Writes `name`, commits it as alice, and returns the new HEAD. fn commit_file(repo: &Path, name: &str, message: &str) -> String { fs::write(repo.join(name), message).expect("write file"); git(repo, &["add", name]); git( repo, &[ "-c", "user.name=alice", "-c", "user.email=alice@example.test", "-c", "commit.gpgsign=false", "commit", "-q", "-m", message, ], ); git(repo, &["rev-parse", "HEAD"]) } /// 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") } /// 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 page = http.get_cookie("/settings/tokens", session).await; assert_eq!(page.status, 200, "GET /settings/tokens"); let csrf = input_value(&page.body, "csrf"); 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 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()) ); } /// Polls `check` every 100ms until it returns true, failing after 5 seconds. async fn eventually(what: &str, mut check: F) where F: FnMut() -> Fut, Fut: Future, { let deadline = Instant::now() + Duration::from_secs(5); while !check().await { assert!( Instant::now() < deadline, "timed out after 5s waiting for {what}" ); tokio::time::sleep(Duration::from_millis(100)).await; } } /// The issue page for `number`, read as `session`. async fn issue_page(http: &Http, session: &str, number: u32) -> String { let res = http .get_cookie(&format!("/alice/pub/issues/{number}"), session) .await; assert_eq!(res.status, 200, "GET issue {number}"); res.body } /// Text of the state badge on an issue page, e.g. `Open` or `Closed`. 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() } #[tokio::test] async fn pushes_close_and_reference_issues() { if !tool_on_path("git") { eprintln!("SKIP issue_close_e2e: `git` was not found on PATH"); return; } // Keep the path short: the internal unix socket path is limited to 108 bytes. let root = tempfile::Builder::new() .prefix("ice2e") .tempdir_in("/tmp") .expect("temp dir"); let dir = root.path().to_path_buf(); let ports = pick_ports(); let config = write_config(&dir, ports); let log_path = dir.join("serve.log"); run_admin(&config, "alice", &["--admin"], ALICE_PW); run_admin(&config, "bob", &[], BOB_PW); // Web setup: log in, make a token and create the repo. let (mut server, ports) = launch(&dir, ports).await; let http_port = ports.http; let http = Http::new(http_port); let alice = login(&http, "alice", ALICE_PW).await; let token = create_token(&http, &alice, "issue-close-token").await; create_repo(&http, &alice, "pub").await; // The database file is locked while the server runs, so stop it, create the issues through // the store, and start the server again on the same ports. drop(server); { let store = Store::open(&dir.join("forge.db")) .await .expect("open store"); let author = store .user_by_username("alice") .await .expect("user lookup") .expect("alice exists"); let repo = store .repo_by_owner_and_name("alice", "pub") .await .expect("repo lookup") .expect("repo exists"); for n in 1..=3 { store .create_issue(&repo.id, &author.id, &format!("issue {n}"), "body") .await .expect("create issue"); } } server = relaunch(&config, &log_path, ports).await; let alice = login(&http, "alice", ALICE_PW).await; // Push over HTTPS with the token. let url = format!("http://alice:{token}@127.0.0.1:{http_port}/alice/pub.git"); let work = dir.join("work"); git( &dir, &["clone", "-q", &url, work.to_str().expect("utf-8 path")], ); let base = commit_file(&work, "base.txt", "base commit"); git(&work, &["push", "-q", "origin", "HEAD:refs/heads/main"]); // A commit that references #3 on a non-default branch must not close it. git(&work, &["checkout", "-q", "-b", "topic"]); let topic_oid = commit_file(&work, "topic.txt", "fixes #3 on a topic branch"); git(&work, &["push", "-q", "origin", "HEAD:refs/heads/topic"]); // Default branch: one closing reference and one plain reference. git(&work, &["checkout", "-q", "-B", "mainline", &base]); let main_oid = commit_file(&work, "main.txt", "Fixes #1, refs #2"); // The same commit goes to another branch first. That push is not processed, so the // push to main below must produce exactly one referenced event. git( &work, &["push", "-q", "origin", "HEAD:refs/heads/dup-of-main"], ); git(&work, &["push", "-q", "origin", "HEAD:refs/heads/main"]); let commit_link = format!("/commit/{main_oid}"); let link_ref = commit_link.as_str(); let (http_ref, alice_ref) = (&http, alice.as_str()); eventually("#1 closed by the push", || async move { state_badge(&issue_page(http_ref, alice_ref, 1).await) == "Closed" }) .await; let page1 = issue_page(&http, &alice, 1).await; assert!( page1.contains(&commit_link), "#1 timeline should link the closing commit" ); eventually("#2 referenced by the push", || async move { let page = issue_page(http_ref, alice_ref, 2).await; page.contains("referenced this") && page.contains(link_ref) }) .await; let page2 = issue_page(&http, &alice, 2).await; assert_eq!(state_badge(&page2), "Open", "#2 is only referenced"); // Nothing observable marks the end of a background run for a non-matching push, so give the // topic and dup-branch work time to finish before checking the counts again. tokio::time::sleep(Duration::from_secs(2)).await; let page2 = issue_page(&http, &alice, 2).await; assert_eq!( page2.matches("referenced this").count(), 1, "referenced event must not be duplicated" ); let page3 = issue_page(&http, &alice, 3).await; assert_eq!( state_badge(&page3), "Open", "#3 must not be closed from topic" ); assert!( !page3.contains(&format!("/commit/{topic_oid}")), "non-default branch pushes are not processed" ); drop(server); }