forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
13.8 KiB
1//! End-to-end checks for branch protection in the pre-receive hook. It starts the real `forge`
2//! binary, pushes with the real `git` client over HTTPS, and writes rules through `forge-core`
3//! while the server runs. Helpers mirror `git_e2e.rs`.
4
5mod common;
6use common::{
7 BIN, Ports, Server, describe, launch, pick_ports, relaunch, tool_on_path, write_config,
8};
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::process::{Command, Output};
12use std::time::Duration;
13
14use forge_core::Store;
15use reqwest::header::{LOCATION, SET_COOKIE};
16
17const ALICE_PW: &str = "alice-password-123";
18
19struct Resp {
20 status: u16,
21 location: Option<String>,
22 set_cookies: Vec<String>,
23 body: String,
24}
25
26struct Http {
27 client: reqwest::Client,
28 base: String,
29}
30
31impl Http {
32 fn new(port: u16) -> Self {
33 let client = reqwest::Client::builder()
34 .redirect(reqwest::redirect::Policy::none())
35 .timeout(Duration::from_secs(60))
36 .build()
37 .expect("http client");
38 Self {
39 client,
40 base: format!("http://127.0.0.1:{port}"),
41 }
42 }
43
44 async fn get(&self, path: &str) -> Resp {
45 self.send(self.client.get(format!("{}{path}", self.base)), None)
46 .await
47 }
48
49 async fn get_cookie(&self, path: &str, cookie: &str) -> Resp {
50 self.send(
51 self.client.get(format!("{}{path}", self.base)),
52 Some(cookie),
53 )
54 .await
55 }
56
57 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
58 self.send(
59 self.client.post(format!("{}{path}", self.base)).form(form),
60 cookie,
61 )
62 .await
63 }
64
65 async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp {
66 let mut req = req;
67 if let Some(cookie) = cookie {
68 req = req.header(reqwest::header::COOKIE, cookie);
69 }
70 let res = req.send().await.expect("http request");
71 let status = res.status().as_u16();
72 let location = res
73 .headers()
74 .get(LOCATION)
75 .and_then(|v| v.to_str().ok())
76 .map(str::to_owned);
77 let set_cookies = res
78 .headers()
79 .get_all(SET_COOKIE)
80 .iter()
81 .filter_map(|v| v.to_str().ok().map(str::to_owned))
82 .collect();
83 let body = res.text().await.unwrap_or_default();
84 Resp {
85 status,
86 location,
87 set_cookies,
88 body,
89 }
90 }
91}
92
93/// Returns `name=value` for the cookie whose name ends with `suffix`. Cleared cookies are ignored.
94fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
95 resp.set_cookies.iter().find_map(|set| {
96 let pair = set.split(';').next()?;
97 let (name, value) = pair.split_once('=')?;
98 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
99 })
100}
101
102/// The value of the first `<input name="...">` on a page.
103fn input_value(body: &str, name: &str) -> String {
104 let doc = scraper::Html::parse_document(body);
105 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
106 doc.select(&selector)
107 .next()
108 .and_then(|el| el.value().attr("value"))
109 .unwrap_or_else(|| panic!("no input named {name} on page"))
110 .to_owned()
111}
112
113fn run_admin(config: &Path, username: &str, extra: &[&str], password: &str) {
114 let out = Command::new(BIN)
115 .arg("--config")
116 .arg(config)
117 .args(["admin", "create-user", username])
118 .args(extra)
119 .env("FORGE_PASSWORD", password)
120 .output()
121 .expect("run admin create-user");
122 ok(&out, &format!("create-user {username}"));
123}
124
125fn ok(out: &Output, what: &str) {
126 assert!(out.status.success(), "{what} failed:\n{}", describe(out));
127}
128
129/// Asserts failure and returns the combined output for further checks.
130fn rejected(out: &Output, what: &str) -> String {
131 assert!(
132 !out.status.success(),
133 "{what} unexpectedly succeeded:\n{}",
134 describe(out)
135 );
136 describe(out)
137}
138
139fn git(cwd: &Path, args: &[&str]) -> Output {
140 Command::new("git")
141 .current_dir(cwd)
142 .args(["-c", "credential.helper="])
143 .args(args)
144 .env("GIT_TERMINAL_PROMPT", "0")
145 .output()
146 .expect("run git")
147}
148
149fn path_str(path: &Path) -> &str {
150 path.to_str().expect("utf-8 path")
151}
152
153fn set_identity(repo: &Path) {
154 ok(
155 &git(repo, &["config", "user.name", "alice"]),
156 "set user.name",
157 );
158 ok(
159 &git(repo, &["config", "user.email", "alice@example.com"]),
160 "set user.email",
161 );
162}
163
164/// Writes a file, commits it in `repo`, and returns the new HEAD.
165fn commit_file(repo: &Path, name: &str, content: &str, message: &str) -> String {
166 fs::write(repo.join(name), content).expect("write file");
167 set_identity(repo);
168 ok(&git(repo, &["add", name]), "git add");
169 ok(
170 &git(
171 repo,
172 &["-c", "commit.gpgsign=false", "commit", "-q", "-m", message],
173 ),
174 "git commit",
175 );
176 head_sha(repo)
177}
178
179fn head_sha(repo: &Path) -> String {
180 let out = git(repo, &["rev-parse", "HEAD"]);
181 ok(&out, "rev-parse HEAD");
182 String::from_utf8_lossy(&out.stdout).trim().to_owned()
183}
184
185/// The commit a remote ref points at, as reported by `git ls-remote`.
186fn remote_sha(repo: &Path, reference: &str) -> String {
187 let out = git(repo, &["ls-remote", "origin", reference]);
188 ok(&out, "ls-remote");
189 String::from_utf8_lossy(&out.stdout)
190 .split_whitespace()
191 .next()
192 .unwrap_or_default()
193 .to_owned()
194}
195
196/// Logs in through the real `/login` form and returns the `__Host-forge_session` cookie header.
197async fn login(http: &Http, username: &str, password: &str) -> String {
198 let page = http.get("/login").await;
199 assert_eq!(page.status, 200, "GET /login");
200 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
201 let precsrf = input_value(&page.body, "precsrf");
202 let res = http
203 .post_form(
204 "/login",
205 &[
206 ("username", username),
207 ("password", password),
208 ("next", "/"),
209 ("precsrf", &precsrf),
210 ],
211 Some(&precsrf_cookie),
212 )
213 .await;
214 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
215 cookie_named(&res, "forge_session").expect("session cookie after login")
216}
217
218/// Creates an access token through `/settings/tokens` and returns the token shown once.
219async fn create_token(http: &Http, session: &str, name: &str) -> String {
220 let page = http.get_cookie("/settings/tokens", session).await;
221 assert_eq!(page.status, 200, "GET /settings/tokens");
222 let csrf = input_value(&page.body, "csrf");
223 let res = http
224 .post_form(
225 "/settings/tokens",
226 &[("csrf", &csrf), ("name", name), ("expires", "30")],
227 Some(session),
228 )
229 .await;
230 assert_eq!(res.status, 200, "create token: {}", res.body);
231 let doc = scraper::Html::parse_document(&res.body);
232 let selector = scraper::Selector::parse("pre.code code").expect("selector");
233 let token: String = doc
234 .select(&selector)
235 .next()
236 .expect("token shown after creation")
237 .text()
238 .collect();
239 let token = token.trim().to_owned();
240 assert!(token.starts_with("forge_"), "unexpected token shape");
241 token
242}
243
244async fn create_repo(http: &Http, session: &str, name: &str) {
245 let page = http.get_cookie("/new", session).await;
246 assert_eq!(page.status, 200, "GET /new");
247 let csrf = input_value(&page.body, "csrf");
248 let res = http
249 .post_form(
250 "/new",
251 &[
252 ("csrf", &csrf),
253 ("name", name),
254 ("description", ""),
255 ("visibility", "private"),
256 ("default_branch", "main"),
257 ],
258 Some(session),
259 )
260 .await;
261 assert_eq!(res.status, 303, "create repo {name}: {}", res.body);
262 assert_eq!(
263 res.location.as_deref(),
264 Some(format!("/alice/{name}").as_str())
265 );
266}
267
268/// Paths and handles shared by the steps of the test.
269struct Ctx {
270 dir: PathBuf,
271 config: PathBuf,
272 log: PathBuf,
273 http: Http,
274 ports: Ports,
275}
276
277/// Starts the server again on the ports the first launch settled on.
278async fn start_server(ctx: &Ctx) -> Server {
279 relaunch(&ctx.config, &ctx.log, ctx.ports).await
280}
281
282/// Stops the server, writes one rule through `forge-core`, and starts the server again. The
283/// database file is locked while the server runs, so the rule cannot be written in place. The
284/// hook reads the rules on every push, so a restart changes nothing else.
285async fn set_rule(
286 ctx: &Ctx,
287 server: Server,
288 repo_id: &str,
289 pattern: &str,
290 allow_force_push: bool,
291 allow_deletion: bool,
292) -> Server {
293 drop(server);
294 {
295 let store = Store::open(&ctx.dir.join("forge.db"))
296 .await
297 .expect("open store");
298 store
299 .upsert_branch_rule(
300 repo_id,
301 pattern,
302 forge_core::protection::RuleFlags::basic(allow_force_push, allow_deletion),
303 )
304 .await
305 .expect("upsert rule");
306 }
307 start_server(ctx).await
308}
309
310#[tokio::test]
311async fn branch_protection_end_to_end() {
312 if !tool_on_path("git") {
313 eprintln!("SKIP branch_protection_e2e: `git` was not found on PATH");
314 return;
315 }
316
317 // Keep the path short: the internal unix socket path is limited to 108 bytes.
318 let root = tempfile::Builder::new()
319 .prefix("fbp")
320 .tempdir_in("/tmp")
321 .expect("temp dir");
322 let dir = root.path().to_path_buf();
323 let ports = pick_ports();
324 let config = write_config(&dir, ports);
325 run_admin(&config, "alice", &["--admin"], ALICE_PW);
326
327 let (server, ports) = launch(&dir, ports).await;
328 let ctx = Ctx {
329 log: dir.join("serve.log"),
330 http: Http::new(ports.http),
331 ports,
332 config,
333 dir,
334 };
335 let alice = login(&ctx.http, "alice", ALICE_PW).await;
336 let token = create_token(&ctx.http, &alice, "bp-token").await;
337 create_repo(&ctx.http, &alice, "bp").await;
338
339 // Look up the repo id while the server is stopped, since the database is locked otherwise.
340 drop(server);
341 let repo_id = {
342 let store = Store::open(&ctx.dir.join("forge.db"))
343 .await
344 .expect("open store");
345 store
346 .repo_by_owner_and_name("alice", "bp")
347 .await
348 .expect("repo lookup")
349 .expect("repo exists")
350 .id
351 };
352 let mut server = start_server(&ctx).await;
353
354 // Seed main, feature/x and release/1 with two commits on main.
355 let url = format!(
356 "http://alice:{token}@127.0.0.1:{}/alice/bp.git",
357 ctx.http.base.rsplit(':').next().expect("port")
358 );
359 let work = ctx.dir.join("work");
360 ok(&git(&ctx.dir, &["clone", &url, path_str(&work)]), "clone");
361 let c1 = commit_file(&work, "file.txt", "one\n", "one");
362 ok(&git(&work, &["push", "origin", "HEAD:main"]), "push main");
363 let c2 = commit_file(&work, "file.txt", "two\n", "two");
364 ok(&git(&work, &["push", "origin", "HEAD:main"]), "push main");
365 ok(
366 &git(&work, &["push", "origin", "HEAD:refs/heads/feature/x"]),
367 "push feature/x",
368 );
369 ok(
370 &git(&work, &["push", "origin", "HEAD:refs/heads/release/1"]),
371 "push release/1",
372 );
373
374 // Divergent history on main: c3 is not a descendant of c2.
375 ok(&git(&work, &["reset", "-q", "--hard", &c1]), "reset");
376 let c3 = commit_file(&work, "file.txt", "three\n", "three");
377
378 // 1. Default protection: force-push to main is rejected.
379 let force = git(&work, &["push", "-f", "origin", "HEAD:main"]);
380 let text = rejected(&force, "force-push to default branch");
381 assert!(
382 text.contains("force-push to protected branch 'main' is not allowed"),
383 "default force-push message: {text}"
384 );
385 assert_eq!(remote_sha(&work, "refs/heads/main"), c2);
386
387 // 2. A rule that allows force pushes lets the force-push through, but not deletion.
388 server = set_rule(&ctx, server, &repo_id, "main", true, false).await;
389 ok(
390 &git(&work, &["push", "-f", "origin", "HEAD:main"]),
391 "force-push main with allow_force_push",
392 );
393 assert_eq!(remote_sha(&work, "refs/heads/main"), c3);
394
395 let delete = git(&work, &["push", "origin", "--delete", "main"]);
396 let text = rejected(&delete, "delete main");
397 assert!(
398 text.contains("deleting protected branch 'main' is not allowed"),
399 "delete main message: {text}"
400 );
401 assert_eq!(remote_sha(&work, "refs/heads/main"), c3);
402
403 // 3. A release/* rule without force pushes rejects release/1 but not feature/x (no rule).
404 server = set_rule(&ctx, server, &repo_id, "release/*", false, true).await;
405 ok(&git(&work, &["reset", "-q", "--hard", &c1]), "reset");
406 let c4 = commit_file(&work, "file.txt", "four\n", "four");
407
408 let release_force = git(
409 &work,
410 &["push", "-f", "origin", "HEAD:refs/heads/release/1"],
411 );
412 let text = rejected(&release_force, "force-push to release/1");
413 assert!(
414 text.contains("force-push to protected branch 'release/1' is not allowed"),
415 "release force-push message: {text}"
416 );
417 assert_eq!(remote_sha(&work, "refs/heads/release/1"), c2);
418
419 ok(
420 &git(
421 &work,
422 &["push", "-f", "origin", "HEAD:refs/heads/feature/x"],
423 ),
424 "force-push feature/x without a rule",
425 );
426 assert_eq!(remote_sha(&work, "refs/heads/feature/x"), c4);
427
428 // 4. Deleting a non-default branch with no rule succeeds.
429 ok(
430 &git(&work, &["push", "origin", "--delete", "feature/x"]),
431 "delete unprotected feature/x",
432 );
433 assert_eq!(remote_sha(&work, "refs/heads/feature/x"), "");
434 drop(server);
435}