forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
18.2 KiB
1//! Black-box end-to-end test. It starts the real `forge` binary and drives it with the real
2//! `git` and `ssh` clients over smart HTTP and SSH, plus raw HTTP requests for the negative cases.
3//!
4//! Cookies are handled by hand: the session cookie is `__Host-forge_session` with `Secure`, so a
5//! cookie jar would not send it over plain http. The test parses `Set-Cookie` and sends `Cookie`.
6
7mod common;
8use common::{BIN, describe, launch, pick_ports, tool_on_path, write_config};
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::process::{Command, Output};
12use std::time::Duration;
13
14use reqwest::header::{LOCATION, SET_COOKIE, WWW_AUTHENTICATE};
15
16const ALICE_PW: &str = "alice-password-123";
17const BOB_PW: &str = "bob-password-4567";
18
19/// A parsed HTTP response. Only the parts the test needs are kept.
20struct Resp {
21 status: u16,
22 location: Option<String>,
23 www_authenticate: 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) -> Resp {
47 self.send(self.client.get(format!("{}{path}", self.base)), None, None)
48 .await
49 }
50
51 async fn get_cookie(&self, path: &str, cookie: &str) -> Resp {
52 self.send(
53 self.client.get(format!("{}{path}", self.base)),
54 Some(cookie),
55 None,
56 )
57 .await
58 }
59
60 async fn get_basic(&self, path: &str, user: &str, password: &str) -> Resp {
61 self.send(
62 self.client.get(format!("{}{path}", self.base)),
63 None,
64 Some((user, password)),
65 )
66 .await
67 }
68
69 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
70 self.send(
71 self.client.post(format!("{}{path}", self.base)).form(form),
72 cookie,
73 None,
74 )
75 .await
76 }
77
78 async fn send(
79 &self,
80 req: reqwest::RequestBuilder,
81 cookie: Option<&str>,
82 basic: Option<(&str, &str)>,
83 ) -> Resp {
84 let mut req = req;
85 if let Some(cookie) = cookie {
86 req = req.header(reqwest::header::COOKIE, cookie);
87 }
88 if let Some((user, password)) = basic {
89 req = req.basic_auth(user, Some(password));
90 }
91 let res = req.send().await.expect("http request");
92 let status = res.status().as_u16();
93 let location = header_text(&res, LOCATION);
94 let www_authenticate = header_text(&res, WWW_AUTHENTICATE);
95 let set_cookies = res
96 .headers()
97 .get_all(SET_COOKIE)
98 .iter()
99 .filter_map(|v| v.to_str().ok().map(str::to_owned))
100 .collect();
101 let body = res.text().await.unwrap_or_default();
102 Resp {
103 status,
104 location,
105 www_authenticate,
106 set_cookies,
107 body,
108 }
109 }
110}
111
112fn header_text(res: &reqwest::Response, name: reqwest::header::HeaderName) -> Option<String> {
113 res.headers()
114 .get(name)
115 .and_then(|v| v.to_str().ok())
116 .map(str::to_owned)
117}
118
119/// Returns `name=value` for the cookie whose name ends with `suffix`, e.g. `forge_session`
120/// matches `__Host-forge_session`. Cleared cookies (empty value) are ignored.
121fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
122 resp.set_cookies.iter().find_map(|set| {
123 let pair = set.split(';').next()?;
124 let (name, value) = pair.split_once('=')?;
125 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
126 })
127}
128
129/// The value of the first `<input name="...">` on a page.
130fn input_value(body: &str, name: &str) -> String {
131 let doc = scraper::Html::parse_document(body);
132 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
133 doc.select(&selector)
134 .next()
135 .and_then(|el| el.value().attr("value"))
136 .unwrap_or_else(|| panic!("no input named {name} on page"))
137 .to_owned()
138}
139
140fn run_admin(config: &Path, username: &str, extra: &[&str], password: &str) {
141 let out = Command::new(BIN)
142 .arg("--config")
143 .arg(config)
144 .args(["admin", "create-user", username])
145 .args(extra)
146 .env("FORGE_PASSWORD", password)
147 .output()
148 .expect("run admin create-user");
149 ok(&out, &format!("create-user {username}"));
150}
151
152fn ok(out: &Output, what: &str) {
153 assert!(out.status.success(), "{what} failed:\n{}", describe(out));
154}
155
156/// Asserts failure and returns the combined output for further checks.
157fn rejected(out: &Output, what: &str) -> String {
158 assert!(
159 !out.status.success(),
160 "{what} unexpectedly succeeded:\n{}",
161 describe(out)
162 );
163 describe(out)
164}
165
166fn git(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> Output {
167 let mut cmd = Command::new("git");
168 cmd.current_dir(cwd)
169 .args(["-c", "credential.helper="])
170 .args(args)
171 .env("GIT_TERMINAL_PROMPT", "0");
172 for (key, value) in env {
173 cmd.env(key, value);
174 }
175 cmd.output().expect("run git")
176}
177
178fn path_str(path: &Path) -> &str {
179 path.to_str().expect("utf-8 path")
180}
181
182/// The ssh command git runs for SSH remotes. Host key checks are off because the host key is
183/// new on every run.
184fn ssh_opts(key: &Path, ssh_port: u16) -> Vec<String> {
185 [
186 "-i",
187 path_str(key),
188 "-o",
189 "IdentitiesOnly=yes",
190 "-o",
191 "StrictHostKeyChecking=no",
192 "-o",
193 "UserKnownHostsFile=/dev/null",
194 "-o",
195 "BatchMode=yes",
196 "-o",
197 "LogLevel=ERROR",
198 "-p",
199 ]
200 .iter()
201 .map(|s| (*s).to_owned())
202 .chain(std::iter::once(ssh_port.to_string()))
203 .collect()
204}
205
206fn ssh_command(key: &Path, ssh_port: u16) -> String {
207 format!("ssh {}", ssh_opts(key, ssh_port).join(" "))
208}
209
210fn gen_key(path: &Path) {
211 let out = Command::new("ssh-keygen")
212 .args(["-t", "ed25519", "-N", "", "-q", "-f"])
213 .arg(path)
214 .output()
215 .expect("run ssh-keygen");
216 ok(&out, "ssh-keygen");
217}
218
219fn set_identity(repo: &Path) {
220 ok(
221 &git(repo, &["config", "user.name", "alice"], &[]),
222 "set user.name",
223 );
224 ok(
225 &git(repo, &["config", "user.email", "alice@example.com"], &[]),
226 "set user.email",
227 );
228}
229
230/// Writes a file and commits it in `repo` with the repo's local identity.
231fn commit_file(repo: &Path, name: &str, content: &str, message: &str) {
232 fs::write(repo.join(name), content).expect("write file");
233 set_identity(repo);
234 ok(&git(repo, &["add", name], &[]), "git add");
235 ok(
236 &git(
237 repo,
238 &["-c", "commit.gpgsign=false", "commit", "-q", "-m", message],
239 &[],
240 ),
241 "git commit",
242 );
243}
244
245fn head_sha(repo: &Path) -> String {
246 let out = git(repo, &["rev-parse", "HEAD"], &[]);
247 ok(&out, "rev-parse HEAD");
248 String::from_utf8_lossy(&out.stdout).trim().to_owned()
249}
250
251fn read(path: PathBuf) -> String {
252 fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
253}
254
255/// Logs in through the real `/login` form and returns the `__Host-forge_session` cookie header.
256async fn login(http: &Http, username: &str, password: &str) -> String {
257 let page = http.get("/login").await;
258 assert_eq!(page.status, 200, "GET /login");
259 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
260 let precsrf = input_value(&page.body, "precsrf");
261 let res = http
262 .post_form(
263 "/login",
264 &[
265 ("username", username),
266 ("password", password),
267 ("next", "/"),
268 ("precsrf", &precsrf),
269 ],
270 Some(&precsrf_cookie),
271 )
272 .await;
273 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
274 cookie_named(&res, "forge_session").expect("session cookie after login")
275}
276
277/// Creates an access token through `/settings/tokens` and returns the token shown once.
278async fn create_token(http: &Http, session: &str, name: &str) -> String {
279 let page = http.get_cookie("/settings/tokens", session).await;
280 assert_eq!(page.status, 200, "GET /settings/tokens");
281 let csrf = input_value(&page.body, "csrf");
282 let res = http
283 .post_form(
284 "/settings/tokens",
285 &[("csrf", &csrf), ("name", name), ("expires", "30")],
286 Some(session),
287 )
288 .await;
289 assert_eq!(res.status, 200, "create token: {}", res.body);
290 let doc = scraper::Html::parse_document(&res.body);
291 let selector = scraper::Selector::parse("pre.code code").expect("selector");
292 let token: String = doc
293 .select(&selector)
294 .next()
295 .expect("token shown after creation")
296 .text()
297 .collect();
298 let token = token.trim().to_owned();
299 assert!(token.starts_with("forge_"), "unexpected token shape");
300 token
301}
302
303/// Adds a public key through `/settings/keys`.
304async fn add_key(http: &Http, session: &str, public_key: &str) {
305 let page = http.get_cookie("/settings/keys", session).await;
306 assert_eq!(page.status, 200, "GET /settings/keys");
307 let csrf = input_value(&page.body, "csrf");
308 let res = http
309 .post_form(
310 "/settings/keys",
311 &[
312 ("csrf", &csrf),
313 ("title", "e2e"),
314 ("public_key", public_key),
315 ],
316 Some(session),
317 )
318 .await;
319 assert_eq!(res.status, 303, "add SSH key: {}", res.body);
320}
321
322/// Creates a repo through `/new`.
323async fn create_repo(http: &Http, session: &str, name: &str, visibility: &str) {
324 let page = http.get_cookie("/new", session).await;
325 assert_eq!(page.status, 200, "GET /new");
326 let csrf = input_value(&page.body, "csrf");
327 let res = http
328 .post_form(
329 "/new",
330 &[
331 ("csrf", &csrf),
332 ("name", name),
333 ("description", ""),
334 ("visibility", visibility),
335 ("default_branch", "main"),
336 ],
337 Some(session),
338 )
339 .await;
340 assert_eq!(res.status, 303, "create repo {name}: {}", res.body);
341 assert_eq!(
342 res.location.as_deref(),
343 Some(format!("/alice/{name}").as_str())
344 );
345}
346
347#[tokio::test]
348async fn git_end_to_end() {
349 for tool in ["git", "ssh", "ssh-keygen"] {
350 if !tool_on_path(tool) {
351 eprintln!(
352 "SKIP git_e2e: `{tool}` was not found on PATH, so the end-to-end test is skipped"
353 );
354 return;
355 }
356 }
357
358 // Keep the path short: the internal unix socket path is limited to 108 bytes.
359 let root = tempfile::Builder::new()
360 .prefix("fe2e")
361 .tempdir_in("/tmp")
362 .expect("temp dir");
363 let dir = root.path().to_path_buf();
364 let ports = pick_ports();
365 let config = write_config(&dir, ports);
366
367 run_admin(&config, "alice", &["--admin"], ALICE_PW);
368 run_admin(&config, "bob", &[], BOB_PW);
369
370 let (_server, ports) = launch(&dir, ports).await;
371 let http = Http::new(ports.http);
372 let (http_port, ssh_port) = (ports.http, ports.ssh);
373
374 // Web setup: alice logs in, makes a token, adds an SSH key and creates both repos.
375 let alice = login(&http, "alice", ALICE_PW).await;
376 let alice_token = create_token(&http, &alice, "e2e-token").await;
377
378 let key = dir.join("id");
379 gen_key(&key);
380 let public_key = fs::read_to_string(dir.join("id.pub")).expect("read id.pub");
381 add_key(&http, &alice, public_key.trim()).await;
382
383 create_repo(&http, &alice, "pub", "public").await;
384 create_repo(&http, &alice, "priv", "private").await;
385
386 // HTTPS: push to the public repo with the token, then read it back.
387 let pub_url = format!("http://alice:{alice_token}@127.0.0.1:{http_port}/alice/pub.git");
388 let pub_work = dir.join("pub-work");
389 ok(
390 &git(&dir, &["clone", &pub_url, path_str(&pub_work)], &[]),
391 "https clone of empty public repo",
392 );
393 commit_file(&pub_work, "hello.txt", "hello over https\n", "https commit");
394 ok(
395 &git(&pub_work, &["push", "origin", "HEAD:main"], &[]),
396 "https push",
397 );
398 let pushed_main = head_sha(&pub_work);
399
400 let pub_verify = dir.join("pub-verify");
401 ok(
402 &git(&dir, &["clone", &pub_url, path_str(&pub_verify)], &[]),
403 "https fresh clone",
404 );
405 assert_eq!(read(pub_verify.join("hello.txt")), "hello over https\n");
406
407 // Anonymous clone of a public repo must succeed.
408 let anon_url = format!("http://127.0.0.1:{http_port}/alice/pub.git");
409 let pub_anon = dir.join("pub-anon");
410 ok(
411 &git(&dir, &["clone", &anon_url, path_str(&pub_anon)], &[]),
412 "anonymous clone of public repo",
413 );
414 assert_eq!(read(pub_anon.join("hello.txt")), "hello over https\n");
415
416 // SSH: push to the private repo with the registered key, then read it back.
417 let ssh_cmd = ssh_command(&key, ssh_port);
418 let ssh_env = [("GIT_SSH_COMMAND", ssh_cmd.as_str())];
419 let priv_ssh_url = format!("ssh://git@127.0.0.1:{ssh_port}/alice/priv.git");
420 let priv_work = dir.join("priv-work");
421 ok(
422 &git(
423 &dir,
424 &["clone", &priv_ssh_url, path_str(&priv_work)],
425 &ssh_env,
426 ),
427 "ssh clone of empty private repo",
428 );
429 commit_file(&priv_work, "secret.txt", "over ssh\n", "ssh commit");
430 ok(
431 &git(&priv_work, &["push", "origin", "HEAD:main"], &ssh_env),
432 "ssh push",
433 );
434 let priv_verify = dir.join("priv-verify");
435 ok(
436 &git(
437 &dir,
438 &["clone", &priv_ssh_url, path_str(&priv_verify)],
439 &ssh_env,
440 ),
441 "ssh fresh clone",
442 );
443 assert_eq!(read(priv_verify.join("secret.txt")), "over ssh\n");
444
445 // Negative: private repo without credentials.
446 let priv_https_anon = format!("http://127.0.0.1:{http_port}/alice/priv.git");
447 rejected(
448 &git(&dir, &["clone", &priv_https_anon, "anon-priv"], &[]),
449 "anonymous clone of private repo",
450 );
451 let refs_path = "/alice/priv.git/info/refs?service=git-upload-pack";
452 let anon_refs = http.get(refs_path).await;
453 assert_eq!(anon_refs.status, 401, "anonymous info/refs on private repo");
454 assert!(
455 anon_refs.www_authenticate.is_some(),
456 "401 must carry WWW-Authenticate"
457 );
458
459 // Negative: bob is signed in but has no access, so the private repo looks absent.
460 let bob = login(&http, "bob", BOB_PW).await;
461 let bob_page = http.get_cookie("/alice/priv", &bob).await;
462 assert_eq!(bob_page.status, 404, "bob's web view of alice/priv");
463 let bob_token = create_token(&http, &bob, "bob-token").await;
464 let bob_refs = http.get_basic(refs_path, "bob", &bob_token).await;
465 assert_eq!(bob_refs.status, 404, "bob's info/refs on alice/priv");
466
467 // Negative: a wrong token is rejected.
468 let wrong = http
469 .get_basic(refs_path, "alice", "forge_notarealtoken")
470 .await;
471 assert_eq!(wrong.status, 401, "wrong token on info/refs");
472 let wrong_url =
473 format!("http://alice:forge_notarealtoken@127.0.0.1:{http_port}/alice/priv.git");
474 rejected(
475 &git(&dir, &["clone", &wrong_url, "wrong-token-clone"], &[]),
476 "clone with wrong token",
477 );
478
479 // Negative: deleting the default branch is rejected by pre-receive.
480 let delete = git(&pub_verify, &["push", "origin", "--delete", "main"], &[]);
481 let text = rejected(&delete, "deleting main");
482 assert!(
483 text.contains("deleting protected branch 'main' is not allowed"),
484 "delete message: {text}"
485 );
486
487 // Negative: a force-push to main after amending the commit is rejected.
488 set_identity(&pub_verify);
489 ok(
490 &git(
491 &pub_verify,
492 &[
493 "-c",
494 "commit.gpgsign=false",
495 "commit",
496 "--amend",
497 "-q",
498 "-m",
499 "rewritten",
500 ],
501 &[],
502 ),
503 "amend commit",
504 );
505 let force = git(&pub_verify, &["push", "-f", "origin", "HEAD:main"], &[]);
506 let text = rejected(&force, "force-push to main");
507 assert!(
508 text.contains("force-push to protected branch 'main' is not allowed"),
509 "force-push message: {text}"
510 );
511
512 // main must still point at the commit that was pushed.
513 let ls = git(&dir, &["ls-remote", &pub_url, "refs/heads/main"], &[]);
514 ok(&ls, "ls-remote main");
515 let remote_main = String::from_utf8_lossy(&ls.stdout);
516 assert!(
517 remote_main.starts_with(&pushed_main),
518 "main moved after rejected pushes: {remote_main}"
519 );
520
521 // Negative: an SSH key that is not registered is refused.
522 let rogue = dir.join("rogue");
523 gen_key(&rogue);
524 let rogue_cmd = ssh_command(&rogue, ssh_port);
525 let rogue_env = [("GIT_SSH_COMMAND", rogue_cmd.as_str())];
526 let text = rejected(
527 &git(&dir, &["clone", &priv_ssh_url, "rogue-clone"], &rogue_env),
528 "clone with unregistered key",
529 );
530 assert!(
531 text.contains("Permission denied"),
532 "rogue key message: {text}"
533 );
534
535 // Negative: an injected path in an SSH exec request is refused by the strict parser.
536 let injected = Command::new("ssh")
537 .args(ssh_opts(&key, ssh_port))
538 .arg("git@127.0.0.1")
539 .arg("git-upload-pack '../../etc'")
540 .output()
541 .expect("run ssh");
542 rejected(&injected, "ssh exec with injected path");
543
544 // Negative: POST /new without a csrf field is forbidden.
545 let no_csrf = http
546 .post_form(
547 "/new",
548 &[
549 ("name", "nocsrf"),
550 ("visibility", "public"),
551 ("default_branch", "main"),
552 ],
553 Some(&alice),
554 )
555 .await;
556 assert_eq!(no_csrf.status, 403, "POST /new without csrf");
557
558 // Negative: the dumb protocol (info/refs without ?service=) is forbidden.
559 let dumb = http.get("/alice/pub.git/info/refs").await;
560 assert_eq!(dumb.status, 403, "info/refs without service");
561 assert!(
562 dumb.body.contains("dumb protocol"),
563 "dumb body: {}",
564 dumb.body
565 );
566}