forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
21.5 KiB
1//! End-to-end: pull requests against the real `forge` binary, with real `git` pushes.
2//!
3//! Each test boots `forge serve` on random ports with a temp data dir, signs in through the
4//! `/login` form, creates tokens, and pushes over HTTPS. Pages are scraped for CSRF tokens. The
5//! scenarios cover a same-repo merge that closes an issue, a push that updates an open pull,
6//! a fork pull merged by squash, and a conflict that blocks the merge.
7
8mod common;
9use common::{BIN, Server, launch, pick_ports, tool_on_path, write_config};
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::process::{Command, Output};
13use std::time::{Duration, Instant};
14
15use reqwest::header::{LOCATION, SET_COOKIE};
16
17const ALICE_PW: &str = "alice-password-123";
18const BOB_PW: &str = "bob-password-4567";
19const BOB_EMAIL: &str = "bob@users.noreply.127.0.0.1";
20const REPO_PATH: &str = "/alice/r";
21
22struct Resp {
23 status: u16,
24 location: Option<String>,
25 set_cookies: Vec<String>,
26 body: String,
27}
28
29struct Http {
30 client: reqwest::Client,
31 base: String,
32}
33
34impl Http {
35 fn new(port: u16) -> Self {
36 let client = reqwest::Client::builder()
37 .redirect(reqwest::redirect::Policy::none())
38 .timeout(Duration::from_secs(60))
39 .build()
40 .expect("http client");
41 Self {
42 client,
43 base: format!("http://127.0.0.1:{port}"),
44 }
45 }
46
47 async fn get(&self, path: &str) -> Resp {
48 send(self.client.get(format!("{}{path}", self.base))).await
49 }
50
51 async fn get_cookie(&self, path: &str, cookie: &str) -> Resp {
52 let req = self
53 .client
54 .get(format!("{}{path}", self.base))
55 .header(reqwest::header::COOKIE, cookie);
56 send(req).await
57 }
58
59 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
60 let mut req = self.client.post(format!("{}{path}", self.base)).form(form);
61 if let Some(cookie) = cookie {
62 req = req.header(reqwest::header::COOKIE, cookie);
63 }
64 send(req).await
65 }
66}
67
68async fn send(req: reqwest::RequestBuilder) -> Resp {
69 let res = req.send().await.expect("http request");
70 let status = res.status().as_u16();
71 let location = res
72 .headers()
73 .get(LOCATION)
74 .and_then(|v| v.to_str().ok())
75 .map(str::to_owned);
76 let set_cookies = res
77 .headers()
78 .get_all(SET_COOKIE)
79 .iter()
80 .filter_map(|v| v.to_str().ok().map(str::to_owned))
81 .collect();
82 let body = res.text().await.unwrap_or_default();
83 Resp {
84 status,
85 location,
86 set_cookies,
87 body,
88 }
89}
90
91/// Returns `name=value` for the cookie whose name ends with `suffix`. Cleared cookies are ignored.
92fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
93 resp.set_cookies.iter().find_map(|set| {
94 let pair = set.split(';').next()?;
95 let (name, value) = pair.split_once('=')?;
96 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
97 })
98}
99
100/// The value of the first `<input name="...">` on a page.
101fn input_value(body: &str, name: &str) -> String {
102 let doc = scraper::Html::parse_document(body);
103 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
104 doc.select(&selector)
105 .next()
106 .and_then(|el| el.value().attr("value"))
107 .unwrap_or_else(|| panic!("no input named {name} on page"))
108 .to_owned()
109}
110
111/// Text of the first state badge on a page, e.g. `Open`, `Closed` or `Merged`.
112fn state_badge(body: &str) -> String {
113 let doc = scraper::Html::parse_document(body);
114 let selector = scraper::Selector::parse(".issue-badge").expect("selector");
115 doc.select(&selector)
116 .next()
117 .map(|el| el.text().collect::<String>().trim().to_owned())
118 .unwrap_or_default()
119}
120
121fn run_admin(config: &Path, username: &str, extra: &[&str], password: &str) {
122 let out = Command::new(BIN)
123 .arg("--config")
124 .arg(config)
125 .args(["admin", "create-user", username])
126 .args(extra)
127 .env("FORGE_PASSWORD", password)
128 .output()
129 .expect("run admin create-user");
130 assert!(
131 out.status.success(),
132 "create-user {username} failed:\n{}",
133 String::from_utf8_lossy(&out.stderr)
134 );
135}
136
137/// Runs git with credential helpers disabled and extra env, returning stdout trimmed. Panics on
138/// failure.
139fn git_env(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> String {
140 let mut cmd = Command::new("git");
141 cmd.current_dir(cwd)
142 .args(["-c", "credential.helper="])
143 .args(args)
144 .env("GIT_TERMINAL_PROMPT", "0");
145 for (key, value) in env {
146 cmd.env(key, value);
147 }
148 let out: Output = cmd.output().expect("run git");
149 assert!(
150 out.status.success(),
151 "git {args:?} failed:\n{}",
152 String::from_utf8_lossy(&out.stderr)
153 );
154 String::from_utf8_lossy(&out.stdout).trim().to_owned()
155}
156
157fn git(cwd: &Path, args: &[&str]) -> String {
158 git_env(cwd, args, &[])
159}
160
161/// Writes `file`, commits it with `who` as author and committer, and returns the new HEAD.
162fn commit(
163 repo: &Path,
164 file: &str,
165 contents: &str,
166 message: &str,
167 who: &str,
168 email: &str,
169) -> String {
170 fs::write(repo.join(file), contents).expect("write file");
171 git(repo, &["add", file]);
172 git_env(
173 repo,
174 &["-c", "commit.gpgsign=false", "commit", "-q", "-m", message],
175 &[
176 ("GIT_AUTHOR_NAME", who),
177 ("GIT_AUTHOR_EMAIL", email),
178 ("GIT_COMMITTER_NAME", who),
179 ("GIT_COMMITTER_EMAIL", email),
180 ],
181 );
182 git(repo, &["rev-parse", "HEAD"])
183}
184
185/// Clones `url` into `into` (a path that does not exist yet) and returns it.
186fn clone(url: &str, into: &Path) -> PathBuf {
187 let parent = into.parent().expect("clone target has a parent");
188 git(
189 parent,
190 &["clone", "-q", url, into.to_str().expect("utf-8 path")],
191 );
192 into.to_path_buf()
193}
194
195/// The HTTPS remote for `owner/repo`, authenticated with a token.
196fn remote(port: u16, user: &str, token: &str, owner: &str, repo: &str) -> String {
197 format!("http://{user}:{token}@127.0.0.1:{port}/{owner}/{repo}.git")
198}
199
200/// The commit a remote branch points at, from `git ls-remote`.
201fn remote_sha(dir: &Path, url: &str, branch: &str) -> String {
202 let out = git(dir, &["ls-remote", url, &format!("refs/heads/{branch}")]);
203 out.split_whitespace()
204 .next()
205 .unwrap_or_else(|| panic!("branch {branch} missing on remote"))
206 .to_owned()
207}
208
209/// Number of parents of `commit` in the repository at `dir`.
210fn parent_count(dir: &Path, commit: &str) -> usize {
211 let line = git(dir, &["rev-list", "--parents", "-n1", commit]);
212 line.split_whitespace().count() - 1
213}
214
215/// Logs in through the real `/login` form and returns the session cookie header.
216async fn login(http: &Http, username: &str, password: &str) -> String {
217 let page = http.get("/login").await;
218 assert_eq!(page.status, 200, "GET /login");
219 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
220 let precsrf = input_value(&page.body, "precsrf");
221 let res = http
222 .post_form(
223 "/login",
224 &[
225 ("username", username),
226 ("password", password),
227 ("next", "/"),
228 ("precsrf", &precsrf),
229 ],
230 Some(&precsrf_cookie),
231 )
232 .await;
233 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
234 cookie_named(&res, "forge_session").expect("session cookie after login")
235}
236
237/// The session's CSRF token, scraped from the token settings page.
238async fn csrf_token(http: &Http, session: &str) -> String {
239 let page = http.get_cookie("/settings/tokens", session).await;
240 assert_eq!(page.status, 200, "GET /settings/tokens");
241 input_value(&page.body, "csrf")
242}
243
244/// Creates an access token through `/settings/tokens` and returns the token shown once.
245async fn create_token(http: &Http, session: &str, name: &str) -> String {
246 let csrf = csrf_token(http, session).await;
247 let res = http
248 .post_form(
249 "/settings/tokens",
250 &[("csrf", &csrf), ("name", name), ("expires", "30")],
251 Some(session),
252 )
253 .await;
254 assert_eq!(res.status, 200, "create token: {}", res.body);
255 let doc = scraper::Html::parse_document(&res.body);
256 let selector = scraper::Selector::parse("pre.code code").expect("selector");
257 let token: String = doc
258 .select(&selector)
259 .next()
260 .expect("token shown after creation")
261 .text()
262 .collect();
263 let token = token.trim().to_owned();
264 assert!(token.starts_with("forge_"), "unexpected token shape");
265 token
266}
267
268/// Creates a public repo `name` owned by alice through `/new`.
269async fn create_repo(http: &Http, session: &str, name: &str) {
270 let page = http.get_cookie("/new", session).await;
271 assert_eq!(page.status, 200, "GET /new");
272 let csrf = input_value(&page.body, "csrf");
273 let res = http
274 .post_form(
275 "/new",
276 &[
277 ("csrf", &csrf),
278 ("name", name),
279 ("description", ""),
280 ("visibility", "public"),
281 ("default_branch", "main"),
282 ],
283 Some(session),
284 )
285 .await;
286 assert_eq!(res.status, 303, "create repo {name}: {}", res.body);
287 assert_eq!(
288 res.location.as_deref(),
289 Some(format!("/alice/{name}").as_str())
290 );
291}
292
293/// A running server with its own temp data dir. Field order matters: the server is dropped (and
294/// killed) before the temp dir is removed.
295struct Booted {
296 _server: Server,
297 _root: tempfile::TempDir,
298 dir: PathBuf,
299 http: Http,
300 http_port: u16,
301}
302
303/// The server's stdout and stderr, for assertion messages.
304fn server_log(b: &Booted) -> String {
305 fs::read_to_string(b.dir.join("serve.log")).unwrap_or_default()
306}
307
308/// Creates alice (admin) and bob, then boots the server on fresh ports.
309async fn boot() -> Booted {
310 if !tool_on_path("git") {
311 panic!("`git` was not found on PATH");
312 }
313 // Keep the path short: the internal unix socket path is limited to 108 bytes.
314 let root = tempfile::Builder::new()
315 .prefix("pr2e")
316 .tempdir_in("/tmp")
317 .expect("temp dir");
318 let dir = root.path().to_path_buf();
319 let ports = pick_ports();
320 let config = write_config(&dir, ports);
321
322 run_admin(&config, "alice", &["--admin"], ALICE_PW);
323 run_admin(&config, "bob", &[], BOB_PW);
324 let (server, ports) = launch(&dir, ports).await;
325 Booted {
326 _server: server,
327 _root: root,
328 dir,
329 http: Http::new(ports.http),
330 http_port: ports.http,
331 }
332}
333
334/// Creates `alice/r` with `main` pushed as alice, and returns alice's session and token.
335async fn alice_repo_with_main(b: &Booted) -> (String, String, PathBuf) {
336 let alice = login(&b.http, "alice", ALICE_PW).await;
337 let token = create_token(&b.http, &alice, "pulls-token").await;
338 create_repo(&b.http, &alice, "r").await;
339 let work = clone(
340 &remote(b.http_port, "alice", &token, "alice", "r"),
341 &b.dir.join("alice-work"),
342 );
343 commit(
344 &work,
345 "base.txt",
346 "base\n",
347 "base commit",
348 "alice",
349 "alice@example.test",
350 );
351 git(&work, &["push", "-q", "origin", "HEAD:refs/heads/main"]);
352 (alice, token, work)
353}
354
355/// Opens a pull request `base...head` on alice/r through the compare form. Returns its number.
356async fn open_pull(http: &Http, session: &str, base: &str, head: &str, title: &str) -> u32 {
357 let compare = http
358 .get_cookie(&format!("{REPO_PATH}/compare/{base}...{head}"), session)
359 .await;
360 assert_eq!(compare.status, 200, "GET compare {base}...{head}");
361 let csrf = csrf_token(http, session).await;
362 let res = http
363 .post_form(
364 &format!("{REPO_PATH}/pulls"),
365 &[
366 ("csrf", &csrf),
367 ("title", title),
368 ("body", ""),
369 ("base", base),
370 ("head", head),
371 ],
372 Some(session),
373 )
374 .await;
375 assert_eq!(res.status, 303, "open pull {head}: {}", res.body);
376 let location = res.location.expect("redirect to the pull");
377 location
378 .rsplit('/')
379 .next()
380 .and_then(|n| n.parse().ok())
381 .unwrap_or_else(|| panic!("no pull number in {location}"))
382}
383
384/// Creates an issue on alice/r through the web form. Returns its number.
385async fn open_issue(http: &Http, session: &str, title: &str) -> u32 {
386 let page = http
387 .get_cookie(&format!("{REPO_PATH}/issues/new"), session)
388 .await;
389 assert_eq!(page.status, 200, "GET issues/new");
390 let csrf = input_value(&page.body, "csrf");
391 let res = http
392 .post_form(
393 &format!("{REPO_PATH}/issues/new"),
394 &[("csrf", &csrf), ("title", title), ("body", "details")],
395 Some(session),
396 )
397 .await;
398 assert_eq!(res.status, 303, "create issue: {}", res.body);
399 let location = res.location.expect("redirect to the issue");
400 location
401 .rsplit('/')
402 .next()
403 .and_then(|n| n.parse().ok())
404 .unwrap_or_else(|| panic!("no issue number in {location}"))
405}
406
407/// Polls `path` until `done` accepts the body, failing after 10 seconds.
408async fn wait_page(
409 http: &Http,
410 session: &str,
411 path: &str,
412 what: &str,
413 done: impl Fn(&str) -> bool,
414) -> String {
415 let deadline = Instant::now() + Duration::from_secs(10);
416 loop {
417 let res = http.get_cookie(path, session).await;
418 assert_eq!(res.status, 200, "GET {path}");
419 if done(&res.body) {
420 return res.body;
421 }
422 assert!(
423 Instant::now() < deadline,
424 "timed out after 10s waiting for {what} on {path}"
425 );
426 tokio::time::sleep(Duration::from_millis(200)).await;
427 }
428}
429
430/// Posts a merge for pull `number` with `style` as `session`.
431async fn merge(http: &Http, session: &str, number: u32, style: &str) -> Resp {
432 let csrf = csrf_token(http, session).await;
433 http.post_form(
434 &format!("{REPO_PATH}/pulls/{number}/merge"),
435 &[("csrf", &csrf), ("style", style), ("message", "")],
436 Some(session),
437 )
438 .await
439}
440
441#[tokio::test]
442async fn same_repo_pull_merges_and_closes_issue() {
443 let b = boot().await;
444 let (alice, token, work) = alice_repo_with_main(&b).await;
445
446 let issue = open_issue(&b.http, &alice, "add x").await;
447 assert_eq!(issue, 1);
448
449 git(&work, &["checkout", "-q", "-b", "feature"]);
450 let feature_head = commit(
451 &work,
452 "x.txt",
453 "x\n",
454 "Add x\n\nFixes #1",
455 "alice",
456 "alice@example.test",
457 );
458 git(&work, &["push", "-q", "origin", "HEAD:refs/heads/feature"]);
459
460 let compare = b
461 .http
462 .get_cookie(&format!("{REPO_PATH}/compare/main...feature"), &alice)
463 .await;
464 assert_eq!(compare.status, 200, "GET compare");
465 assert!(compare.body.contains("Add x"), "compare lists the commit");
466
467 let number = open_pull(&b.http, &alice, "main", "feature", "Add x").await;
468 assert_eq!(number, 2, "the pull shares the issue number sequence");
469
470 let files = b
471 .http
472 .get_cookie(&format!("{REPO_PATH}/pulls/2/files"), &alice)
473 .await;
474 assert_eq!(files.status, 200, "GET pull files");
475 assert!(files.body.contains("x.txt"), "files tab shows x.txt");
476
477 let res = merge(&b.http, &alice, 2, "merge").await;
478 assert_eq!(res.status, 303, "merge: {}", res.body);
479 assert_eq!(res.location.as_deref(), Some("/alice/r/pulls/2"));
480
481 let url = remote(b.http_port, "alice", &token, "alice", "r");
482 let main_sha = remote_sha(&b.dir, &url, "main");
483 assert_ne!(main_sha, feature_head, "main is a new merge commit");
484 let check = clone(&url, &b.dir.join("check"));
485 assert_eq!(
486 parent_count(&check, "HEAD"),
487 2,
488 "main tip is a merge commit with two parents"
489 );
490 assert!(
491 git(&check, &["log", "--format=%s", "-n", "5"]).contains("Add x"),
492 "feature commit reachable from main"
493 );
494
495 let pr = b
496 .http
497 .get_cookie(&format!("{REPO_PATH}/pulls/2"), &alice)
498 .await;
499 assert_eq!(state_badge(&pr.body), "Merged");
500
501 wait_page(
502 &b.http,
503 &alice,
504 &format!("{REPO_PATH}/issues/1"),
505 "issue closed",
506 |body| state_badge(body) == "Closed",
507 )
508 .await;
509}
510
511#[tokio::test]
512async fn push_to_head_branch_updates_open_pull() {
513 let b = boot().await;
514 let (alice, _token, work) = alice_repo_with_main(&b).await;
515
516 git(&work, &["checkout", "-q", "-b", "feature2"]);
517 commit(
518 &work,
519 "one.txt",
520 "1\n",
521 "first on feature2",
522 "alice",
523 "alice@example.test",
524 );
525 git(&work, &["push", "-q", "origin", "HEAD:refs/heads/feature2"]);
526 let number = open_pull(&b.http, &alice, "main", "feature2", "Feature two").await;
527
528 let second = commit(
529 &work,
530 "two.txt",
531 "2\n",
532 "second on feature2",
533 "alice",
534 "alice@example.test",
535 );
536 git(&work, &["push", "-q", "origin", "HEAD:refs/heads/feature2"]);
537
538 let path = format!("{REPO_PATH}/pulls/{number}");
539 let page = wait_page(&b.http, &alice, &path, "head_updated event", |body| {
540 body.contains("pushed new commits")
541 })
542 .await;
543 assert!(page.contains("pushed new commits"));
544
545 let commits = b.http.get_cookie(&format!("{path}/commits"), &alice).await;
546 assert_eq!(commits.status, 200, "GET pull commits");
547 assert!(
548 commits.body.contains("second on feature2"),
549 "new commit listed"
550 );
551 assert!(
552 commits.body.contains("first on feature2"),
553 "old commit listed"
554 );
555 assert!(commits.body.contains(&second[..7]), "new commit sha shown");
556}
557
558#[tokio::test]
559async fn fork_pull_squash_merge_credits_contributor() {
560 let b = boot().await;
561 let (alice, alice_token, _work) = alice_repo_with_main(&b).await;
562
563 let bob = login(&b.http, "bob", BOB_PW).await;
564 let bob_token = create_token(&b.http, &bob, "bob-token").await;
565
566 // Bob forks alice/r. The fork keeps the source name under bob's owner.
567 let csrf = csrf_token(&b.http, &bob).await;
568 let fork = b
569 .http
570 .post_form(&format!("{REPO_PATH}/fork"), &[("csrf", &csrf)], Some(&bob))
571 .await;
572 assert_eq!(fork.status, 303, "fork: {}\n{}", fork.body, server_log(&b));
573 assert_eq!(fork.location.as_deref(), Some("/bob/r"));
574
575 let bob_url = remote(b.http_port, "bob", &bob_token, "bob", "r");
576 let bob_work = clone(&bob_url, &b.dir.join("bob-work"));
577 git(&bob_work, &["checkout", "-q", "-b", "bob-feature"]);
578 let bob_head = commit(
579 &bob_work,
580 "bob.txt",
581 "bob\n",
582 "Bob change",
583 "bob",
584 BOB_EMAIL,
585 );
586 git(
587 &bob_work,
588 &["push", "-q", "origin", "HEAD:refs/heads/bob-feature"],
589 );
590
591 let number = open_pull(&b.http, &bob, "main", "bob:bob-feature", "Bob change").await;
592
593 // Bob can open the pull but cannot merge it.
594 let denied = merge(&b.http, &bob, number, "merge").await;
595 assert_eq!(denied.status, 403, "bob must not merge");
596
597 let res = merge(&b.http, &alice, number, "squash").await;
598 assert_eq!(res.status, 303, "alice squash merge: {}", res.body);
599
600 let alice_url = remote(b.http_port, "alice", &alice_token, "alice", "r");
601 let check = clone(&alice_url, &b.dir.join("check"));
602 assert_eq!(parent_count(&check, "HEAD"), 1, "squash makes one parent");
603 assert_ne!(
604 git(&check, &["rev-parse", "HEAD"]),
605 bob_head,
606 "main has a new squash commit, not bob's commit"
607 );
608 assert_eq!(git(&check, &["log", "-1", "--format=%an"]), "bob");
609 assert_eq!(git(&check, &["log", "-1", "--format=%ae"]), BOB_EMAIL);
610}
611
612#[tokio::test]
613async fn conflicting_pull_cannot_merge() {
614 let b = boot().await;
615 let (alice, token, work) = alice_repo_with_main(&b).await;
616
617 // Both sides edit the same line. The pull is opened while main still matches its base.
618 git(&work, &["checkout", "-q", "-b", "conflict"]);
619 commit(
620 &work,
621 "shared.txt",
622 "branch side\n",
623 "branch edit",
624 "alice",
625 "alice@example.test",
626 );
627 git(&work, &["push", "-q", "origin", "HEAD:refs/heads/conflict"]);
628 let number = open_pull(&b.http, &alice, "main", "conflict", "Conflicting").await;
629
630 git(&work, &["checkout", "-q", "main"]);
631 let main_head = commit(
632 &work,
633 "shared.txt",
634 "main side\n",
635 "main edit",
636 "alice",
637 "alice@example.test",
638 );
639 git(&work, &["push", "-q", "origin", "HEAD:refs/heads/main"]);
640
641 let path = format!("{REPO_PATH}/pulls/{number}");
642 let page = wait_page(&b.http, &alice, &path, "conflict detection", |body| {
643 body.contains("This branch has conflicts that must be resolved")
644 })
645 .await;
646 assert!(page.contains("shared.txt"), "conflict lists the file");
647 assert!(
648 !page.contains("merge-form"),
649 "no merge form on a conflicting pull"
650 );
651
652 let res = merge(&b.http, &alice, number, "merge").await;
653 assert_eq!(
654 res.status,
655 303,
656 "conflicting merge: {}\n{}",
657 res.body,
658 server_log(&b)
659 );
660 assert_eq!(
661 res.location.as_deref(),
662 Some(format!("{REPO_PATH}/pulls/{number}?merge_error=conflict").as_str()),
663 "refusal redirects back to the pull with the conflict code"
664 );
665
666 let url = remote(b.http_port, "alice", &token, "alice", "r");
667 assert_eq!(
668 remote_sha(&b.dir, &url, "main"),
669 main_head,
670 "main is unchanged by the refused merge"
671 );
672}