forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
39.0 KiB
1//! Black-box test for repo deletion. It starts the real `forge` binary, seeds repos through the
2//! store before the server starts, and then drives the settings page and the delete form over HTTP.
3//!
4//! Cookies are handled by hand because the session cookie is `__Host-forge_session` with `Secure`,
5//! which a cookie jar would not send over plain http.
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 forge_core::{Role, Store, Visibility};
15use reqwest::header::{COOKIE, HeaderMap, HeaderName, LOCATION, SET_COOKIE};
16
17const PW: &str = "alice-password-123";
18
19struct Resp {
20 status: u16,
21 headers: HeaderMap,
22 body: String,
23}
24
25impl Resp {
26 fn header(&self, name: HeaderName) -> Option<String> {
27 self.headers
28 .get(name)
29 .and_then(|v| v.to_str().ok())
30 .map(str::to_owned)
31 }
32
33 fn set_cookies(&self) -> Vec<String> {
34 self.headers
35 .get_all(SET_COOKIE)
36 .iter()
37 .filter_map(|v| v.to_str().ok().map(str::to_owned))
38 .collect()
39 }
40}
41
42struct Http {
43 client: reqwest::Client,
44 base: String,
45}
46
47impl Http {
48 fn new(port: u16) -> Self {
49 let client = reqwest::Client::builder()
50 .redirect(reqwest::redirect::Policy::none())
51 .timeout(Duration::from_secs(60))
52 .build()
53 .expect("http client");
54 Self {
55 client,
56 base: format!("http://127.0.0.1:{port}"),
57 }
58 }
59
60 async fn get(&self, path: &str, cookie: Option<&str>) -> Resp {
61 self.send(self.client.get(format!("{}{path}", self.base)), cookie)
62 .await
63 }
64
65 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
66 self.send(
67 self.client.post(format!("{}{path}", self.base)).form(form),
68 cookie,
69 )
70 .await
71 }
72
73 async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp {
74 let req = match cookie {
75 Some(cookie) => req.header(COOKIE, cookie),
76 None => req,
77 };
78 let res = req.send().await.expect("http request");
79 let status = res.status().as_u16();
80 let headers = res.headers().clone();
81 let body = res.text().await.expect("read body");
82 Resp {
83 status,
84 headers,
85 body,
86 }
87 }
88}
89
90/// `name=value` for the cookie whose name ends with `suffix`, ignoring cleared cookies.
91fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
92 resp.set_cookies().iter().find_map(|set| {
93 let pair = set.split(';').next()?;
94 let (name, value) = pair.split_once('=')?;
95 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
96 })
97}
98
99/// The value of the first `<input name="...">` on a page.
100fn input_value(body: &str, name: &str) -> String {
101 let doc = scraper::Html::parse_document(body);
102 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
103 doc.select(&selector)
104 .next()
105 .and_then(|el| el.value().attr("value"))
106 .unwrap_or_else(|| panic!("no input named {name} on page"))
107 .to_owned()
108}
109
110/// Runs an admin subcommand with the password in FORGE_PASSWORD and asserts it succeeded.
111fn admin(config: &Path, args: &[&str]) -> Output {
112 let out = Command::new(BIN)
113 .arg("--config")
114 .arg(config)
115 .arg("admin")
116 .args(args)
117 .env("FORGE_PASSWORD", PW)
118 .output()
119 .expect("run admin command");
120 assert!(
121 out.status.success(),
122 "admin {args:?} failed:\n{}",
123 describe(&out)
124 );
125 out
126}
127
128async fn login(http: &Http, username: &str) -> String {
129 let page = http.get("/login", None).await;
130 assert_eq!(page.status, 200, "GET /login");
131 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
132 let precsrf = input_value(&page.body, "precsrf");
133 let res = http
134 .post_form(
135 "/login",
136 &[
137 ("username", username),
138 ("password", PW),
139 ("next", "/"),
140 ("precsrf", &precsrf),
141 ],
142 Some(&precsrf_cookie),
143 )
144 .await;
145 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
146 cookie_named(&res, "forge_session").expect("session cookie after login")
147}
148
149/// The session's CSRF token, read from a page every signed-in user can open.
150async fn csrf_for(http: &Http, session: &str) -> String {
151 let page = http.get("/settings/tokens", Some(session)).await;
152 assert_eq!(page.status, 200, "GET /settings/tokens");
153 input_value(&page.body, "csrf")
154}
155
156/// Creates a bare git repo for `repo_id` at its data-dir location, as the create-repo page does.
157fn init_bare(data_dir: &Path, repo_id: &str) {
158 let path = forge_git::repo::repo_path(data_dir, repo_id).expect("repo path");
159 fs::create_dir_all(path.parent().expect("parent")).expect("create repo parent");
160 let out = Command::new("git")
161 .args(["init", "--bare", "--quiet"])
162 .arg(&path)
163 .output()
164 .expect("run git init");
165 assert!(out.status.success(), "git init --bare: {}", describe(&out));
166}
167
168/// Directories in `data_dir/trash` for the repo `id` (lowercase, as the trash names them).
169fn trash_entries(data_dir: &Path, repo_id: &str) -> Vec<PathBuf> {
170 let prefix = format!("{}-", repo_id.to_ascii_lowercase());
171 let Ok(read) = fs::read_dir(data_dir.join(forge_git::repo::TRASH_DIR)) else {
172 return Vec::new();
173 };
174 read.filter_map(Result::ok)
175 .map(|e| e.path())
176 .filter(|p| {
177 p.file_name()
178 .and_then(|n| n.to_str())
179 .is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".git"))
180 })
181 .collect()
182}
183
184#[tokio::test]
185async fn repo_settings_end_to_end() {
186 if !tool_on_path("git") {
187 eprintln!("SKIP repo_settings_e2e: `git` was not found on PATH");
188 return;
189 }
190
191 // Keep the path short: the data dir holds a unix socket with a length limit.
192 let root = tempfile::Builder::new()
193 .prefix("frs")
194 .tempdir_in("/tmp")
195 .expect("temp dir");
196 let dir = root.path().to_path_buf();
197
198 let ports = pick_ports();
199 let config = write_config(&dir, ports);
200
201 // The first user is an admin. Bob owns the repos, carol is an admin collaborator on the private
202 // one, and alice (admin) must still be able to delete bob's repo.
203 admin(&config, &["create-user", "alice", "--admin"]);
204 admin(&config, &["create-user", "bob"]);
205 admin(&config, &["create-user", "carol"]);
206
207 let (public_id, private_id) = {
208 let store = Store::open(&dir.join("forge.db"))
209 .await
210 .expect("open store");
211 let bob = store.user_by_username("bob").await.unwrap().expect("bob");
212 let carol = store
213 .user_by_username("carol")
214 .await
215 .unwrap()
216 .expect("carol");
217 let public = store
218 .create_repo(&bob.id, "pub", "", Visibility::Public, "main")
219 .await
220 .expect("public repo");
221 let private = store
222 .create_repo(&bob.id, "priv", "", Visibility::Private, "main")
223 .await
224 .expect("private repo");
225 store
226 .set_collaborator(&private.id, &carol.id, Role::Admin)
227 .await
228 .expect("carol collaborator");
229 init_bare(&dir, &public.id);
230 init_bare(&dir, &private.id);
231 (public.id, private.id)
232 };
233
234 let (server, ports) = launch(&dir, ports).await;
235 let http = Http::new(ports.http);
236
237 let bob = login(&http, "bob").await;
238 let carol = login(&http, "carol").await;
239 let alice = login(&http, "alice").await;
240
241 // Anonymous on a private repo: 404, the same as a missing repo.
242 let anon = http.get("/bob/priv/settings", None).await;
243 assert_eq!(anon.status, 404, "anonymous settings on private repo");
244
245 // A collaborator with the Admin role gets the settings page (branch rules) but no danger zone,
246 // and a POST to the delete form is 403. The repo stays.
247 let carol_page = http.get("/bob/priv/settings", Some(&carol)).await;
248 assert_eq!(carol_page.status, 200, "admin collaborator settings page");
249 assert!(
250 !carol_page.body.contains("Danger zone"),
251 "admin collaborator must not see the danger zone"
252 );
253 let carol_csrf = csrf_for(&http, &carol).await;
254 let carol_delete = http
255 .post_form(
256 "/bob/priv/settings/delete",
257 &[("csrf", &carol_csrf), ("confirm", "bob/priv")],
258 Some(&carol),
259 )
260 .await;
261 assert_eq!(carol_delete.status, 403, "collaborator delete");
262 assert_eq!(http.get("/bob/priv", Some(&carol)).await.status, 200);
263
264 // The owner sees the danger zone, and the Settings tab appears on the repo header.
265 let owner_page = http.get("/bob/pub/settings", Some(&bob)).await;
266 assert_eq!(owner_page.status, 200, "owner settings page");
267 assert!(
268 owner_page.body.contains("Danger zone"),
269 "route did not reach the settings page"
270 );
271 assert!(
272 owner_page.body.contains("href=\"/bob/pub/settings\""),
273 "Settings tab missing"
274 );
275 let collaborator_repo_page = http.get("/bob/priv", Some(&carol)).await;
276 assert!(
277 collaborator_repo_page
278 .body
279 .contains("href=\"/bob/priv/settings\""),
280 "Settings tab shows to an Admin collaborator"
281 );
282
283 // A wrong confirmation is an error and keeps the repo.
284 let owner_csrf = csrf_for(&http, &bob).await;
285 let wrong = http
286 .post_form(
287 "/bob/pub/settings/delete",
288 &[("csrf", &owner_csrf), ("confirm", "pub")],
289 Some(&bob),
290 )
291 .await;
292 assert_eq!(wrong.status, 200, "wrong confirmation re-renders the page");
293 assert!(
294 wrong.body.contains("exactly to confirm"),
295 "missing mismatch error"
296 );
297 assert_eq!(
298 http.get("/bob/pub", Some(&bob)).await.status,
299 200,
300 "repo kept"
301 );
302 assert!(
303 trash_entries(&dir, &public_id).is_empty(),
304 "nothing trashed on mismatch"
305 );
306
307 // No CSRF token is rejected, even with the right confirmation.
308 let no_csrf = http
309 .post_form(
310 "/bob/pub/settings/delete",
311 &[("confirm", "bob/pub")],
312 Some(&bob),
313 )
314 .await;
315 assert_eq!(no_csrf.status, 403, "missing CSRF token");
316 assert_eq!(
317 http.get("/bob/pub", Some(&bob)).await.status,
318 200,
319 "repo kept without CSRF"
320 );
321
322 // The owner deletes: redirect home with a notice, the repo is gone, the directory is trashed.
323 let deleted = http
324 .post_form(
325 "/bob/pub/settings/delete",
326 &[("csrf", &owner_csrf), ("confirm", "bob/pub")],
327 Some(&bob),
328 )
329 .await;
330 assert_eq!(deleted.status, 303, "owner delete: {}", deleted.body);
331 assert_eq!(deleted.header(LOCATION).as_deref(), Some("/"));
332 let notice = cookie_named(&deleted, "forge_notice").expect("notice cookie");
333 assert_eq!(
334 http.get("/bob/pub", Some(&bob)).await.status,
335 404,
336 "repo page after delete"
337 );
338 assert_eq!(
339 http.get("/bob/pub/settings", Some(&bob)).await.status,
340 404,
341 "settings after delete"
342 );
343 let trashed = trash_entries(&dir, &public_id);
344 assert_eq!(trashed.len(), 1, "one trash entry for the deleted repo");
345 assert!(
346 trashed[0].join("HEAD").is_file(),
347 "trash entry is a bare repo"
348 );
349
350 // The next home page shows the notice once.
351 let home = http.get("/", Some(&format!("{bob}; {notice}"))).await;
352 assert!(home.body.contains("Repository"), "home page notice missing");
353 assert!(
354 home.body.contains("bob/pub"),
355 "home page notice names the repo"
356 );
357
358 // An admin who does not own the repo can delete it.
359 let admin_page = http.get("/bob/priv/settings", Some(&alice)).await;
360 assert_eq!(admin_page.status, 200, "admin settings page");
361 let admin_csrf = csrf_for(&http, &alice).await;
362 let admin_delete = http
363 .post_form(
364 "/bob/priv/settings/delete",
365 &[("csrf", &admin_csrf), ("confirm", "bob/priv")],
366 Some(&alice),
367 )
368 .await;
369 assert_eq!(admin_delete.status, 303, "admin delete");
370 assert_eq!(
371 http.get("/bob/priv", Some(&alice)).await.status,
372 404,
373 "admin-deleted repo"
374 );
375 assert_eq!(
376 trash_entries(&dir, &private_id).len(),
377 1,
378 "admin delete is trashed"
379 );
380
381 // The admin CLI lists both trash entries, then purges them.
382 let listed = String::from_utf8_lossy(&admin(&config, &["trash-list"]).stdout).into_owned();
383 assert!(
384 listed.contains(&public_id.to_ascii_lowercase()),
385 "trash-list: {listed}"
386 );
387 assert!(
388 listed.contains(&private_id.to_ascii_lowercase()),
389 "trash-list: {listed}"
390 );
391 let purged = admin(&config, &["trash-purge", "--older-than-days", "0"]);
392 assert!(
393 String::from_utf8_lossy(&purged.stdout).contains("purged 2 trashed repositories"),
394 "{}",
395 describe(&purged)
396 );
397 assert!(trash_entries(&dir, &public_id).is_empty());
398 assert!(trash_entries(&dir, &private_id).is_empty());
399 drop(server);
400}
401
402/// A repo whose bare directory was removed by hand can still be deleted from the UI: the DB rows
403/// go, nothing is trashed, and the request succeeds.
404#[tokio::test]
405async fn repo_settings_deletes_rows_when_repo_dir_is_missing() {
406 if !tool_on_path("git") {
407 eprintln!("SKIP repo_settings_e2e: `git` was not found on PATH");
408 return;
409 }
410
411 let root = tempfile::Builder::new()
412 .prefix("frm")
413 .tempdir_in("/tmp")
414 .expect("temp dir");
415 let dir = root.path().to_path_buf();
416 let ports = pick_ports();
417 let config = write_config(&dir, ports);
418 admin(&config, &["create-user", "bob"]);
419
420 let (repo_id, bare_dir) = {
421 let store = Store::open(&dir.join("forge.db"))
422 .await
423 .expect("open store");
424 let bob = store.user_by_username("bob").await.unwrap().expect("bob");
425 let repo = store
426 .create_repo(&bob.id, "gone", "", Visibility::Public, "main")
427 .await
428 .expect("repo");
429 init_bare(&dir, &repo.id);
430 let bare = forge_git::repo::repo_path(&dir, &repo.id).expect("repo path");
431 // Simulate the broken state: the DB row exists but the bare directory is gone.
432 fs::remove_dir_all(&bare).expect("remove bare dir by hand");
433 (repo.id, bare)
434 };
435
436 let (server, ports) = launch(&dir, ports).await;
437 let http = Http::new(ports.http);
438 let bob = login(&http, "bob").await;
439 let csrf = csrf_for(&http, &bob).await;
440 let deleted = http
441 .post_form(
442 "/bob/gone/settings/delete",
443 &[("csrf", &csrf), ("confirm", "bob/gone")],
444 Some(&bob),
445 )
446 .await;
447 assert_eq!(
448 deleted.status, 303,
449 "delete with missing dir: {}",
450 deleted.body
451 );
452 assert_eq!(deleted.header(LOCATION).as_deref(), Some("/"));
453 assert_eq!(
454 http.get("/bob/gone", Some(&bob)).await.status,
455 404,
456 "repo page after delete"
457 );
458 assert!(!bare_dir.exists(), "missing dir must not be recreated");
459 assert!(
460 trash_entries(&dir, &repo_id).is_empty(),
461 "nothing to trash when the dir is missing"
462 );
463 // Stop the server first: the database file is locked while it runs.
464 drop(server);
465 let store = Store::open(&dir.join("forge.db"))
466 .await
467 .expect("open store");
468 assert!(
469 store.repo_by_id(&repo_id).await.unwrap().is_none(),
470 "DB row removed"
471 );
472 let log = fs::read_to_string(dir.join("serve.log")).unwrap_or_default();
473 assert!(
474 log.contains("repo dir missing, deleting DB rows only") && log.contains(&repo_id),
475 "missing warning in log:\n{log}"
476 );
477}
478
479/// Rows of the branch rules table as their cell texts, in page order. The built-in default-branch
480/// row is a single cell with a `colspan`, so it appears as a one-cell row.
481fn branch_rows(body: &str) -> Vec<Vec<String>> {
482 let doc = scraper::Html::parse_document(body);
483 let rows = scraper::Selector::parse("table.branch-rules tbody tr").expect("selector");
484 let cells = scraper::Selector::parse("td").expect("selector");
485 doc.select(&rows)
486 .map(|row| {
487 row.select(&cells)
488 .map(|cell| cell.text().collect::<String>().trim().to_owned())
489 .collect()
490 })
491 .collect()
492}
493
494/// The cells of the rule row for `pattern`, if the table has one.
495fn rule_row(body: &str, pattern: &str) -> Option<Vec<String>> {
496 branch_rows(body)
497 .into_iter()
498 .find(|cells| cells.first().is_some_and(|c| c == pattern) && cells.len() == 5)
499}
500
501/// Owner adds, updates, invalidates and removes branch rules. A Write collaborator and an Admin
502/// collaborator get the right access: the Admin can manage rules but sees no danger zone.
503#[tokio::test]
504async fn branch_protection_end_to_end() {
505 if !tool_on_path("git") {
506 eprintln!("SKIP repo_settings_e2e: `git` was not found on PATH");
507 return;
508 }
509
510 let root = tempfile::Builder::new()
511 .prefix("frb")
512 .tempdir_in("/tmp")
513 .expect("temp dir");
514 let dir = root.path().to_path_buf();
515 let ports = pick_ports();
516 let config = write_config(&dir, ports);
517
518 // The first user is an instance admin, so bob (the owner) is an ordinary user.
519 admin(&config, &["create-user", "alice", "--admin"]);
520 admin(&config, &["create-user", "bob"]);
521 admin(&config, &["create-user", "carol"]);
522 admin(&config, &["create-user", "dan"]);
523
524 {
525 let store = Store::open(&dir.join("forge.db"))
526 .await
527 .expect("open store");
528 let bob = store.user_by_username("bob").await.unwrap().expect("bob");
529 let carol = store
530 .user_by_username("carol")
531 .await
532 .unwrap()
533 .expect("carol");
534 let dan = store.user_by_username("dan").await.unwrap().expect("dan");
535 let app = store
536 .create_repo(&bob.id, "app", "", Visibility::Public, "main")
537 .await
538 .expect("repo");
539 store
540 .set_collaborator(&app.id, &carol.id, Role::Admin)
541 .await
542 .expect("carol collaborator");
543 store
544 .set_collaborator(&app.id, &dan.id, Role::Write)
545 .await
546 .expect("dan collaborator");
547 init_bare(&dir, &app.id);
548 }
549
550 let (server, ports) = launch(&dir, ports).await;
551 let http = Http::new(ports.http);
552 let bob = login(&http, "bob").await;
553 let carol = login(&http, "carol").await;
554 let dan = login(&http, "dan").await;
555
556 // Owner: the built-in row is there before any rule, and the danger zone is shown.
557 let page = http.get("/bob/app/settings", Some(&bob)).await;
558 assert_eq!(page.status, 200, "owner settings page");
559 assert!(page.body.contains("Branch protection"), "section missing");
560 assert!(
561 page.body.contains("(default branch): protected by default"),
562 "built-in row missing"
563 );
564 assert!(
565 page.body.contains("Danger zone"),
566 "owner sees the danger zone"
567 );
568 assert_eq!(
569 input_value(&page.body, "pattern"),
570 "main",
571 "pattern is prefilled with the default branch"
572 );
573 let owner_csrf = csrf_for(&http, &bob).await;
574
575 // Adding main with force push allowed: it appears, and the built-in row disappears.
576 let added = http
577 .post_form(
578 "/bob/app/settings/branches",
579 &[
580 ("csrf", &owner_csrf),
581 ("pattern", "main"),
582 ("allow_force_push", "on"),
583 ],
584 Some(&bob),
585 )
586 .await;
587 assert_eq!(added.status, 303, "add rule: {}", added.body);
588 assert_eq!(added.header(LOCATION).as_deref(), Some("/bob/app/settings"));
589 let notice = cookie_named(&added, "forge_settings_notice").expect("settings notice cookie");
590 let page = http
591 .get("/bob/app/settings", Some(&format!("{bob}; {notice}")))
592 .await;
593 assert!(
594 page.body.contains("Branch rule saved."),
595 "save notice missing"
596 );
597 assert_eq!(
598 rule_row(&page.body, "main"),
599 Some(vec![
600 "main".into(),
601 "yes".into(),
602 "no".into(),
603 "none".into(),
604 "Remove".into()
605 ]),
606 "main row after add"
607 );
608 assert!(
609 !page.body.contains("(default branch): protected by default"),
610 "built-in row must disappear once a rule matches"
611 );
612
613 // Updating the same pattern toggles the value and does not add a second row.
614 let updated = http
615 .post_form(
616 "/bob/app/settings/branches",
617 &[("csrf", &owner_csrf), ("pattern", "main")],
618 Some(&bob),
619 )
620 .await;
621 assert_eq!(updated.status, 303, "update rule: {}", updated.body);
622 let page = http.get("/bob/app/settings", Some(&bob)).await;
623 assert_eq!(
624 rule_row(&page.body, "main"),
625 Some(vec![
626 "main".into(),
627 "no".into(),
628 "no".into(),
629 "none".into(),
630 "Remove".into()
631 ]),
632 "main row after update"
633 );
634 assert_eq!(
635 branch_rows(&page.body).len(),
636 1,
637 "update must not add a row"
638 );
639
640 // An invalid pattern is refused inline and nothing is saved.
641 let invalid = http
642 .post_form(
643 "/bob/app/settings/branches",
644 &[("csrf", &owner_csrf), ("pattern", "bad..name")],
645 Some(&bob),
646 )
647 .await;
648 assert_eq!(invalid.status, 200, "invalid pattern re-renders the page");
649 assert!(
650 invalid.body.contains("role=\"alert\""),
651 "inline error missing"
652 );
653 assert!(
654 invalid.body.contains("branch pattern"),
655 "error text missing"
656 );
657 assert!(
658 invalid.body.contains("value=\"bad..name\""),
659 "the entered pattern is kept in the form"
660 );
661 let page = http.get("/bob/app/settings", Some(&bob)).await;
662 assert!(
663 rule_row(&page.body, "bad..name").is_none(),
664 "invalid rule must not be saved"
665 );
666
667 // No CSRF token is rejected.
668 let no_csrf = http
669 .post_form(
670 "/bob/app/settings/branches",
671 &[("pattern", "release/*"), ("allow_deletion", "on")],
672 Some(&bob),
673 )
674 .await;
675 assert_eq!(no_csrf.status, 403, "missing CSRF on save");
676 let no_csrf_delete = http
677 .post_form(
678 "/bob/app/settings/branches/delete",
679 &[("rule_id", "x")],
680 Some(&bob),
681 )
682 .await;
683 assert_eq!(no_csrf_delete.status, 403, "missing CSRF on remove");
684 let page = http.get("/bob/app/settings", Some(&bob)).await;
685 assert!(
686 rule_row(&page.body, "release/*").is_none(),
687 "rule saved without CSRF"
688 );
689 assert!(
690 rule_row(&page.body, "main").is_some(),
691 "main kept without CSRF"
692 );
693
694 // Remove works: the row goes and the built-in row comes back.
695 let rule_id = input_value(&page.body, "rule_id");
696 let removed = http
697 .post_form(
698 "/bob/app/settings/branches/delete",
699 &[("csrf", &owner_csrf), ("rule_id", &rule_id)],
700 Some(&bob),
701 )
702 .await;
703 assert_eq!(removed.status, 303, "remove rule: {}", removed.body);
704 let page = http.get("/bob/app/settings", Some(&bob)).await;
705 assert!(rule_row(&page.body, "main").is_none(), "main removed");
706 assert!(
707 page.body.contains("(default branch): protected by default"),
708 "built-in row back after remove"
709 );
710
711 // A Write collaborator cannot open the settings or post rules.
712 let dan_page = http.get("/bob/app/settings", Some(&dan)).await;
713 assert_eq!(dan_page.status, 403, "write collaborator settings page");
714 let dan_csrf = csrf_for(&http, &dan).await;
715 let dan_save = http
716 .post_form(
717 "/bob/app/settings/branches",
718 &[("csrf", &dan_csrf), ("pattern", "main")],
719 Some(&dan),
720 )
721 .await;
722 assert_eq!(dan_save.status, 403, "write collaborator save");
723 assert!(
724 http.get("/bob/app/settings", Some(&bob))
725 .await
726 .body
727 .contains("(default branch): protected by default"),
728 "write collaborator must not change rules"
729 );
730
731 // An Admin collaborator manages rules, sees no danger zone and cannot delete the repo.
732 let carol_page = http.get("/bob/app/settings", Some(&carol)).await;
733 assert_eq!(carol_page.status, 200, "admin collaborator settings page");
734 assert!(carol_page.body.contains("Branch protection"));
735 assert!(
736 !carol_page.body.contains("Danger zone"),
737 "admin collaborator must not see the danger zone"
738 );
739 assert!(
740 http.get("/bob/app", Some(&carol))
741 .await
742 .body
743 .contains("href=\"/bob/app/settings\""),
744 "Settings tab shows for an Admin collaborator"
745 );
746 let carol_csrf = csrf_for(&http, &carol).await;
747 let carol_save = http
748 .post_form(
749 "/bob/app/settings/branches",
750 &[
751 ("csrf", &carol_csrf),
752 ("pattern", "release/*"),
753 ("allow_force_push", "on"),
754 ],
755 Some(&carol),
756 )
757 .await;
758 assert_eq!(carol_save.status, 303, "admin collaborator save");
759 let page = http.get("/bob/app/settings", Some(&carol)).await;
760 assert_eq!(
761 rule_row(&page.body, "release/*"),
762 Some(vec![
763 "release/*".into(),
764 "yes".into(),
765 "no".into(),
766 "none".into(),
767 "Remove".into()
768 ]),
769 "admin collaborator's rule"
770 );
771 let carol_delete_rule = http
772 .post_form(
773 "/bob/app/settings/branches/delete",
774 &[
775 ("csrf", &carol_csrf),
776 ("rule_id", &input_value(&page.body, "rule_id")),
777 ],
778 Some(&carol),
779 )
780 .await;
781 assert_eq!(carol_delete_rule.status, 303, "admin collaborator remove");
782 assert!(
783 rule_row(
784 &http.get("/bob/app/settings", Some(&bob)).await.body,
785 "release/*"
786 )
787 .is_none(),
788 "admin collaborator removed the rule"
789 );
790 let carol_delete_repo = http
791 .post_form(
792 "/bob/app/settings/delete",
793 &[("csrf", &carol_csrf), ("confirm", "bob/app")],
794 Some(&carol),
795 )
796 .await;
797 assert_eq!(
798 carol_delete_repo.status, 403,
799 "admin collaborator repo delete"
800 );
801 assert_eq!(
802 http.get("/bob/app", Some(&bob)).await.status,
803 200,
804 "repo kept after the admin collaborator's delete"
805 );
806 drop(server);
807}
808
809/// Creates commits in a scratch work tree and pushes them as `branches` into the bare repo.
810fn seed_branches(dir: &Path, repo_id: &str, branches: &[&str]) {
811 let bare = forge_git::repo::repo_path(dir, repo_id).expect("repo path");
812 let work = dir.join(format!("seed-{repo_id}"));
813 fs::create_dir_all(&work).expect("seed work dir");
814 let run = |args: &[&str]| {
815 let out = Command::new("git")
816 .arg("-C")
817 .arg(&work)
818 .args([
819 "-c",
820 "user.name=seed",
821 "-c",
822 "user.email=seed@example.invalid",
823 ])
824 .args(["-c", "commit.gpgsign=false"])
825 .args(args)
826 .output()
827 .expect("run git");
828 assert!(out.status.success(), "git {args:?}: {}", describe(&out));
829 };
830 run(&["init", "--quiet"]);
831 run(&["commit", "--quiet", "--allow-empty", "-m", "seed"]);
832 let refspecs: Vec<String> = branches
833 .iter()
834 .map(|b| format!("HEAD:refs/heads/{b}"))
835 .collect();
836 let mut push = vec!["push", "--quiet"];
837 let bare_str = bare.to_str().expect("utf-8 path");
838 push.push(bare_str);
839 push.extend(refspecs.iter().map(String::as_str));
840 run(&push);
841}
842
843/// The symbolic target of HEAD in the bare repo, e.g. `refs/heads/dev`.
844fn git_head(data_dir: &Path, repo_id: &str) -> String {
845 let bare = forge_git::repo::repo_path(data_dir, repo_id).expect("repo path");
846 let out = Command::new("git")
847 .arg("--git-dir")
848 .arg(&bare)
849 .args(["symbolic-ref", "HEAD"])
850 .output()
851 .expect("run git symbolic-ref");
852 assert!(out.status.success(), "symbolic-ref: {}", describe(&out));
853 String::from_utf8_lossy(&out.stdout).trim().to_owned()
854}
855
856/// The value of the `selected` option of the default-branch select, if the page has one.
857fn selected_branch(body: &str) -> Option<String> {
858 let doc = scraper::Html::parse_document(body);
859 let selector =
860 scraper::Selector::parse("select[name=\"default_branch\"] option[selected]").expect("sel");
861 doc.select(&selector)
862 .next()
863 .and_then(|el| el.value().attr("value"))
864 .map(str::to_owned)
865}
866
867/// Cookie header with the session plus a one-shot settings notice set by the previous response.
868fn with_notice(session: &str, res: &Resp) -> String {
869 match cookie_named(res, "forge_settings_notice") {
870 Some(notice) => format!("{session}; {notice}"),
871 None => session.to_owned(),
872 }
873}
874
875#[tokio::test]
876async fn general_settings_end_to_end() {
877 if !tool_on_path("git") {
878 eprintln!("SKIP general_settings_end_to_end: `git` was not found on PATH");
879 return;
880 }
881
882 let root = tempfile::Builder::new()
883 .prefix("fgen")
884 .tempdir_in("/tmp")
885 .expect("temp dir");
886 let dir = root.path().to_path_buf();
887 let ports = pick_ports();
888 let config = write_config(&dir, ports);
889
890 admin(&config, &["create-user", "alice", "--admin"]);
891 admin(&config, &["create-user", "bob"]);
892 admin(&config, &["create-user", "carol"]);
893
894 let gen_id = {
895 let store = Store::open(&dir.join("forge.db"))
896 .await
897 .expect("open store");
898 let bob = store.user_by_username("bob").await.unwrap().expect("bob");
899 let carol = store
900 .user_by_username("carol")
901 .await
902 .unwrap()
903 .expect("carol");
904 let gen_repo = store
905 .create_repo(&bob.id, "gen", "", Visibility::Public, "main")
906 .await
907 .expect("gen repo");
908 let empty = store
909 .create_repo(&bob.id, "empty", "", Visibility::Public, "main")
910 .await
911 .expect("empty repo");
912 store
913 .set_collaborator(&gen_repo.id, &carol.id, Role::Write)
914 .await
915 .expect("carol write collaborator");
916 init_bare(&dir, &gen_repo.id);
917 init_bare(&dir, &empty.id);
918 seed_branches(&dir, &gen_repo.id, &["main", "dev"]);
919 gen_repo.id
920 };
921
922 let (server, ports) = launch(&dir, ports).await;
923 let http = Http::new(ports.http);
924 let bob = login(&http, "bob").await;
925 let carol = login(&http, "carol").await;
926
927 // The General section is the first card on the page, with the sub-navigation above it.
928 let page = http.get("/bob/gen/settings", Some(&bob)).await;
929 assert_eq!(page.status, 200, "owner settings page");
930 assert!(
931 page.body.contains("Description"),
932 "description field missing"
933 );
934 assert_eq!(selected_branch(&page.body).as_deref(), Some("main"));
935 assert!(
936 page.body
937 .contains("href=\"/bob/gen/settings/collaborators\"")
938 );
939 assert!(page.body.contains("href=\"/bob/gen/settings/rename\""));
940
941 // Description: control characters are stripped and it shows on the repo header.
942 let csrf = csrf_for(&http, &bob).await;
943 let saved = http
944 .post_form(
945 "/bob/gen/settings/general",
946 &[
947 ("csrf", &csrf),
948 ("description", "Hello\u{7} forge "),
949 ("default_branch", "main"),
950 ],
951 Some(&bob),
952 )
953 .await;
954 assert_eq!(saved.status, 303, "general save: {}", saved.body);
955 let home = http.get("/bob/gen", Some(&bob)).await;
956 assert!(
957 home.body.contains("Hello forge"),
958 "description not on header"
959 );
960
961 // Default branch: an existing branch updates the DB value and git HEAD.
962 let switched = http
963 .post_form(
964 "/bob/gen/settings/general",
965 &[
966 ("csrf", &csrf),
967 ("description", "Hello forge"),
968 ("default_branch", "dev"),
969 ],
970 Some(&bob),
971 )
972 .await;
973 assert_eq!(switched.status, 303, "branch switch: {}", switched.body);
974 assert_eq!(git_head(&dir, &gen_id), "refs/heads/dev");
975 let after = http.get("/bob/gen/settings", Some(&bob)).await;
976 assert_eq!(selected_branch(&after.body).as_deref(), Some("dev"));
977
978 // A nonexistent branch is rejected: HEAD and the DB keep dev.
979 let missing = http
980 .post_form(
981 "/bob/gen/settings/general",
982 &[
983 ("csrf", &csrf),
984 ("description", "Hello forge"),
985 ("default_branch", "nope"),
986 ],
987 Some(&bob),
988 )
989 .await;
990 assert_eq!(missing.status, 303);
991 let notice_page = http
992 .get("/bob/gen/settings", Some(&with_notice(&bob, &missing)))
993 .await;
994 assert!(
995 notice_page.body.contains("That branch does not exist"),
996 "missing branch notice"
997 );
998 assert_eq!(git_head(&dir, &gen_id), "refs/heads/dev");
999 assert_eq!(
1000 selected_branch(&http.get("/bob/gen/settings", Some(&bob)).await.body).as_deref(),
1001 Some("dev")
1002 );
1003
1004 // Over-long description is rejected.
1005 let long = "x".repeat(351);
1006 let too_long = http
1007 .post_form(
1008 "/bob/gen/settings/general",
1009 &[
1010 ("csrf", &csrf),
1011 ("description", &long),
1012 ("default_branch", "dev"),
1013 ],
1014 Some(&bob),
1015 )
1016 .await;
1017 assert_eq!(too_long.status, 303);
1018 let long_page = http
1019 .get("/bob/gen/settings", Some(&with_notice(&bob, &too_long)))
1020 .await;
1021 assert!(long_page.body.contains("at most 350 characters"));
1022
1023 // Visibility: changing to private needs "I understand"; then anonymous gets 404.
1024 let vis_csrf = csrf_for(&http, &bob).await;
1025 let no_ack = http
1026 .post_form(
1027 "/bob/gen/settings/visibility",
1028 &[("csrf", &vis_csrf), ("visibility", "private")],
1029 Some(&bob),
1030 )
1031 .await;
1032 assert_eq!(no_ack.status, 303);
1033 assert_eq!(
1034 http.get("/bob/gen", None).await.status,
1035 200,
1036 "still public without the confirmation"
1037 );
1038 let private = http
1039 .post_form(
1040 "/bob/gen/settings/visibility",
1041 &[
1042 ("csrf", &vis_csrf),
1043 ("visibility", "private"),
1044 ("understand", "on"),
1045 ],
1046 Some(&bob),
1047 )
1048 .await;
1049 assert_eq!(private.status, 303);
1050 assert_eq!(
1051 http.get("/bob/gen", None).await.status,
1052 404,
1053 "anonymous on private"
1054 );
1055 assert_eq!(
1056 http.get("/bob/gen", Some(&bob)).await.status,
1057 200,
1058 "owner on private"
1059 );
1060 let public_again = http
1061 .post_form(
1062 "/bob/gen/settings/visibility",
1063 &[
1064 ("csrf", &vis_csrf),
1065 ("visibility", "public"),
1066 ("understand", "on"),
1067 ],
1068 Some(&bob),
1069 )
1070 .await;
1071 assert_eq!(public_again.status, 303);
1072 assert_eq!(
1073 http.get("/bob/gen", None).await.status,
1074 200,
1075 "anonymous on public"
1076 );
1077
1078 // Archive, then unarchive. The banner shows only while archived.
1079 let arch_csrf = csrf_for(&http, &bob).await;
1080 let archive = http
1081 .post_form(
1082 "/bob/gen/settings/archive",
1083 &[("csrf", &arch_csrf), ("archived", "1")],
1084 Some(&bob),
1085 )
1086 .await;
1087 assert_eq!(archive.status, 303);
1088 assert!(
1089 http.get("/bob/gen", Some(&bob))
1090 .await
1091 .body
1092 .contains("This repository is archived"),
1093 "archived banner"
1094 );
1095 let unarchive = http
1096 .post_form(
1097 "/bob/gen/settings/archive",
1098 &[("csrf", &arch_csrf), ("archived", "0")],
1099 Some(&bob),
1100 )
1101 .await;
1102 assert_eq!(unarchive.status, 303);
1103 assert!(
1104 !http
1105 .get("/bob/gen", Some(&bob))
1106 .await
1107 .body
1108 .contains("This repository is archived"),
1109 "banner after unarchive"
1110 );
1111
1112 // A Write collaborator cannot reach the settings: GET and every POST are 403.
1113 let carol_page = http.get("/bob/gen/settings", Some(&carol)).await;
1114 assert_eq!(carol_page.status, 403, "write collaborator settings page");
1115 let carol_csrf = csrf_for(&http, &carol).await;
1116 let carol_general = http
1117 .post_form(
1118 "/bob/gen/settings/general",
1119 &[
1120 ("csrf", &carol_csrf),
1121 ("description", "pwned"),
1122 ("default_branch", "main"),
1123 ],
1124 Some(&carol),
1125 )
1126 .await;
1127 assert_eq!(carol_general.status, 403, "write collaborator general save");
1128 let carol_archive = http
1129 .post_form(
1130 "/bob/gen/settings/archive",
1131 &[("csrf", &carol_csrf), ("archived", "1")],
1132 Some(&carol),
1133 )
1134 .await;
1135 assert_eq!(carol_archive.status, 403, "write collaborator archive");
1136 assert_eq!(
1137 git_head(&dir, &gen_id),
1138 "refs/heads/dev",
1139 "HEAD untouched by carol"
1140 );
1141
1142 // No CSRF token, or a wrong one, is rejected on every General POST.
1143 let no_csrf = http
1144 .post_form(
1145 "/bob/gen/settings/general",
1146 &[("description", "no token"), ("default_branch", "main")],
1147 Some(&bob),
1148 )
1149 .await;
1150 assert_eq!(no_csrf.status, 403, "general without csrf");
1151 let bad_csrf = http
1152 .post_form(
1153 "/bob/gen/settings/visibility",
1154 &[
1155 ("csrf", "forged"),
1156 ("visibility", "private"),
1157 ("understand", "on"),
1158 ],
1159 Some(&bob),
1160 )
1161 .await;
1162 assert_eq!(bad_csrf.status, 403, "visibility with a wrong csrf");
1163 assert_eq!(
1164 http.get("/bob/gen", None).await.status,
1165 200,
1166 "visibility unchanged"
1167 );
1168
1169 // An empty repo says so and keeps the current value.
1170 let empty_page = http.get("/bob/empty/settings", Some(&bob)).await;
1171 assert!(
1172 empty_page.body.contains("No branches yet"),
1173 "empty repo notice"
1174 );
1175 assert!(selected_branch(&empty_page.body).is_none());
1176 let empty_csrf = csrf_for(&http, &bob).await;
1177 let empty_save = http
1178 .post_form(
1179 "/bob/empty/settings/general",
1180 &[("csrf", &empty_csrf), ("description", "Fresh repo")],
1181 Some(&bob),
1182 )
1183 .await;
1184 assert_eq!(empty_save.status, 303);
1185 assert!(
1186 http.get("/bob/empty", Some(&bob))
1187 .await
1188 .body
1189 .contains("Fresh repo"),
1190 "empty repo description saved"
1191 );
1192 drop(server);
1193}