//! Black-box test for repo deletion. It starts the real `forge` binary, seeds repos through the //! store before the server starts, and then drives the settings page and the delete form over HTTP. //! //! Cookies are handled by hand because the session cookie is `__Host-forge_session` with `Secure`, //! which a cookie jar would not send over plain http. mod common; use common::{BIN, describe, launch, pick_ports, tool_on_path, write_config}; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::time::Duration; use forge_core::{Role, Store, Visibility}; use reqwest::header::{COOKIE, HeaderMap, HeaderName, LOCATION, SET_COOKIE}; const PW: &str = "alice-password-123"; struct Resp { status: u16, headers: HeaderMap, body: String, } impl Resp { fn header(&self, name: HeaderName) -> Option { self.headers .get(name) .and_then(|v| v.to_str().ok()) .map(str::to_owned) } fn set_cookies(&self) -> Vec { self.headers .get_all(SET_COOKIE) .iter() .filter_map(|v| v.to_str().ok().map(str::to_owned)) .collect() } } struct Http { client: reqwest::Client, base: String, } impl Http { fn new(port: u16) -> Self { let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .timeout(Duration::from_secs(60)) .build() .expect("http client"); Self { client, base: format!("http://127.0.0.1:{port}"), } } async fn get(&self, path: &str, cookie: Option<&str>) -> Resp { self.send(self.client.get(format!("{}{path}", self.base)), cookie) .await } async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp { self.send( self.client.post(format!("{}{path}", self.base)).form(form), cookie, ) .await } async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp { let req = match cookie { Some(cookie) => req.header(COOKIE, cookie), None => req, }; let res = req.send().await.expect("http request"); let status = res.status().as_u16(); let headers = res.headers().clone(); let body = res.text().await.expect("read body"); Resp { status, headers, body, } } } /// `name=value` for the cookie whose name ends with `suffix`, ignoring cleared cookies. fn cookie_named(resp: &Resp, suffix: &str) -> Option { resp.set_cookies().iter().find_map(|set| { let pair = set.split(';').next()?; let (name, value) = pair.split_once('=')?; (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}")) }) } /// The value of the first `` on a page. fn input_value(body: &str, name: &str) -> String { let doc = scraper::Html::parse_document(body); let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector"); doc.select(&selector) .next() .and_then(|el| el.value().attr("value")) .unwrap_or_else(|| panic!("no input named {name} on page")) .to_owned() } /// Runs an admin subcommand with the password in FORGE_PASSWORD and asserts it succeeded. fn admin(config: &Path, args: &[&str]) -> Output { let out = Command::new(BIN) .arg("--config") .arg(config) .arg("admin") .args(args) .env("FORGE_PASSWORD", PW) .output() .expect("run admin command"); assert!( out.status.success(), "admin {args:?} failed:\n{}", describe(&out) ); out } async fn login(http: &Http, username: &str) -> String { let page = http.get("/login", None).await; assert_eq!(page.status, 200, "GET /login"); let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie"); let precsrf = input_value(&page.body, "precsrf"); let res = http .post_form( "/login", &[ ("username", username), ("password", PW), ("next", "/"), ("precsrf", &precsrf), ], Some(&precsrf_cookie), ) .await; assert_eq!(res.status, 303, "login as {username}: {}", res.body); cookie_named(&res, "forge_session").expect("session cookie after login") } /// The session's CSRF token, read from a page every signed-in user can open. async fn csrf_for(http: &Http, session: &str) -> String { let page = http.get("/settings/tokens", Some(session)).await; assert_eq!(page.status, 200, "GET /settings/tokens"); input_value(&page.body, "csrf") } /// Creates a bare git repo for `repo_id` at its data-dir location, as the create-repo page does. fn init_bare(data_dir: &Path, repo_id: &str) { let path = forge_git::repo::repo_path(data_dir, repo_id).expect("repo path"); fs::create_dir_all(path.parent().expect("parent")).expect("create repo parent"); let out = Command::new("git") .args(["init", "--bare", "--quiet"]) .arg(&path) .output() .expect("run git init"); assert!(out.status.success(), "git init --bare: {}", describe(&out)); } /// Directories in `data_dir/trash` for the repo `id` (lowercase, as the trash names them). fn trash_entries(data_dir: &Path, repo_id: &str) -> Vec { let prefix = format!("{}-", repo_id.to_ascii_lowercase()); let Ok(read) = fs::read_dir(data_dir.join(forge_git::repo::TRASH_DIR)) else { return Vec::new(); }; read.filter_map(Result::ok) .map(|e| e.path()) .filter(|p| { p.file_name() .and_then(|n| n.to_str()) .is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".git")) }) .collect() } #[tokio::test] async fn repo_settings_end_to_end() { if !tool_on_path("git") { eprintln!("SKIP repo_settings_e2e: `git` was not found on PATH"); return; } // Keep the path short: the data dir holds a unix socket with a length limit. let root = tempfile::Builder::new() .prefix("frs") .tempdir_in("/tmp") .expect("temp dir"); let dir = root.path().to_path_buf(); let ports = pick_ports(); let config = write_config(&dir, ports); // The first user is an admin. Bob owns the repos, carol is an admin collaborator on the private // one, and alice (admin) must still be able to delete bob's repo. admin(&config, &["create-user", "alice", "--admin"]); admin(&config, &["create-user", "bob"]); admin(&config, &["create-user", "carol"]); let (public_id, private_id) = { let store = Store::open(&dir.join("forge.db")) .await .expect("open store"); let bob = store.user_by_username("bob").await.unwrap().expect("bob"); let carol = store .user_by_username("carol") .await .unwrap() .expect("carol"); let public = store .create_repo(&bob.id, "pub", "", Visibility::Public, "main") .await .expect("public repo"); let private = store .create_repo(&bob.id, "priv", "", Visibility::Private, "main") .await .expect("private repo"); store .set_collaborator(&private.id, &carol.id, Role::Admin) .await .expect("carol collaborator"); init_bare(&dir, &public.id); init_bare(&dir, &private.id); (public.id, private.id) }; let (server, ports) = launch(&dir, ports).await; let http = Http::new(ports.http); let bob = login(&http, "bob").await; let carol = login(&http, "carol").await; let alice = login(&http, "alice").await; // Anonymous on a private repo: 404, the same as a missing repo. let anon = http.get("/bob/priv/settings", None).await; assert_eq!(anon.status, 404, "anonymous settings on private repo"); // A collaborator with the Admin role gets the settings page (branch rules) but no danger zone, // and a POST to the delete form is 403. The repo stays. let carol_page = http.get("/bob/priv/settings", Some(&carol)).await; assert_eq!(carol_page.status, 200, "admin collaborator settings page"); assert!( !carol_page.body.contains("Danger zone"), "admin collaborator must not see the danger zone" ); let carol_csrf = csrf_for(&http, &carol).await; let carol_delete = http .post_form( "/bob/priv/settings/delete", &[("csrf", &carol_csrf), ("confirm", "bob/priv")], Some(&carol), ) .await; assert_eq!(carol_delete.status, 403, "collaborator delete"); assert_eq!(http.get("/bob/priv", Some(&carol)).await.status, 200); // The owner sees the danger zone, and the Settings tab appears on the repo header. let owner_page = http.get("/bob/pub/settings", Some(&bob)).await; assert_eq!(owner_page.status, 200, "owner settings page"); assert!( owner_page.body.contains("Danger zone"), "route did not reach the settings page" ); assert!( owner_page.body.contains("href=\"/bob/pub/settings\""), "Settings tab missing" ); let collaborator_repo_page = http.get("/bob/priv", Some(&carol)).await; assert!( collaborator_repo_page .body .contains("href=\"/bob/priv/settings\""), "Settings tab shows to an Admin collaborator" ); // A wrong confirmation is an error and keeps the repo. let owner_csrf = csrf_for(&http, &bob).await; let wrong = http .post_form( "/bob/pub/settings/delete", &[("csrf", &owner_csrf), ("confirm", "pub")], Some(&bob), ) .await; assert_eq!(wrong.status, 200, "wrong confirmation re-renders the page"); assert!( wrong.body.contains("exactly to confirm"), "missing mismatch error" ); assert_eq!( http.get("/bob/pub", Some(&bob)).await.status, 200, "repo kept" ); assert!( trash_entries(&dir, &public_id).is_empty(), "nothing trashed on mismatch" ); // No CSRF token is rejected, even with the right confirmation. let no_csrf = http .post_form( "/bob/pub/settings/delete", &[("confirm", "bob/pub")], Some(&bob), ) .await; assert_eq!(no_csrf.status, 403, "missing CSRF token"); assert_eq!( http.get("/bob/pub", Some(&bob)).await.status, 200, "repo kept without CSRF" ); // The owner deletes: redirect home with a notice, the repo is gone, the directory is trashed. let deleted = http .post_form( "/bob/pub/settings/delete", &[("csrf", &owner_csrf), ("confirm", "bob/pub")], Some(&bob), ) .await; assert_eq!(deleted.status, 303, "owner delete: {}", deleted.body); assert_eq!(deleted.header(LOCATION).as_deref(), Some("/")); let notice = cookie_named(&deleted, "forge_notice").expect("notice cookie"); assert_eq!( http.get("/bob/pub", Some(&bob)).await.status, 404, "repo page after delete" ); assert_eq!( http.get("/bob/pub/settings", Some(&bob)).await.status, 404, "settings after delete" ); let trashed = trash_entries(&dir, &public_id); assert_eq!(trashed.len(), 1, "one trash entry for the deleted repo"); assert!( trashed[0].join("HEAD").is_file(), "trash entry is a bare repo" ); // The next home page shows the notice once. let home = http.get("/", Some(&format!("{bob}; {notice}"))).await; assert!(home.body.contains("Repository"), "home page notice missing"); assert!( home.body.contains("bob/pub"), "home page notice names the repo" ); // An admin who does not own the repo can delete it. let admin_page = http.get("/bob/priv/settings", Some(&alice)).await; assert_eq!(admin_page.status, 200, "admin settings page"); let admin_csrf = csrf_for(&http, &alice).await; let admin_delete = http .post_form( "/bob/priv/settings/delete", &[("csrf", &admin_csrf), ("confirm", "bob/priv")], Some(&alice), ) .await; assert_eq!(admin_delete.status, 303, "admin delete"); assert_eq!( http.get("/bob/priv", Some(&alice)).await.status, 404, "admin-deleted repo" ); assert_eq!( trash_entries(&dir, &private_id).len(), 1, "admin delete is trashed" ); // The admin CLI lists both trash entries, then purges them. let listed = String::from_utf8_lossy(&admin(&config, &["trash-list"]).stdout).into_owned(); assert!( listed.contains(&public_id.to_ascii_lowercase()), "trash-list: {listed}" ); assert!( listed.contains(&private_id.to_ascii_lowercase()), "trash-list: {listed}" ); let purged = admin(&config, &["trash-purge", "--older-than-days", "0"]); assert!( String::from_utf8_lossy(&purged.stdout).contains("purged 2 trashed repositories"), "{}", describe(&purged) ); assert!(trash_entries(&dir, &public_id).is_empty()); assert!(trash_entries(&dir, &private_id).is_empty()); drop(server); } /// A repo whose bare directory was removed by hand can still be deleted from the UI: the DB rows /// go, nothing is trashed, and the request succeeds. #[tokio::test] async fn repo_settings_deletes_rows_when_repo_dir_is_missing() { if !tool_on_path("git") { eprintln!("SKIP repo_settings_e2e: `git` was not found on PATH"); return; } let root = tempfile::Builder::new() .prefix("frm") .tempdir_in("/tmp") .expect("temp dir"); let dir = root.path().to_path_buf(); let ports = pick_ports(); let config = write_config(&dir, ports); admin(&config, &["create-user", "bob"]); let (repo_id, bare_dir) = { let store = Store::open(&dir.join("forge.db")) .await .expect("open store"); let bob = store.user_by_username("bob").await.unwrap().expect("bob"); let repo = store .create_repo(&bob.id, "gone", "", Visibility::Public, "main") .await .expect("repo"); init_bare(&dir, &repo.id); let bare = forge_git::repo::repo_path(&dir, &repo.id).expect("repo path"); // Simulate the broken state: the DB row exists but the bare directory is gone. fs::remove_dir_all(&bare).expect("remove bare dir by hand"); (repo.id, bare) }; let (server, ports) = launch(&dir, ports).await; let http = Http::new(ports.http); let bob = login(&http, "bob").await; let csrf = csrf_for(&http, &bob).await; let deleted = http .post_form( "/bob/gone/settings/delete", &[("csrf", &csrf), ("confirm", "bob/gone")], Some(&bob), ) .await; assert_eq!( deleted.status, 303, "delete with missing dir: {}", deleted.body ); assert_eq!(deleted.header(LOCATION).as_deref(), Some("/")); assert_eq!( http.get("/bob/gone", Some(&bob)).await.status, 404, "repo page after delete" ); assert!(!bare_dir.exists(), "missing dir must not be recreated"); assert!( trash_entries(&dir, &repo_id).is_empty(), "nothing to trash when the dir is missing" ); // Stop the server first: the database file is locked while it runs. drop(server); let store = Store::open(&dir.join("forge.db")) .await .expect("open store"); assert!( store.repo_by_id(&repo_id).await.unwrap().is_none(), "DB row removed" ); let log = fs::read_to_string(dir.join("serve.log")).unwrap_or_default(); assert!( log.contains("repo dir missing, deleting DB rows only") && log.contains(&repo_id), "missing warning in log:\n{log}" ); } /// Rows of the branch rules table as their cell texts, in page order. The built-in default-branch /// row is a single cell with a `colspan`, so it appears as a one-cell row. fn branch_rows(body: &str) -> Vec> { let doc = scraper::Html::parse_document(body); let rows = scraper::Selector::parse("table.branch-rules tbody tr").expect("selector"); let cells = scraper::Selector::parse("td").expect("selector"); doc.select(&rows) .map(|row| { row.select(&cells) .map(|cell| cell.text().collect::().trim().to_owned()) .collect() }) .collect() } /// The cells of the rule row for `pattern`, if the table has one. fn rule_row(body: &str, pattern: &str) -> Option> { branch_rows(body) .into_iter() .find(|cells| cells.first().is_some_and(|c| c == pattern) && cells.len() == 5) } /// Owner adds, updates, invalidates and removes branch rules. A Write collaborator and an Admin /// collaborator get the right access: the Admin can manage rules but sees no danger zone. #[tokio::test] async fn branch_protection_end_to_end() { if !tool_on_path("git") { eprintln!("SKIP repo_settings_e2e: `git` was not found on PATH"); return; } let root = tempfile::Builder::new() .prefix("frb") .tempdir_in("/tmp") .expect("temp dir"); let dir = root.path().to_path_buf(); let ports = pick_ports(); let config = write_config(&dir, ports); // The first user is an instance admin, so bob (the owner) is an ordinary user. admin(&config, &["create-user", "alice", "--admin"]); admin(&config, &["create-user", "bob"]); admin(&config, &["create-user", "carol"]); admin(&config, &["create-user", "dan"]); { let store = Store::open(&dir.join("forge.db")) .await .expect("open store"); let bob = store.user_by_username("bob").await.unwrap().expect("bob"); let carol = store .user_by_username("carol") .await .unwrap() .expect("carol"); let dan = store.user_by_username("dan").await.unwrap().expect("dan"); let app = store .create_repo(&bob.id, "app", "", Visibility::Public, "main") .await .expect("repo"); store .set_collaborator(&app.id, &carol.id, Role::Admin) .await .expect("carol collaborator"); store .set_collaborator(&app.id, &dan.id, Role::Write) .await .expect("dan collaborator"); init_bare(&dir, &app.id); } let (server, ports) = launch(&dir, ports).await; let http = Http::new(ports.http); let bob = login(&http, "bob").await; let carol = login(&http, "carol").await; let dan = login(&http, "dan").await; // Owner: the built-in row is there before any rule, and the danger zone is shown. let page = http.get("/bob/app/settings", Some(&bob)).await; assert_eq!(page.status, 200, "owner settings page"); assert!(page.body.contains("Branch protection"), "section missing"); assert!( page.body.contains("(default branch): protected by default"), "built-in row missing" ); assert!( page.body.contains("Danger zone"), "owner sees the danger zone" ); assert_eq!( input_value(&page.body, "pattern"), "main", "pattern is prefilled with the default branch" ); let owner_csrf = csrf_for(&http, &bob).await; // Adding main with force push allowed: it appears, and the built-in row disappears. let added = http .post_form( "/bob/app/settings/branches", &[ ("csrf", &owner_csrf), ("pattern", "main"), ("allow_force_push", "on"), ], Some(&bob), ) .await; assert_eq!(added.status, 303, "add rule: {}", added.body); assert_eq!(added.header(LOCATION).as_deref(), Some("/bob/app/settings")); let notice = cookie_named(&added, "forge_settings_notice").expect("settings notice cookie"); let page = http .get("/bob/app/settings", Some(&format!("{bob}; {notice}"))) .await; assert!( page.body.contains("Branch rule saved."), "save notice missing" ); assert_eq!( rule_row(&page.body, "main"), Some(vec![ "main".into(), "yes".into(), "no".into(), "none".into(), "Remove".into() ]), "main row after add" ); assert!( !page.body.contains("(default branch): protected by default"), "built-in row must disappear once a rule matches" ); // Updating the same pattern toggles the value and does not add a second row. let updated = http .post_form( "/bob/app/settings/branches", &[("csrf", &owner_csrf), ("pattern", "main")], Some(&bob), ) .await; assert_eq!(updated.status, 303, "update rule: {}", updated.body); let page = http.get("/bob/app/settings", Some(&bob)).await; assert_eq!( rule_row(&page.body, "main"), Some(vec![ "main".into(), "no".into(), "no".into(), "none".into(), "Remove".into() ]), "main row after update" ); assert_eq!( branch_rows(&page.body).len(), 1, "update must not add a row" ); // An invalid pattern is refused inline and nothing is saved. let invalid = http .post_form( "/bob/app/settings/branches", &[("csrf", &owner_csrf), ("pattern", "bad..name")], Some(&bob), ) .await; assert_eq!(invalid.status, 200, "invalid pattern re-renders the page"); assert!( invalid.body.contains("role=\"alert\""), "inline error missing" ); assert!( invalid.body.contains("branch pattern"), "error text missing" ); assert!( invalid.body.contains("value=\"bad..name\""), "the entered pattern is kept in the form" ); let page = http.get("/bob/app/settings", Some(&bob)).await; assert!( rule_row(&page.body, "bad..name").is_none(), "invalid rule must not be saved" ); // No CSRF token is rejected. let no_csrf = http .post_form( "/bob/app/settings/branches", &[("pattern", "release/*"), ("allow_deletion", "on")], Some(&bob), ) .await; assert_eq!(no_csrf.status, 403, "missing CSRF on save"); let no_csrf_delete = http .post_form( "/bob/app/settings/branches/delete", &[("rule_id", "x")], Some(&bob), ) .await; assert_eq!(no_csrf_delete.status, 403, "missing CSRF on remove"); let page = http.get("/bob/app/settings", Some(&bob)).await; assert!( rule_row(&page.body, "release/*").is_none(), "rule saved without CSRF" ); assert!( rule_row(&page.body, "main").is_some(), "main kept without CSRF" ); // Remove works: the row goes and the built-in row comes back. let rule_id = input_value(&page.body, "rule_id"); let removed = http .post_form( "/bob/app/settings/branches/delete", &[("csrf", &owner_csrf), ("rule_id", &rule_id)], Some(&bob), ) .await; assert_eq!(removed.status, 303, "remove rule: {}", removed.body); let page = http.get("/bob/app/settings", Some(&bob)).await; assert!(rule_row(&page.body, "main").is_none(), "main removed"); assert!( page.body.contains("(default branch): protected by default"), "built-in row back after remove" ); // A Write collaborator cannot open the settings or post rules. let dan_page = http.get("/bob/app/settings", Some(&dan)).await; assert_eq!(dan_page.status, 403, "write collaborator settings page"); let dan_csrf = csrf_for(&http, &dan).await; let dan_save = http .post_form( "/bob/app/settings/branches", &[("csrf", &dan_csrf), ("pattern", "main")], Some(&dan), ) .await; assert_eq!(dan_save.status, 403, "write collaborator save"); assert!( http.get("/bob/app/settings", Some(&bob)) .await .body .contains("(default branch): protected by default"), "write collaborator must not change rules" ); // An Admin collaborator manages rules, sees no danger zone and cannot delete the repo. let carol_page = http.get("/bob/app/settings", Some(&carol)).await; assert_eq!(carol_page.status, 200, "admin collaborator settings page"); assert!(carol_page.body.contains("Branch protection")); assert!( !carol_page.body.contains("Danger zone"), "admin collaborator must not see the danger zone" ); assert!( http.get("/bob/app", Some(&carol)) .await .body .contains("href=\"/bob/app/settings\""), "Settings tab shows for an Admin collaborator" ); let carol_csrf = csrf_for(&http, &carol).await; let carol_save = http .post_form( "/bob/app/settings/branches", &[ ("csrf", &carol_csrf), ("pattern", "release/*"), ("allow_force_push", "on"), ], Some(&carol), ) .await; assert_eq!(carol_save.status, 303, "admin collaborator save"); let page = http.get("/bob/app/settings", Some(&carol)).await; assert_eq!( rule_row(&page.body, "release/*"), Some(vec![ "release/*".into(), "yes".into(), "no".into(), "none".into(), "Remove".into() ]), "admin collaborator's rule" ); let carol_delete_rule = http .post_form( "/bob/app/settings/branches/delete", &[ ("csrf", &carol_csrf), ("rule_id", &input_value(&page.body, "rule_id")), ], Some(&carol), ) .await; assert_eq!(carol_delete_rule.status, 303, "admin collaborator remove"); assert!( rule_row( &http.get("/bob/app/settings", Some(&bob)).await.body, "release/*" ) .is_none(), "admin collaborator removed the rule" ); let carol_delete_repo = http .post_form( "/bob/app/settings/delete", &[("csrf", &carol_csrf), ("confirm", "bob/app")], Some(&carol), ) .await; assert_eq!( carol_delete_repo.status, 403, "admin collaborator repo delete" ); assert_eq!( http.get("/bob/app", Some(&bob)).await.status, 200, "repo kept after the admin collaborator's delete" ); drop(server); } /// Creates commits in a scratch work tree and pushes them as `branches` into the bare repo. fn seed_branches(dir: &Path, repo_id: &str, branches: &[&str]) { let bare = forge_git::repo::repo_path(dir, repo_id).expect("repo path"); let work = dir.join(format!("seed-{repo_id}")); fs::create_dir_all(&work).expect("seed work dir"); let run = |args: &[&str]| { let out = Command::new("git") .arg("-C") .arg(&work) .args([ "-c", "user.name=seed", "-c", "user.email=seed@example.invalid", ]) .args(["-c", "commit.gpgsign=false"]) .args(args) .output() .expect("run git"); assert!(out.status.success(), "git {args:?}: {}", describe(&out)); }; run(&["init", "--quiet"]); run(&["commit", "--quiet", "--allow-empty", "-m", "seed"]); let refspecs: Vec = branches .iter() .map(|b| format!("HEAD:refs/heads/{b}")) .collect(); let mut push = vec!["push", "--quiet"]; let bare_str = bare.to_str().expect("utf-8 path"); push.push(bare_str); push.extend(refspecs.iter().map(String::as_str)); run(&push); } /// The symbolic target of HEAD in the bare repo, e.g. `refs/heads/dev`. fn git_head(data_dir: &Path, repo_id: &str) -> String { let bare = forge_git::repo::repo_path(data_dir, repo_id).expect("repo path"); let out = Command::new("git") .arg("--git-dir") .arg(&bare) .args(["symbolic-ref", "HEAD"]) .output() .expect("run git symbolic-ref"); assert!(out.status.success(), "symbolic-ref: {}", describe(&out)); String::from_utf8_lossy(&out.stdout).trim().to_owned() } /// The value of the `selected` option of the default-branch select, if the page has one. fn selected_branch(body: &str) -> Option { let doc = scraper::Html::parse_document(body); let selector = scraper::Selector::parse("select[name=\"default_branch\"] option[selected]").expect("sel"); doc.select(&selector) .next() .and_then(|el| el.value().attr("value")) .map(str::to_owned) } /// Cookie header with the session plus a one-shot settings notice set by the previous response. fn with_notice(session: &str, res: &Resp) -> String { match cookie_named(res, "forge_settings_notice") { Some(notice) => format!("{session}; {notice}"), None => session.to_owned(), } } #[tokio::test] async fn general_settings_end_to_end() { if !tool_on_path("git") { eprintln!("SKIP general_settings_end_to_end: `git` was not found on PATH"); return; } let root = tempfile::Builder::new() .prefix("fgen") .tempdir_in("/tmp") .expect("temp dir"); let dir = root.path().to_path_buf(); let ports = pick_ports(); let config = write_config(&dir, ports); admin(&config, &["create-user", "alice", "--admin"]); admin(&config, &["create-user", "bob"]); admin(&config, &["create-user", "carol"]); let gen_id = { let store = Store::open(&dir.join("forge.db")) .await .expect("open store"); let bob = store.user_by_username("bob").await.unwrap().expect("bob"); let carol = store .user_by_username("carol") .await .unwrap() .expect("carol"); let gen_repo = store .create_repo(&bob.id, "gen", "", Visibility::Public, "main") .await .expect("gen repo"); let empty = store .create_repo(&bob.id, "empty", "", Visibility::Public, "main") .await .expect("empty repo"); store .set_collaborator(&gen_repo.id, &carol.id, Role::Write) .await .expect("carol write collaborator"); init_bare(&dir, &gen_repo.id); init_bare(&dir, &empty.id); seed_branches(&dir, &gen_repo.id, &["main", "dev"]); gen_repo.id }; let (server, ports) = launch(&dir, ports).await; let http = Http::new(ports.http); let bob = login(&http, "bob").await; let carol = login(&http, "carol").await; // The General section is the first card on the page, with the sub-navigation above it. let page = http.get("/bob/gen/settings", Some(&bob)).await; assert_eq!(page.status, 200, "owner settings page"); assert!( page.body.contains("Description"), "description field missing" ); assert_eq!(selected_branch(&page.body).as_deref(), Some("main")); assert!( page.body .contains("href=\"/bob/gen/settings/collaborators\"") ); assert!(page.body.contains("href=\"/bob/gen/settings/rename\"")); // Description: control characters are stripped and it shows on the repo header. let csrf = csrf_for(&http, &bob).await; let saved = http .post_form( "/bob/gen/settings/general", &[ ("csrf", &csrf), ("description", "Hello\u{7} forge "), ("default_branch", "main"), ], Some(&bob), ) .await; assert_eq!(saved.status, 303, "general save: {}", saved.body); let home = http.get("/bob/gen", Some(&bob)).await; assert!( home.body.contains("Hello forge"), "description not on header" ); // Default branch: an existing branch updates the DB value and git HEAD. let switched = http .post_form( "/bob/gen/settings/general", &[ ("csrf", &csrf), ("description", "Hello forge"), ("default_branch", "dev"), ], Some(&bob), ) .await; assert_eq!(switched.status, 303, "branch switch: {}", switched.body); assert_eq!(git_head(&dir, &gen_id), "refs/heads/dev"); let after = http.get("/bob/gen/settings", Some(&bob)).await; assert_eq!(selected_branch(&after.body).as_deref(), Some("dev")); // A nonexistent branch is rejected: HEAD and the DB keep dev. let missing = http .post_form( "/bob/gen/settings/general", &[ ("csrf", &csrf), ("description", "Hello forge"), ("default_branch", "nope"), ], Some(&bob), ) .await; assert_eq!(missing.status, 303); let notice_page = http .get("/bob/gen/settings", Some(&with_notice(&bob, &missing))) .await; assert!( notice_page.body.contains("That branch does not exist"), "missing branch notice" ); assert_eq!(git_head(&dir, &gen_id), "refs/heads/dev"); assert_eq!( selected_branch(&http.get("/bob/gen/settings", Some(&bob)).await.body).as_deref(), Some("dev") ); // Over-long description is rejected. let long = "x".repeat(351); let too_long = http .post_form( "/bob/gen/settings/general", &[ ("csrf", &csrf), ("description", &long), ("default_branch", "dev"), ], Some(&bob), ) .await; assert_eq!(too_long.status, 303); let long_page = http .get("/bob/gen/settings", Some(&with_notice(&bob, &too_long))) .await; assert!(long_page.body.contains("at most 350 characters")); // Visibility: changing to private needs "I understand"; then anonymous gets 404. let vis_csrf = csrf_for(&http, &bob).await; let no_ack = http .post_form( "/bob/gen/settings/visibility", &[("csrf", &vis_csrf), ("visibility", "private")], Some(&bob), ) .await; assert_eq!(no_ack.status, 303); assert_eq!( http.get("/bob/gen", None).await.status, 200, "still public without the confirmation" ); let private = http .post_form( "/bob/gen/settings/visibility", &[ ("csrf", &vis_csrf), ("visibility", "private"), ("understand", "on"), ], Some(&bob), ) .await; assert_eq!(private.status, 303); assert_eq!( http.get("/bob/gen", None).await.status, 404, "anonymous on private" ); assert_eq!( http.get("/bob/gen", Some(&bob)).await.status, 200, "owner on private" ); let public_again = http .post_form( "/bob/gen/settings/visibility", &[ ("csrf", &vis_csrf), ("visibility", "public"), ("understand", "on"), ], Some(&bob), ) .await; assert_eq!(public_again.status, 303); assert_eq!( http.get("/bob/gen", None).await.status, 200, "anonymous on public" ); // Archive, then unarchive. The banner shows only while archived. let arch_csrf = csrf_for(&http, &bob).await; let archive = http .post_form( "/bob/gen/settings/archive", &[("csrf", &arch_csrf), ("archived", "1")], Some(&bob), ) .await; assert_eq!(archive.status, 303); assert!( http.get("/bob/gen", Some(&bob)) .await .body .contains("This repository is archived"), "archived banner" ); let unarchive = http .post_form( "/bob/gen/settings/archive", &[("csrf", &arch_csrf), ("archived", "0")], Some(&bob), ) .await; assert_eq!(unarchive.status, 303); assert!( !http .get("/bob/gen", Some(&bob)) .await .body .contains("This repository is archived"), "banner after unarchive" ); // A Write collaborator cannot reach the settings: GET and every POST are 403. let carol_page = http.get("/bob/gen/settings", Some(&carol)).await; assert_eq!(carol_page.status, 403, "write collaborator settings page"); let carol_csrf = csrf_for(&http, &carol).await; let carol_general = http .post_form( "/bob/gen/settings/general", &[ ("csrf", &carol_csrf), ("description", "pwned"), ("default_branch", "main"), ], Some(&carol), ) .await; assert_eq!(carol_general.status, 403, "write collaborator general save"); let carol_archive = http .post_form( "/bob/gen/settings/archive", &[("csrf", &carol_csrf), ("archived", "1")], Some(&carol), ) .await; assert_eq!(carol_archive.status, 403, "write collaborator archive"); assert_eq!( git_head(&dir, &gen_id), "refs/heads/dev", "HEAD untouched by carol" ); // No CSRF token, or a wrong one, is rejected on every General POST. let no_csrf = http .post_form( "/bob/gen/settings/general", &[("description", "no token"), ("default_branch", "main")], Some(&bob), ) .await; assert_eq!(no_csrf.status, 403, "general without csrf"); let bad_csrf = http .post_form( "/bob/gen/settings/visibility", &[ ("csrf", "forged"), ("visibility", "private"), ("understand", "on"), ], Some(&bob), ) .await; assert_eq!(bad_csrf.status, 403, "visibility with a wrong csrf"); assert_eq!( http.get("/bob/gen", None).await.status, 200, "visibility unchanged" ); // An empty repo says so and keeps the current value. let empty_page = http.get("/bob/empty/settings", Some(&bob)).await; assert!( empty_page.body.contains("No branches yet"), "empty repo notice" ); assert!(selected_branch(&empty_page.body).is_none()); let empty_csrf = csrf_for(&http, &bob).await; let empty_save = http .post_form( "/bob/empty/settings/general", &[("csrf", &empty_csrf), ("description", "Fresh repo")], Some(&bob), ) .await; assert_eq!(empty_save.status, 303); assert!( http.get("/bob/empty", Some(&bob)) .await .body .contains("Fresh repo"), "empty repo description saved" ); drop(server); }