//! Black-box end-to-end test. It starts the real `forge` binary and drives it with the real //! `git` and `ssh` clients over smart HTTP and SSH, plus raw HTTP requests for the negative cases. //! //! Cookies are handled by hand: the session cookie is `__Host-forge_session` with `Secure`, so a //! cookie jar would not send it over plain http. The test parses `Set-Cookie` and sends `Cookie`. mod common; use common::{BIN, describe, 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; use reqwest::header::{LOCATION, SET_COOKIE, WWW_AUTHENTICATE}; const ALICE_PW: &str = "alice-password-123"; const BOB_PW: &str = "bob-password-4567"; /// A parsed HTTP response. Only the parts the test needs are kept. struct Resp { status: u16, location: Option, www_authenticate: 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, None) .await } async fn get_cookie(&self, path: &str, cookie: &str) -> Resp { self.send( self.client.get(format!("{}{path}", self.base)), Some(cookie), None, ) .await } async fn get_basic(&self, path: &str, user: &str, password: &str) -> Resp { self.send( self.client.get(format!("{}{path}", self.base)), None, Some((user, password)), ) .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, None, ) .await } async fn send( &self, req: reqwest::RequestBuilder, cookie: Option<&str>, basic: Option<(&str, &str)>, ) -> Resp { let mut req = req; if let Some(cookie) = cookie { req = req.header(reqwest::header::COOKIE, cookie); } if let Some((user, password)) = basic { req = req.basic_auth(user, Some(password)); } let res = req.send().await.expect("http request"); let status = res.status().as_u16(); let location = header_text(&res, LOCATION); let www_authenticate = header_text(&res, WWW_AUTHENTICATE); 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, www_authenticate, set_cookies, body, } } } fn header_text(res: &reqwest::Response, name: reqwest::header::HeaderName) -> Option { res.headers() .get(name) .and_then(|v| v.to_str().ok()) .map(str::to_owned) } /// Returns `name=value` for the cookie whose name ends with `suffix`, e.g. `forge_session` /// matches `__Host-forge_session`. Cleared cookies (empty value) 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], 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") } fn path_str(path: &Path) -> &str { path.to_str().expect("utf-8 path") } /// The ssh command git runs for SSH remotes. Host key checks are off because the host key is /// new on every run. fn ssh_opts(key: &Path, ssh_port: u16) -> Vec { [ "-i", path_str(key), "-o", "IdentitiesOnly=yes", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "BatchMode=yes", "-o", "LogLevel=ERROR", "-p", ] .iter() .map(|s| (*s).to_owned()) .chain(std::iter::once(ssh_port.to_string())) .collect() } fn ssh_command(key: &Path, ssh_port: u16) -> String { format!("ssh {}", ssh_opts(key, ssh_port).join(" ")) } fn gen_key(path: &Path) { let out = Command::new("ssh-keygen") .args(["-t", "ed25519", "-N", "", "-q", "-f"]) .arg(path) .output() .expect("run ssh-keygen"); ok(&out, "ssh-keygen"); } 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 and commits it in `repo` with the repo's local identity. fn commit_file(repo: &Path, name: &str, content: &str, message: &str) { 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", ); } 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() } fn read(path: PathBuf) -> String { fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) } /// 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 } /// Adds a public key through `/settings/keys`. async fn add_key(http: &Http, session: &str, public_key: &str) { let page = http.get_cookie("/settings/keys", session).await; assert_eq!(page.status, 200, "GET /settings/keys"); let csrf = input_value(&page.body, "csrf"); let res = http .post_form( "/settings/keys", &[ ("csrf", &csrf), ("title", "e2e"), ("public_key", public_key), ], Some(session), ) .await; assert_eq!(res.status, 303, "add SSH key: {}", res.body); } /// Creates a repo through `/new`. async fn create_repo(http: &Http, session: &str, name: &str, visibility: &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", visibility), ("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()) ); } #[tokio::test] async fn git_end_to_end() { for tool in ["git", "ssh", "ssh-keygen"] { if !tool_on_path(tool) { eprintln!( "SKIP git_e2e: `{tool}` was not found on PATH, so the end-to-end test is skipped" ); return; } } // Keep the path short: the internal unix socket path is limited to 108 bytes. let root = tempfile::Builder::new() .prefix("fe2e") .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; let http = Http::new(ports.http); let (http_port, ssh_port) = (ports.http, ports.ssh); // Web setup: alice logs in, makes a token, adds an SSH key and creates both repos. let alice = login(&http, "alice", ALICE_PW).await; let alice_token = create_token(&http, &alice, "e2e-token").await; let key = dir.join("id"); gen_key(&key); let public_key = fs::read_to_string(dir.join("id.pub")).expect("read id.pub"); add_key(&http, &alice, public_key.trim()).await; create_repo(&http, &alice, "pub", "public").await; create_repo(&http, &alice, "priv", "private").await; // HTTPS: push to the public repo with the token, then read it back. let pub_url = format!("http://alice:{alice_token}@127.0.0.1:{http_port}/alice/pub.git"); let pub_work = dir.join("pub-work"); ok( &git(&dir, &["clone", &pub_url, path_str(&pub_work)], &[]), "https clone of empty public repo", ); commit_file(&pub_work, "hello.txt", "hello over https\n", "https commit"); ok( &git(&pub_work, &["push", "origin", "HEAD:main"], &[]), "https push", ); let pushed_main = head_sha(&pub_work); let pub_verify = dir.join("pub-verify"); ok( &git(&dir, &["clone", &pub_url, path_str(&pub_verify)], &[]), "https fresh clone", ); assert_eq!(read(pub_verify.join("hello.txt")), "hello over https\n"); // Anonymous clone of a public repo must succeed. let anon_url = format!("http://127.0.0.1:{http_port}/alice/pub.git"); let pub_anon = dir.join("pub-anon"); ok( &git(&dir, &["clone", &anon_url, path_str(&pub_anon)], &[]), "anonymous clone of public repo", ); assert_eq!(read(pub_anon.join("hello.txt")), "hello over https\n"); // SSH: push to the private repo with the registered key, then read it back. let ssh_cmd = ssh_command(&key, ssh_port); let ssh_env = [("GIT_SSH_COMMAND", ssh_cmd.as_str())]; let priv_ssh_url = format!("ssh://git@127.0.0.1:{ssh_port}/alice/priv.git"); let priv_work = dir.join("priv-work"); ok( &git( &dir, &["clone", &priv_ssh_url, path_str(&priv_work)], &ssh_env, ), "ssh clone of empty private repo", ); commit_file(&priv_work, "secret.txt", "over ssh\n", "ssh commit"); ok( &git(&priv_work, &["push", "origin", "HEAD:main"], &ssh_env), "ssh push", ); let priv_verify = dir.join("priv-verify"); ok( &git( &dir, &["clone", &priv_ssh_url, path_str(&priv_verify)], &ssh_env, ), "ssh fresh clone", ); assert_eq!(read(priv_verify.join("secret.txt")), "over ssh\n"); // Negative: private repo without credentials. let priv_https_anon = format!("http://127.0.0.1:{http_port}/alice/priv.git"); rejected( &git(&dir, &["clone", &priv_https_anon, "anon-priv"], &[]), "anonymous clone of private repo", ); let refs_path = "/alice/priv.git/info/refs?service=git-upload-pack"; let anon_refs = http.get(refs_path).await; assert_eq!(anon_refs.status, 401, "anonymous info/refs on private repo"); assert!( anon_refs.www_authenticate.is_some(), "401 must carry WWW-Authenticate" ); // Negative: bob is signed in but has no access, so the private repo looks absent. let bob = login(&http, "bob", BOB_PW).await; let bob_page = http.get_cookie("/alice/priv", &bob).await; assert_eq!(bob_page.status, 404, "bob's web view of alice/priv"); let bob_token = create_token(&http, &bob, "bob-token").await; let bob_refs = http.get_basic(refs_path, "bob", &bob_token).await; assert_eq!(bob_refs.status, 404, "bob's info/refs on alice/priv"); // Negative: a wrong token is rejected. let wrong = http .get_basic(refs_path, "alice", "forge_notarealtoken") .await; assert_eq!(wrong.status, 401, "wrong token on info/refs"); let wrong_url = format!("http://alice:forge_notarealtoken@127.0.0.1:{http_port}/alice/priv.git"); rejected( &git(&dir, &["clone", &wrong_url, "wrong-token-clone"], &[]), "clone with wrong token", ); // Negative: deleting the default branch is rejected by pre-receive. let delete = git(&pub_verify, &["push", "origin", "--delete", "main"], &[]); let text = rejected(&delete, "deleting main"); assert!( text.contains("deleting protected branch 'main' is not allowed"), "delete message: {text}" ); // Negative: a force-push to main after amending the commit is rejected. set_identity(&pub_verify); ok( &git( &pub_verify, &[ "-c", "commit.gpgsign=false", "commit", "--amend", "-q", "-m", "rewritten", ], &[], ), "amend commit", ); let force = git(&pub_verify, &["push", "-f", "origin", "HEAD:main"], &[]); let text = rejected(&force, "force-push to main"); assert!( text.contains("force-push to protected branch 'main' is not allowed"), "force-push message: {text}" ); // main must still point at the commit that was pushed. let ls = git(&dir, &["ls-remote", &pub_url, "refs/heads/main"], &[]); ok(&ls, "ls-remote main"); let remote_main = String::from_utf8_lossy(&ls.stdout); assert!( remote_main.starts_with(&pushed_main), "main moved after rejected pushes: {remote_main}" ); // Negative: an SSH key that is not registered is refused. let rogue = dir.join("rogue"); gen_key(&rogue); let rogue_cmd = ssh_command(&rogue, ssh_port); let rogue_env = [("GIT_SSH_COMMAND", rogue_cmd.as_str())]; let text = rejected( &git(&dir, &["clone", &priv_ssh_url, "rogue-clone"], &rogue_env), "clone with unregistered key", ); assert!( text.contains("Permission denied"), "rogue key message: {text}" ); // Negative: an injected path in an SSH exec request is refused by the strict parser. let injected = Command::new("ssh") .args(ssh_opts(&key, ssh_port)) .arg("git@127.0.0.1") .arg("git-upload-pack '../../etc'") .output() .expect("run ssh"); rejected(&injected, "ssh exec with injected path"); // Negative: POST /new without a csrf field is forbidden. let no_csrf = http .post_form( "/new", &[ ("name", "nocsrf"), ("visibility", "public"), ("default_branch", "main"), ], Some(&alice), ) .await; assert_eq!(no_csrf.status, 403, "POST /new without csrf"); // Negative: the dumb protocol (info/refs without ?service=) is forbidden. let dumb = http.get("/alice/pub.git/info/refs").await; assert_eq!(dumb.status, 403, "info/refs without service"); assert!( dumb.body.contains("dumb protocol"), "dumb body: {}", dumb.body ); }