//! Black-box test for repo transfer acceptance. It seeds users, repos and an org through the store, //! starts the real `forge` binary, and drives the offer, decline, cancel and org-target flows over //! HTTP. The target of a private repo can act only from the inbox, which this test relies on. //! //! 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::process::Command; use std::time::Duration; use forge_core::store::orgs::OrgMemberRole; use forge_core::{Store, Visibility}; use reqwest::header::{COOKIE, HeaderMap, LOCATION, SET_COOKIE}; const PW: &str = "alice-password-123"; struct Resp { status: u16, headers: HeaderMap, body: String, } impl Resp { fn header(&self, name: reqwest::header::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() } /// The `action` of the first form on a page whose action ends with `suffix`. Panics when none. fn form_action(body: &str, suffix: &str) -> String { let doc = scraper::Html::parse_document(body); let selector = scraper::Selector::parse("form").expect("selector"); doc.select(&selector) .filter_map(|el| el.value().attr("action")) .find(|action| action.ends_with(suffix)) .unwrap_or_else(|| panic!("no form ending in {suffix} on page")) .to_owned() } /// Runs an admin subcommand with the password in FORGE_PASSWORD and asserts it succeeded. fn admin(config: &std::path::Path, args: &[&str]) { 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) ); } 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") } /// Posts the transfer form on the settings page as `session`. Returns the response. async fn offer(http: &Http, session: &str, owner: &str, repo: &str, to: &str) -> Resp { let csrf = csrf_for(http, session).await; http.post_form( &format!("/{owner}/{repo}/settings/transfer"), &[ ("csrf", &csrf), ("new_owner", to), ("confirm", &format!("{owner}/{repo}")), ], Some(session), ) .await } /// POSTs one of the transfer actions named by `suffix` from `page`, as `session`. async fn act_from(http: &Http, session: &str, page: &str, suffix: &str) -> Resp { let body = http.get(page, Some(session)).await.body; let action = form_action(&body, suffix); let csrf = csrf_for(http, session).await; http.post_form(&action, &[("csrf", &csrf)], Some(session)) .await } #[tokio::test] async fn repo_transfer_offers_declines_cancels_and_org_targets() { if !tool_on_path("git") { eprintln!("SKIP repo_transfer_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("frt") .tempdir_in("/tmp") .expect("temp dir"); let dir = root.path().to_path_buf(); let ports = pick_ports(); let config = write_config(&dir, ports); // Bob owns `priv` (private) and `pub`. Carol is an outsider who cannot read `priv`. Acme is an // org owned by bob, with dave as a second owner and eve as a plain member. admin(&config, &["create-user", "alice", "--admin"]); for name in ["bob", "carol", "dave", "eve"] { admin(&config, &["create-user", name]); } { let store = Store::open(&dir.join("forge.db")) .await .expect("open store"); let bob = store.user_by_username("bob").await.unwrap().expect("bob"); let dave = store.user_by_username("dave").await.unwrap().expect("dave"); let eve = store.user_by_username("eve").await.unwrap().expect("eve"); let priv_repo = store .create_repo(&bob.id, "priv", "", Visibility::Private, "main") .await .expect("priv repo"); let pub_repo = store .create_repo(&bob.id, "pub", "", Visibility::Public, "main") .await .expect("pub repo"); let acme = store.create_org("acme", &bob.id).await.expect("acme"); store .add_org_member(&acme.id, &dave.id, OrgMemberRole::Owner) .await .expect("dave owner"); store .add_org_member(&acme.id, &eve.id, OrgMemberRole::Member) .await .expect("eve member"); for repo in [&priv_repo, &pub_repo] { let path = forge_git::repo::repo_path(&dir, &repo.id).expect("repo path"); fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); let out = Command::new("git") .args(["init", "--bare", "--quiet", "--initial-branch=main"]) .arg(&path) .output() .expect("run git init"); assert!(out.status.success(), "git init: {}", describe(&out)); } } 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 dave = login(&http, "dave").await; let eve = login(&http, "eve").await; // Offer, then decline as carol from the inbox. The repo stays with bob. let offered = offer(&http, &bob, "bob", "priv", "carol").await; assert_eq!(offered.status, 303, "offer: {}", offered.body); assert_eq!( offered.header(LOCATION).as_deref(), Some("/bob/priv/transfer") ); let status = http.get("/bob/priv/transfer", Some(&bob)).await; assert!(status.body.contains("Pending transfer to carol")); // Carol cannot read the private repo, yet the inbox offers her the actions. assert_eq!(http.get("/bob/priv", Some(&carol)).await.status, 404); let declined = act_from(&http, &carol, "/notifications", "/decline").await; assert_eq!(declined.status, 303, "decline: {}", declined.body); assert_eq!(declined.header(LOCATION).as_deref(), Some("/notifications")); let after = http.get("/bob/priv/transfer", Some(&bob)).await; assert!(after.body.contains("No transfer is pending")); assert_eq!(http.get("/bob/priv", Some(&bob)).await.status, 200); // Offer again, then the initiator cancels it. assert_eq!(offer(&http, &bob, "bob", "priv", "carol").await.status, 303); let cancelled = act_from(&http, &bob, "/bob/priv/transfer", "/cancel").await; assert_eq!(cancelled.status, 303, "cancel: {}", cancelled.body); assert_eq!( cancelled.header(LOCATION).as_deref(), Some("/bob/priv/transfer") ); let after = http.get("/bob/priv/transfer", Some(&bob)).await; assert!(after.body.contains("No transfer is pending")); // A transfer to an org: plain members get no accept form, owners do. let to_org = offer(&http, &bob, "bob", "pub", "acme").await; assert_eq!(to_org.status, 303, "offer to org: {}", to_org.body); let eve_inbox = http.get("/notifications", Some(&eve)).await; assert!( !eve_inbox.body.contains("/accept"), "member sees no org offer" ); let outsider = offer(&http, &carol, "bob", "pub", "carol").await; assert_eq!(outsider.status, 403, "non-admin outsider cannot offer"); let accepted = act_from(&http, &dave, "/notifications", "/accept").await; assert_eq!(accepted.status, 303, "accept: {}", accepted.body); assert_eq!(accepted.header(LOCATION).as_deref(), Some("/acme/pub")); assert_eq!(http.get("/acme/pub", Some(&bob)).await.status, 200); let old = http.get("/bob/pub", Some(&bob)).await; assert_eq!(old.status, 301, "old owner and name redirect"); }