forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
13.2 KiB
1//! End-to-end: pushes over HTTPS close and reference issues from commit messages.
2//!
3//! The server runs as a subprocess. Issues are created through the store on the same database
4//! file, and every push is a real `git push` through the git HTTP endpoints.
5
6mod common;
7use common::{BIN, launch, pick_ports, relaunch, tool_on_path, write_config};
8use std::fs;
9use std::future::Future;
10use std::path::Path;
11use std::process::{Command, Output};
12use std::time::{Duration, Instant};
13
14use forge_core::Store;
15use reqwest::header::{LOCATION, SET_COOKIE};
16
17const ALICE_PW: &str = "alice-password-123";
18const BOB_PW: &str = "bob-password-4567";
19
20struct Resp {
21 status: u16,
22 location: Option<String>,
23 set_cookies: Vec<String>,
24 body: String,
25}
26
27struct Http {
28 client: reqwest::Client,
29 base: String,
30}
31
32impl Http {
33 fn new(port: u16) -> Self {
34 let client = reqwest::Client::builder()
35 .redirect(reqwest::redirect::Policy::none())
36 .timeout(Duration::from_secs(60))
37 .build()
38 .expect("http client");
39 Self {
40 client,
41 base: format!("http://127.0.0.1:{port}"),
42 }
43 }
44
45 async fn get_cookie(&self, path: &str, cookie: &str) -> Resp {
46 let req = self
47 .client
48 .get(format!("{}{path}", self.base))
49 .header(reqwest::header::COOKIE, cookie);
50 send(req).await
51 }
52
53 async fn get(&self, path: &str) -> Resp {
54 send(self.client.get(format!("{}{path}", self.base))).await
55 }
56
57 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
58 let mut req = self.client.post(format!("{}{path}", self.base)).form(form);
59 if let Some(cookie) = cookie {
60 req = req.header(reqwest::header::COOKIE, cookie);
61 }
62 send(req).await
63 }
64}
65
66async fn send(req: reqwest::RequestBuilder) -> Resp {
67 let res = req.send().await.expect("http request");
68 let status = res.status().as_u16();
69 let location = res
70 .headers()
71 .get(LOCATION)
72 .and_then(|v| v.to_str().ok())
73 .map(str::to_owned);
74 let set_cookies = res
75 .headers()
76 .get_all(SET_COOKIE)
77 .iter()
78 .filter_map(|v| v.to_str().ok().map(str::to_owned))
79 .collect();
80 let body = res.text().await.unwrap_or_default();
81 Resp {
82 status,
83 location,
84 set_cookies,
85 body,
86 }
87}
88
89/// Returns `name=value` for the cookie whose name ends with `suffix`. Cleared cookies are ignored.
90fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
91 resp.set_cookies.iter().find_map(|set| {
92 let pair = set.split(';').next()?;
93 let (name, value) = pair.split_once('=')?;
94 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
95 })
96}
97
98/// The value of the first `<input name="...">` on a page.
99fn input_value(body: &str, name: &str) -> String {
100 let doc = scraper::Html::parse_document(body);
101 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
102 doc.select(&selector)
103 .next()
104 .and_then(|el| el.value().attr("value"))
105 .unwrap_or_else(|| panic!("no input named {name} on page"))
106 .to_owned()
107}
108
109fn run_admin(config: &Path, username: &str, extra: &[&str], password: &str) {
110 let out = Command::new(BIN)
111 .arg("--config")
112 .arg(config)
113 .args(["admin", "create-user", username])
114 .args(extra)
115 .env("FORGE_PASSWORD", password)
116 .output()
117 .expect("run admin create-user");
118 assert!(
119 out.status.success(),
120 "create-user {username} failed:\n{}",
121 String::from_utf8_lossy(&out.stderr)
122 );
123}
124
125/// Runs git with credential helpers disabled and returns stdout, trimmed. Panics on failure.
126fn git(cwd: &Path, args: &[&str]) -> String {
127 let out = git_output(cwd, args, &[]);
128 assert!(
129 out.status.success(),
130 "git {args:?} failed:\n{}",
131 String::from_utf8_lossy(&out.stderr)
132 );
133 String::from_utf8_lossy(&out.stdout).trim().to_owned()
134}
135
136fn git_output(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> Output {
137 let mut cmd = Command::new("git");
138 cmd.current_dir(cwd)
139 .args(["-c", "credential.helper="])
140 .args(args)
141 .env("GIT_TERMINAL_PROMPT", "0");
142 for (key, value) in env {
143 cmd.env(key, value);
144 }
145 cmd.output().expect("run git")
146}
147
148/// Writes `name`, commits it as alice, and returns the new HEAD.
149fn commit_file(repo: &Path, name: &str, message: &str) -> String {
150 fs::write(repo.join(name), message).expect("write file");
151 git(repo, &["add", name]);
152 git(
153 repo,
154 &[
155 "-c",
156 "user.name=alice",
157 "-c",
158 "user.email=alice@example.test",
159 "-c",
160 "commit.gpgsign=false",
161 "commit",
162 "-q",
163 "-m",
164 message,
165 ],
166 );
167 git(repo, &["rev-parse", "HEAD"])
168}
169
170/// Logs in through the real `/login` form and returns the session cookie header.
171async fn login(http: &Http, username: &str, password: &str) -> String {
172 let page = http.get("/login").await;
173 assert_eq!(page.status, 200, "GET /login");
174 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
175 let precsrf = input_value(&page.body, "precsrf");
176 let res = http
177 .post_form(
178 "/login",
179 &[
180 ("username", username),
181 ("password", password),
182 ("next", "/"),
183 ("precsrf", &precsrf),
184 ],
185 Some(&precsrf_cookie),
186 )
187 .await;
188 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
189 cookie_named(&res, "forge_session").expect("session cookie after login")
190}
191
192/// Creates an access token through `/settings/tokens` and returns the token shown once.
193async fn create_token(http: &Http, session: &str, name: &str) -> String {
194 let page = http.get_cookie("/settings/tokens", session).await;
195 assert_eq!(page.status, 200, "GET /settings/tokens");
196 let csrf = input_value(&page.body, "csrf");
197 let res = http
198 .post_form(
199 "/settings/tokens",
200 &[("csrf", &csrf), ("name", name), ("expires", "30")],
201 Some(session),
202 )
203 .await;
204 assert_eq!(res.status, 200, "create token: {}", res.body);
205 let doc = scraper::Html::parse_document(&res.body);
206 let selector = scraper::Selector::parse("pre.code code").expect("selector");
207 let token: String = doc
208 .select(&selector)
209 .next()
210 .expect("token shown after creation")
211 .text()
212 .collect();
213 let token = token.trim().to_owned();
214 assert!(token.starts_with("forge_"), "unexpected token shape");
215 token
216}
217
218/// Creates a public repo through `/new`.
219async fn create_repo(http: &Http, session: &str, name: &str) {
220 let page = http.get_cookie("/new", session).await;
221 assert_eq!(page.status, 200, "GET /new");
222 let csrf = input_value(&page.body, "csrf");
223 let res = http
224 .post_form(
225 "/new",
226 &[
227 ("csrf", &csrf),
228 ("name", name),
229 ("description", ""),
230 ("visibility", "public"),
231 ("default_branch", "main"),
232 ],
233 Some(session),
234 )
235 .await;
236 assert_eq!(res.status, 303, "create repo {name}: {}", res.body);
237 assert_eq!(
238 res.location.as_deref(),
239 Some(format!("/alice/{name}").as_str())
240 );
241}
242
243/// Polls `check` every 100ms until it returns true, failing after 5 seconds.
244async fn eventually<F, Fut>(what: &str, mut check: F)
245where
246 F: FnMut() -> Fut,
247 Fut: Future<Output = bool>,
248{
249 let deadline = Instant::now() + Duration::from_secs(5);
250 while !check().await {
251 assert!(
252 Instant::now() < deadline,
253 "timed out after 5s waiting for {what}"
254 );
255 tokio::time::sleep(Duration::from_millis(100)).await;
256 }
257}
258
259/// The issue page for `number`, read as `session`.
260async fn issue_page(http: &Http, session: &str, number: u32) -> String {
261 let res = http
262 .get_cookie(&format!("/alice/pub/issues/{number}"), session)
263 .await;
264 assert_eq!(res.status, 200, "GET issue {number}");
265 res.body
266}
267
268/// Text of the state badge on an issue page, e.g. `Open` or `Closed`.
269fn state_badge(body: &str) -> String {
270 let doc = scraper::Html::parse_document(body);
271 let selector = scraper::Selector::parse(".issue-badge").expect("selector");
272 doc.select(&selector)
273 .next()
274 .map(|el| el.text().collect::<String>().trim().to_owned())
275 .unwrap_or_default()
276}
277
278#[tokio::test]
279async fn pushes_close_and_reference_issues() {
280 if !tool_on_path("git") {
281 eprintln!("SKIP issue_close_e2e: `git` was not found on PATH");
282 return;
283 }
284
285 // Keep the path short: the internal unix socket path is limited to 108 bytes.
286 let root = tempfile::Builder::new()
287 .prefix("ice2e")
288 .tempdir_in("/tmp")
289 .expect("temp dir");
290 let dir = root.path().to_path_buf();
291 let ports = pick_ports();
292 let config = write_config(&dir, ports);
293 let log_path = dir.join("serve.log");
294
295 run_admin(&config, "alice", &["--admin"], ALICE_PW);
296 run_admin(&config, "bob", &[], BOB_PW);
297
298 // Web setup: log in, make a token and create the repo.
299 let (mut server, ports) = launch(&dir, ports).await;
300 let http_port = ports.http;
301 let http = Http::new(http_port);
302 let alice = login(&http, "alice", ALICE_PW).await;
303 let token = create_token(&http, &alice, "issue-close-token").await;
304 create_repo(&http, &alice, "pub").await;
305
306 // The database file is locked while the server runs, so stop it, create the issues through
307 // the store, and start the server again on the same ports.
308 drop(server);
309 {
310 let store = Store::open(&dir.join("forge.db"))
311 .await
312 .expect("open store");
313 let author = store
314 .user_by_username("alice")
315 .await
316 .expect("user lookup")
317 .expect("alice exists");
318 let repo = store
319 .repo_by_owner_and_name("alice", "pub")
320 .await
321 .expect("repo lookup")
322 .expect("repo exists");
323 for n in 1..=3 {
324 store
325 .create_issue(&repo.id, &author.id, &format!("issue {n}"), "body")
326 .await
327 .expect("create issue");
328 }
329 }
330 server = relaunch(&config, &log_path, ports).await;
331 let alice = login(&http, "alice", ALICE_PW).await;
332
333 // Push over HTTPS with the token.
334 let url = format!("http://alice:{token}@127.0.0.1:{http_port}/alice/pub.git");
335 let work = dir.join("work");
336 git(
337 &dir,
338 &["clone", "-q", &url, work.to_str().expect("utf-8 path")],
339 );
340 let base = commit_file(&work, "base.txt", "base commit");
341 git(&work, &["push", "-q", "origin", "HEAD:refs/heads/main"]);
342
343 // A commit that references #3 on a non-default branch must not close it.
344 git(&work, &["checkout", "-q", "-b", "topic"]);
345 let topic_oid = commit_file(&work, "topic.txt", "fixes #3 on a topic branch");
346 git(&work, &["push", "-q", "origin", "HEAD:refs/heads/topic"]);
347
348 // Default branch: one closing reference and one plain reference.
349 git(&work, &["checkout", "-q", "-B", "mainline", &base]);
350 let main_oid = commit_file(&work, "main.txt", "Fixes #1, refs #2");
351 // The same commit goes to another branch first. That push is not processed, so the
352 // push to main below must produce exactly one referenced event.
353 git(
354 &work,
355 &["push", "-q", "origin", "HEAD:refs/heads/dup-of-main"],
356 );
357 git(&work, &["push", "-q", "origin", "HEAD:refs/heads/main"]);
358
359 let commit_link = format!("/commit/{main_oid}");
360 let link_ref = commit_link.as_str();
361 let (http_ref, alice_ref) = (&http, alice.as_str());
362 eventually("#1 closed by the push", || async move {
363 state_badge(&issue_page(http_ref, alice_ref, 1).await) == "Closed"
364 })
365 .await;
366 let page1 = issue_page(&http, &alice, 1).await;
367 assert!(
368 page1.contains(&commit_link),
369 "#1 timeline should link the closing commit"
370 );
371
372 eventually("#2 referenced by the push", || async move {
373 let page = issue_page(http_ref, alice_ref, 2).await;
374 page.contains("referenced this") && page.contains(link_ref)
375 })
376 .await;
377 let page2 = issue_page(&http, &alice, 2).await;
378 assert_eq!(state_badge(&page2), "Open", "#2 is only referenced");
379
380 // Nothing observable marks the end of a background run for a non-matching push, so give the
381 // topic and dup-branch work time to finish before checking the counts again.
382 tokio::time::sleep(Duration::from_secs(2)).await;
383 let page2 = issue_page(&http, &alice, 2).await;
384 assert_eq!(
385 page2.matches("referenced this").count(),
386 1,
387 "referenced event must not be duplicated"
388 );
389 let page3 = issue_page(&http, &alice, 3).await;
390 assert_eq!(
391 state_badge(&page3),
392 "Open",
393 "#3 must not be closed from topic"
394 );
395 assert!(
396 !page3.contains(&format!("/commit/{topic_oid}")),
397 "non-default branch pushes are not processed"
398 );
399 drop(server);
400}