forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
10.5 KiB
1//! End-to-end test for Git LFS. It starts the real `forge` binary and drives it with the real
2//! `git` and `git lfs` clients over smart HTTP, authenticated with a personal access token.
3//!
4//! The test needs `git lfs` on PATH. Without it, the test prints a message and returns early.
5//! Git config is isolated from the developer's machine: `HOME` and `GIT_CONFIG_GLOBAL` point into
6//! the test's temp dir.
7
8mod common;
9use common::{BIN, describe, launch, pick_ports, write_config};
10use std::fs;
11use std::path::Path;
12use std::process::{Command, Output};
13use std::time::Duration;
14
15use reqwest::header::{LOCATION, SET_COOKIE};
16
17const ALICE_PW: &str = "alice-password-123";
18const FILE_SIZE: usize = 100 * 1024;
19
20/// A parsed HTTP response. Only the parts the test needs are kept.
21struct Resp {
22 status: u16,
23 location: Option<String>,
24 set_cookies: Vec<String>,
25 body: String,
26}
27
28struct Http {
29 client: reqwest::Client,
30 base: String,
31}
32
33impl Http {
34 fn new(port: u16) -> Self {
35 let client = reqwest::Client::builder()
36 .redirect(reqwest::redirect::Policy::none())
37 .timeout(Duration::from_secs(60))
38 .build()
39 .expect("http client");
40 Self {
41 client,
42 base: format!("http://127.0.0.1:{port}"),
43 }
44 }
45
46 async fn get(&self, path: &str, cookie: Option<&str>) -> Resp {
47 self.send(self.client.get(format!("{}{path}", self.base)), cookie)
48 .await
49 }
50
51 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
52 self.send(
53 self.client.post(format!("{}{path}", self.base)).form(form),
54 cookie,
55 )
56 .await
57 }
58
59 async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp {
60 let req = match cookie {
61 Some(cookie) => req.header(reqwest::header::COOKIE, cookie),
62 None => req,
63 };
64 let res = req.send().await.expect("http request");
65 let status = res.status().as_u16();
66 let location = res
67 .headers()
68 .get(LOCATION)
69 .and_then(|v| v.to_str().ok())
70 .map(str::to_owned);
71 let set_cookies = res
72 .headers()
73 .get_all(SET_COOKIE)
74 .iter()
75 .filter_map(|v| v.to_str().ok().map(str::to_owned))
76 .collect();
77 let body = res.text().await.unwrap_or_default();
78 Resp {
79 status,
80 location,
81 set_cookies,
82 body,
83 }
84 }
85}
86
87/// Returns `name=value` for the cookie whose name ends with `suffix`. Cleared cookies are ignored.
88fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
89 resp.set_cookies.iter().find_map(|set| {
90 let pair = set.split(';').next()?;
91 let (name, value) = pair.split_once('=')?;
92 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
93 })
94}
95
96/// The value of the first `<input name="...">` on a page.
97fn input_value(body: &str, name: &str) -> String {
98 let doc = scraper::Html::parse_document(body);
99 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
100 doc.select(&selector)
101 .next()
102 .and_then(|el| el.value().attr("value"))
103 .unwrap_or_else(|| panic!("no input named {name} on page"))
104 .to_owned()
105}
106
107fn run_admin(config: &Path, username: &str, extra: &[&str], password: &str) {
108 let out = Command::new(BIN)
109 .arg("--config")
110 .arg(config)
111 .args(["admin", "create-user", username])
112 .args(extra)
113 .env("FORGE_PASSWORD", password)
114 .output()
115 .expect("run admin create-user");
116 ok(&out, &format!("create-user {username}"));
117}
118
119fn ok(out: &Output, what: &str) {
120 assert!(out.status.success(), "{what} failed:\n{}", describe(out));
121}
122
123/// Runs git in `cwd` with `env` applied on top of the inherited environment.
124fn git(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> Output {
125 let mut cmd = Command::new("git");
126 cmd.current_dir(cwd)
127 .args(["-c", "credential.helper="])
128 .args(args)
129 .env("GIT_TERMINAL_PROMPT", "0");
130 for (key, value) in env {
131 cmd.env(key, value);
132 }
133 cmd.output().expect("run git")
134}
135
136fn path_str(path: &Path) -> &str {
137 path.to_str().expect("utf-8 path")
138}
139
140fn git_lfs_installed() -> bool {
141 Command::new("git")
142 .args(["lfs", "version"])
143 .output()
144 .is_ok_and(|out| out.status.success())
145}
146
147/// Deterministic bytes that are not all one value, so a wrong or empty download fails the check.
148fn payload() -> Vec<u8> {
149 (0..FILE_SIZE)
150 .map(|i| (i.wrapping_mul(31) % 251) as u8)
151 .collect()
152}
153
154/// Logs in through the real `/login` form and returns the `__Host-forge_session` cookie header.
155async fn login(http: &Http, username: &str, password: &str) -> String {
156 let page = http.get("/login", None).await;
157 assert_eq!(page.status, 200, "GET /login");
158 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
159 let precsrf = input_value(&page.body, "precsrf");
160 let res = http
161 .post_form(
162 "/login",
163 &[
164 ("username", username),
165 ("password", password),
166 ("next", "/"),
167 ("precsrf", &precsrf),
168 ],
169 Some(&precsrf_cookie),
170 )
171 .await;
172 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
173 cookie_named(&res, "forge_session").expect("session cookie after login")
174}
175
176/// Creates an access token through `/settings/tokens` and returns the token shown once.
177async fn create_token(http: &Http, session: &str, name: &str) -> String {
178 let page = http.get("/settings/tokens", Some(session)).await;
179 assert_eq!(page.status, 200, "GET /settings/tokens");
180 let csrf = input_value(&page.body, "csrf");
181 let res = http
182 .post_form(
183 "/settings/tokens",
184 &[("csrf", &csrf), ("name", name), ("expires", "30")],
185 Some(session),
186 )
187 .await;
188 assert_eq!(res.status, 200, "create token: {}", res.body);
189 let doc = scraper::Html::parse_document(&res.body);
190 let selector = scraper::Selector::parse("pre.code code").expect("selector");
191 let token: String = doc
192 .select(&selector)
193 .next()
194 .expect("token shown after creation")
195 .text()
196 .collect();
197 let token = token.trim().to_owned();
198 assert!(token.starts_with("forge_"), "unexpected token shape");
199 token
200}
201
202/// Creates a repo through `/new`.
203async fn create_repo(http: &Http, session: &str, name: &str, visibility: &str) {
204 let page = http.get("/new", Some(session)).await;
205 assert_eq!(page.status, 200, "GET /new");
206 let csrf = input_value(&page.body, "csrf");
207 let res = http
208 .post_form(
209 "/new",
210 &[
211 ("csrf", &csrf),
212 ("name", name),
213 ("description", ""),
214 ("visibility", visibility),
215 ("default_branch", "main"),
216 ],
217 Some(session),
218 )
219 .await;
220 assert_eq!(res.status, 303, "create repo {name}: {}", res.body);
221 assert_eq!(
222 res.location.as_deref(),
223 Some(format!("/alice/{name}").as_str())
224 );
225}
226
227#[tokio::test]
228async fn lfs_end_to_end() {
229 if !git_lfs_installed() {
230 eprintln!("skipping: git-lfs not installed");
231 return;
232 }
233
234 // Keep the path short: the internal unix socket path is limited to 108 bytes.
235 let root = tempfile::Builder::new()
236 .prefix("flfs")
237 .tempdir_in("/tmp")
238 .expect("temp dir");
239 let dir = root.path().to_path_buf();
240 let ports = pick_ports();
241 let config = write_config(&dir, ports);
242 run_admin(&config, "alice", &["--admin"], ALICE_PW);
243
244 let (_server, ports) = launch(&dir, ports).await;
245 let http = Http::new(ports.http);
246
247 let session = login(&http, "alice", ALICE_PW).await;
248 let token = create_token(&http, &session, "lfs-e2e").await;
249 create_repo(&http, &session, "lfs", "public").await;
250
251 // Isolated git config for every client call below.
252 let global = dir.join("gitconfig");
253 let iso = [
254 ("HOME", path_str(&dir)),
255 ("GIT_CONFIG_GLOBAL", path_str(&global)),
256 ];
257 let url = format!(
258 "http://alice:{token}@127.0.0.1:{}/alice/lfs.git",
259 ports.http
260 );
261
262 // Push a 100 KiB file tracked by LFS.
263 let work = dir.join("work");
264 ok(
265 &git(&dir, &["clone", &url, path_str(&work)], &iso),
266 "clone of empty repo",
267 );
268 ok(
269 &git(&work, &["config", "user.name", "alice"], &iso),
270 "user.name",
271 );
272 ok(
273 &git(&work, &["config", "user.email", "alice@example.com"], &iso),
274 "user.email",
275 );
276 ok(
277 &git(&work, &["lfs", "install", "--local"], &iso),
278 "git lfs install",
279 );
280 ok(
281 &git(&work, &["lfs", "track", "*.bin"], &iso),
282 "git lfs track",
283 );
284 let content = payload();
285 fs::write(work.join("data.bin"), &content).expect("write data.bin");
286 ok(
287 &git(&work, &["add", ".gitattributes", "data.bin"], &iso),
288 "git add",
289 );
290 ok(
291 &git(
292 &work,
293 &[
294 "-c",
295 "commit.gpgsign=false",
296 "commit",
297 "-q",
298 "-m",
299 "add lfs file",
300 ],
301 &iso,
302 ),
303 "git commit",
304 );
305 ok(
306 &git(&work, &["push", "origin", "HEAD:main"], &iso),
307 "git push",
308 );
309
310 // Clone again with smudge on and check that the bytes come back intact.
311 let verify = dir.join("verify");
312 let smudge = [iso[0], iso[1], ("GIT_LFS_SKIP_SMUDGE", "0")];
313 ok(
314 &git(&dir, &["clone", &url, path_str(&verify)], &smudge),
315 "clone for verification",
316 );
317 ok(
318 &git(&verify, &["lfs", "install", "--local"], &iso),
319 "git lfs install in verify clone",
320 );
321 ok(&git(&verify, &["lfs", "pull"], &smudge), "git lfs pull");
322 let fetched = fs::read(verify.join("data.bin")).expect("read fetched data.bin");
323 assert!(
324 fetched == content,
325 "downloaded LFS bytes differ from the pushed file ({} vs {} bytes)",
326 fetched.len(),
327 content.len()
328 );
329
330 // The blob page shows the object as stored in LFS.
331 let page = http
332 .get("/alice/lfs/blob/main/data.bin", Some(&session))
333 .await;
334 assert_eq!(page.status, 200, "GET blob page");
335 assert!(
336 page.body.contains("Stored with Git LFS"),
337 "blob page does not show the LFS note"
338 );
339}