//! End-to-end test for Git LFS. It starts the real `forge` binary and drives it with the real //! `git` and `git lfs` clients over smart HTTP, authenticated with a personal access token. //! //! The test needs `git lfs` on PATH. Without it, the test prints a message and returns early. //! Git config is isolated from the developer's machine: `HOME` and `GIT_CONFIG_GLOBAL` point into //! the test's temp dir. mod common; use common::{BIN, describe, launch, pick_ports, write_config}; use std::fs; use std::path::Path; use std::process::{Command, Output}; use std::time::Duration; use reqwest::header::{LOCATION, SET_COOKIE}; const ALICE_PW: &str = "alice-password-123"; const FILE_SIZE: usize = 100 * 1024; /// A parsed HTTP response. Only the parts the test needs are kept. 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, cookie: Option<&str>) -> Resp { self.send(self.client.get(format!("{}{path}", self.base)), 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 req = match cookie { Some(cookie) => req.header(reqwest::header::COOKIE, cookie), None => req, }; 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)); } /// Runs git in `cwd` with `env` applied on top of the inherited environment. 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") } fn git_lfs_installed() -> bool { Command::new("git") .args(["lfs", "version"]) .output() .is_ok_and(|out| out.status.success()) } /// Deterministic bytes that are not all one value, so a wrong or empty download fails the check. fn payload() -> Vec { (0..FILE_SIZE) .map(|i| (i.wrapping_mul(31) % 251) as u8) .collect() } /// 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", None).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("/settings/tokens", Some(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 repo through `/new`. async fn create_repo(http: &Http, session: &str, name: &str, visibility: &str) { let page = http.get("/new", Some(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 lfs_end_to_end() { if !git_lfs_installed() { eprintln!("skipping: git-lfs not installed"); return; } // Keep the path short: the internal unix socket path is limited to 108 bytes. let root = tempfile::Builder::new() .prefix("flfs") .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 http = Http::new(ports.http); let session = login(&http, "alice", ALICE_PW).await; let token = create_token(&http, &session, "lfs-e2e").await; create_repo(&http, &session, "lfs", "public").await; // Isolated git config for every client call below. let global = dir.join("gitconfig"); let iso = [ ("HOME", path_str(&dir)), ("GIT_CONFIG_GLOBAL", path_str(&global)), ]; let url = format!( "http://alice:{token}@127.0.0.1:{}/alice/lfs.git", ports.http ); // Push a 100 KiB file tracked by LFS. let work = dir.join("work"); ok( &git(&dir, &["clone", &url, path_str(&work)], &iso), "clone of empty repo", ); ok( &git(&work, &["config", "user.name", "alice"], &iso), "user.name", ); ok( &git(&work, &["config", "user.email", "alice@example.com"], &iso), "user.email", ); ok( &git(&work, &["lfs", "install", "--local"], &iso), "git lfs install", ); ok( &git(&work, &["lfs", "track", "*.bin"], &iso), "git lfs track", ); let content = payload(); fs::write(work.join("data.bin"), &content).expect("write data.bin"); ok( &git(&work, &["add", ".gitattributes", "data.bin"], &iso), "git add", ); ok( &git( &work, &[ "-c", "commit.gpgsign=false", "commit", "-q", "-m", "add lfs file", ], &iso, ), "git commit", ); ok( &git(&work, &["push", "origin", "HEAD:main"], &iso), "git push", ); // Clone again with smudge on and check that the bytes come back intact. let verify = dir.join("verify"); let smudge = [iso[0], iso[1], ("GIT_LFS_SKIP_SMUDGE", "0")]; ok( &git(&dir, &["clone", &url, path_str(&verify)], &smudge), "clone for verification", ); ok( &git(&verify, &["lfs", "install", "--local"], &iso), "git lfs install in verify clone", ); ok(&git(&verify, &["lfs", "pull"], &smudge), "git lfs pull"); let fetched = fs::read(verify.join("data.bin")).expect("read fetched data.bin"); assert!( fetched == content, "downloaded LFS bytes differ from the pushed file ({} vs {} bytes)", fetched.len(), content.len() ); // The blob page shows the object as stored in LFS. let page = http .get("/alice/lfs/blob/main/data.bin", Some(&session)) .await; assert_eq!(page.status, 200, "GET blob page"); assert!( page.body.contains("Stored with Git LFS"), "blob page does not show the LFS note" ); }