//! End-to-end checks for branch protection in the pre-receive hook. It starts the real `forge` //! binary, pushes with the real `git` client over HTTPS, and writes rules through `forge-core` //! while the server runs. Helpers mirror `git_e2e.rs`. mod common; use common::{ BIN, Ports, Server, describe, launch, pick_ports, relaunch, tool_on_path, write_config, }; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::time::Duration; use forge_core::Store; use reqwest::header::{LOCATION, SET_COOKIE}; const ALICE_PW: &str = "alice-password-123"; 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 { self.send(self.client.get(format!("{}{path}", self.base)), None) .await } async fn get_cookie(&self, path: &str, cookie: &str) -> Resp { self.send( self.client.get(format!("{}{path}", self.base)), Some(cookie), ) .await } async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp { self.send( self.client.post(format!("{}{path}", self.base)).form(form), cookie, ) .await } async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp { let mut req = req; if let Some(cookie) = cookie { req = req.header(reqwest::header::COOKIE, cookie); } 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"); ok(&out, &format!("create-user {username}")); } fn ok(out: &Output, what: &str) { assert!(out.status.success(), "{what} failed:\n{}", describe(out)); } /// Asserts failure and returns the combined output for further checks. fn rejected(out: &Output, what: &str) -> String { assert!( !out.status.success(), "{what} unexpectedly succeeded:\n{}", describe(out) ); describe(out) } fn git(cwd: &Path, args: &[&str]) -> Output { Command::new("git") .current_dir(cwd) .args(["-c", "credential.helper="]) .args(args) .env("GIT_TERMINAL_PROMPT", "0") .output() .expect("run git") } fn path_str(path: &Path) -> &str { path.to_str().expect("utf-8 path") } fn set_identity(repo: &Path) { ok( &git(repo, &["config", "user.name", "alice"]), "set user.name", ); ok( &git(repo, &["config", "user.email", "alice@example.com"]), "set user.email", ); } /// Writes a file, commits it in `repo`, and returns the new HEAD. fn commit_file(repo: &Path, name: &str, content: &str, message: &str) -> String { fs::write(repo.join(name), content).expect("write file"); set_identity(repo); ok(&git(repo, &["add", name]), "git add"); ok( &git( repo, &["-c", "commit.gpgsign=false", "commit", "-q", "-m", message], ), "git commit", ); head_sha(repo) } fn head_sha(repo: &Path) -> String { let out = git(repo, &["rev-parse", "HEAD"]); ok(&out, "rev-parse HEAD"); String::from_utf8_lossy(&out.stdout).trim().to_owned() } /// The commit a remote ref points at, as reported by `git ls-remote`. fn remote_sha(repo: &Path, reference: &str) -> String { let out = git(repo, &["ls-remote", "origin", reference]); ok(&out, "ls-remote"); String::from_utf8_lossy(&out.stdout) .split_whitespace() .next() .unwrap_or_default() .to_owned() } /// Logs in through the real `/login` form and returns the `__Host-forge_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 } 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", "private"), ("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()) ); } /// Paths and handles shared by the steps of the test. struct Ctx { dir: PathBuf, config: PathBuf, log: PathBuf, http: Http, ports: Ports, } /// Starts the server again on the ports the first launch settled on. async fn start_server(ctx: &Ctx) -> Server { relaunch(&ctx.config, &ctx.log, ctx.ports).await } /// Stops the server, writes one rule through `forge-core`, and starts the server again. The /// database file is locked while the server runs, so the rule cannot be written in place. The /// hook reads the rules on every push, so a restart changes nothing else. async fn set_rule( ctx: &Ctx, server: Server, repo_id: &str, pattern: &str, allow_force_push: bool, allow_deletion: bool, ) -> Server { drop(server); { let store = Store::open(&ctx.dir.join("forge.db")) .await .expect("open store"); store .upsert_branch_rule( repo_id, pattern, forge_core::protection::RuleFlags::basic(allow_force_push, allow_deletion), ) .await .expect("upsert rule"); } start_server(ctx).await } #[tokio::test] async fn branch_protection_end_to_end() { if !tool_on_path("git") { eprintln!("SKIP branch_protection_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("fbp") .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); let (server, ports) = launch(&dir, ports).await; let ctx = Ctx { log: dir.join("serve.log"), http: Http::new(ports.http), ports, config, dir, }; let alice = login(&ctx.http, "alice", ALICE_PW).await; let token = create_token(&ctx.http, &alice, "bp-token").await; create_repo(&ctx.http, &alice, "bp").await; // Look up the repo id while the server is stopped, since the database is locked otherwise. drop(server); let repo_id = { let store = Store::open(&ctx.dir.join("forge.db")) .await .expect("open store"); store .repo_by_owner_and_name("alice", "bp") .await .expect("repo lookup") .expect("repo exists") .id }; let mut server = start_server(&ctx).await; // Seed main, feature/x and release/1 with two commits on main. let url = format!( "http://alice:{token}@127.0.0.1:{}/alice/bp.git", ctx.http.base.rsplit(':').next().expect("port") ); let work = ctx.dir.join("work"); ok(&git(&ctx.dir, &["clone", &url, path_str(&work)]), "clone"); let c1 = commit_file(&work, "file.txt", "one\n", "one"); ok(&git(&work, &["push", "origin", "HEAD:main"]), "push main"); let c2 = commit_file(&work, "file.txt", "two\n", "two"); ok(&git(&work, &["push", "origin", "HEAD:main"]), "push main"); ok( &git(&work, &["push", "origin", "HEAD:refs/heads/feature/x"]), "push feature/x", ); ok( &git(&work, &["push", "origin", "HEAD:refs/heads/release/1"]), "push release/1", ); // Divergent history on main: c3 is not a descendant of c2. ok(&git(&work, &["reset", "-q", "--hard", &c1]), "reset"); let c3 = commit_file(&work, "file.txt", "three\n", "three"); // 1. Default protection: force-push to main is rejected. let force = git(&work, &["push", "-f", "origin", "HEAD:main"]); let text = rejected(&force, "force-push to default branch"); assert!( text.contains("force-push to protected branch 'main' is not allowed"), "default force-push message: {text}" ); assert_eq!(remote_sha(&work, "refs/heads/main"), c2); // 2. A rule that allows force pushes lets the force-push through, but not deletion. server = set_rule(&ctx, server, &repo_id, "main", true, false).await; ok( &git(&work, &["push", "-f", "origin", "HEAD:main"]), "force-push main with allow_force_push", ); assert_eq!(remote_sha(&work, "refs/heads/main"), c3); let delete = git(&work, &["push", "origin", "--delete", "main"]); let text = rejected(&delete, "delete main"); assert!( text.contains("deleting protected branch 'main' is not allowed"), "delete main message: {text}" ); assert_eq!(remote_sha(&work, "refs/heads/main"), c3); // 3. A release/* rule without force pushes rejects release/1 but not feature/x (no rule). server = set_rule(&ctx, server, &repo_id, "release/*", false, true).await; ok(&git(&work, &["reset", "-q", "--hard", &c1]), "reset"); let c4 = commit_file(&work, "file.txt", "four\n", "four"); let release_force = git( &work, &["push", "-f", "origin", "HEAD:refs/heads/release/1"], ); let text = rejected(&release_force, "force-push to release/1"); assert!( text.contains("force-push to protected branch 'release/1' is not allowed"), "release force-push message: {text}" ); assert_eq!(remote_sha(&work, "refs/heads/release/1"), c2); ok( &git( &work, &["push", "-f", "origin", "HEAD:refs/heads/feature/x"], ), "force-push feature/x without a rule", ); assert_eq!(remote_sha(&work, "refs/heads/feature/x"), c4); // 4. Deleting a non-default branch with no rule succeeds. ok( &git(&work, &["push", "origin", "--delete", "feature/x"]), "delete unprotected feature/x", ); assert_eq!(remote_sha(&work, "refs/heads/feature/x"), ""); drop(server); }