//! Black-box test for rename and transfer. It seeds users and repos through the store, starts the //! real `forge` binary, and drives the rename/transfer forms, the redirects, and the git clone //! URLs of the old names 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; 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() } /// The `action` of the first form on a page whose action ends with `suffix`. 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: &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") } /// Creates an access token through `/settings/tokens` and returns the token shown once. async fn create_token(http: &Http, session: &str, name: &str) -> String { let csrf = csrf_for(http, session).await; let res = http .post_form( "/settings/tokens", &[("csrf", &csrf), ("name", name), ("expires", "30")], Some(session), ) .await; assert_eq!(res.status, 200, "create token: {}", res.body); let doc = scraper::Html::parse_document(&res.body); let selector = scraper::Selector::parse("pre.code code").expect("selector"); let token: String = doc .select(&selector) .next() .expect("token shown after creation") .text() .collect(); let token = token.trim().to_owned(); assert!(token.starts_with("forge_"), "unexpected token shape"); token } /// Creates a public repo for the signed-in user through `/new`. Returns the response. async fn create_repo_web(http: &Http, session: &str, name: &str) -> Resp { let page = http.get("/new", Some(session)).await; assert_eq!(page.status, 200, "GET /new"); let csrf = input_value(&page.body, "csrf"); http.post_form( "/new", &[ ("csrf", &csrf), ("name", name), ("description", ""), ("visibility", "public"), ("default_branch", "main"), ], Some(session), ) .await } /// 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", "--initial-branch=main"]) .arg(&path) .output() .expect("run git init"); assert!(out.status.success(), "git init --bare: {}", describe(&out)); } fn git(cwd: &Path, args: &[&str]) -> Output { Command::new("git") .current_dir(cwd) .args(["-c", "credential.helper="]) .args(args) .env("GIT_TERMINAL_PROMPT", "0") .output() .expect("run git") } fn ok(out: &Output, what: &str) { assert!(out.status.success(), "{what} failed:\n{}", describe(out)); } fn path_str(path: &Path) -> &str { path.to_str().expect("utf-8 path") } /// Writes a file under `repo` (creating its directory) and commits it as `bob`. fn commit_file(repo: &Path, name: &str, content: &str, message: &str) { let file = repo.join(name); fs::create_dir_all(file.parent().expect("parent")).expect("create dir"); fs::write(&file, content).expect("write file"); ok(&git(repo, &["config", "user.name", "bob"]), "set user.name"); ok( &git(repo, &["config", "user.email", "bob@example.com"]), "set user.email", ); ok(&git(repo, &["add", name]), "git add"); ok( &git( repo, &["-c", "commit.gpgsign=false", "commit", "-q", "-m", message], ), "git commit", ); } #[tokio::test] async fn repo_rename_end_to_end() { if !tool_on_path("git") { eprintln!("SKIP repo_rename_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("frn") .tempdir_in("/tmp") .expect("temp dir"); let dir = root.path().to_path_buf(); let ports = pick_ports(); let config = write_config(&dir, ports); // Alice is the instance admin. Bob owns the repos. Carol has Write on `pub` and Dave has Admin // on it. Eve is an unrelated signed-in user. 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 carol = store .user_by_username("carol") .await .unwrap() .expect("carol"); let dave = store.user_by_username("dave").await.unwrap().expect("dave"); let pub_repo = store .create_repo(&bob.id, "pub", "", Visibility::Public, "main") .await .expect("pub repo"); let priv_repo = store .create_repo(&bob.id, "priv", "", Visibility::Private, "main") .await .expect("priv repo"); let secret = store .create_repo(&bob.id, "secret", "", Visibility::Private, "main") .await .expect("secret repo"); store .set_collaborator(&pub_repo.id, &carol.id, Role::Write) .await .expect("carol write"); store .set_collaborator(&pub_repo.id, &dave.id, Role::Admin) .await .expect("dave admin"); for repo in [&pub_repo, &priv_repo, &secret] { init_bare(&dir, &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; let dave = login(&http, "dave").await; let eve = login(&http, "eve").await; let bob_token = create_token(&http, &bob, "rename-e2e").await; // Bob pushes a commit with a `src` directory to the public repo, so the tree path can be checked. let bob_url = |name: &str| { format!( "http://bob:{bob_token}@127.0.0.1:{}/bob/{name}.git", ports.http ) }; let work = dir.join("work"); ok( &git(&dir, &["clone", &bob_url("pub"), path_str(&work)]), "clone empty pub", ); commit_file(&work, "src/main.rs", "fn main() {}\n", "initial"); ok( &git(&work, &["push", "origin", "HEAD:main"]), "push initial", ); let tree = http.get("/bob/pub/tree/main/src", None).await; assert_eq!(tree.status, 200, "tree before rename"); // Bob renames `pub` to `newpub`. The form sends him to the new settings URL. let csrf = csrf_for(&http, &bob).await; let renamed = http .post_form( "/bob/pub/settings/rename", &[("csrf", &csrf), ("name", "newpub")], Some(&bob), ) .await; assert_eq!(renamed.status, 303, "rename: {}", renamed.body); assert_eq!( renamed.header(LOCATION).as_deref(), Some("/bob/newpub/settings/rename") ); assert_eq!( http.get("/bob/newpub/tree/main/src", None).await.status, 200 ); // The old web URL 301s to the same path under the new name, and keeps the query. let old_tree = http.get("/bob/pub/tree/main/src", None).await; assert_eq!(old_tree.status, 301, "old tree URL"); assert_eq!( old_tree.header(LOCATION).as_deref(), Some("/bob/newpub/tree/main/src") ); let old_query = http.get("/bob/pub?tab=x", None).await; assert_eq!(old_query.status, 301, "old repo URL with query"); assert_eq!( old_query.header(LOCATION).as_deref(), Some("/bob/newpub?tab=x") ); // The old clone URL still fetches and pushes over HTTPS. ok( &git(&work, &["remote", "set-url", "origin", &bob_url("pub")]), "set old remote", ); commit_file(&work, "src/lib.rs", "pub fn lib() {}\n", "after rename"); ok( &git(&work, &["push", "origin", "HEAD:main"]), "push to old clone URL", ); let fresh = dir.join("fresh"); ok( &git(&dir, &["clone", &bob_url("pub"), path_str(&fresh)]), "fetch via old clone URL", ); assert!(fresh.join("src/lib.rs").exists(), "push via old URL landed"); let new_clone = dir.join("new-clone"); ok( &git( &dir, &[ "clone", &format!( "http://bob:{bob_token}@127.0.0.1:{}/bob/newpub.git", ports.http ), path_str(&new_clone), ], ), "clone via new URL", ); assert!(new_clone.join("src/lib.rs").exists()); // A name taken by another repo, and an invalid name, are rejected and change nothing. let csrf = csrf_for(&http, &bob).await; let taken = http .post_form( "/bob/newpub/settings/rename", &[("csrf", &csrf), ("name", "priv")], Some(&bob), ) .await; assert_eq!(taken.status, 200, "conflicting rename re-renders"); assert!(taken.body.contains("already exists"), "conflict message"); let invalid = http .post_form( "/bob/newpub/settings/rename", &[("csrf", &csrf), ("name", "Bad Name")], Some(&bob), ) .await; assert_eq!(invalid.status, 200, "invalid rename re-renders"); assert!(invalid.body.contains("only A-Z"), "invalid name message"); assert_eq!( http.get("/bob/newpub", None).await.status, 200, "name unchanged after rejected renames" ); // A Write collaborator cannot rename (403) and the name stays. let carol_csrf = csrf_for(&http, &carol).await; let carol_rename = http .post_form( "/bob/newpub/settings/rename", &[("csrf", &carol_csrf), ("name", "hacked")], Some(&carol), ) .await; assert_eq!(carol_rename.status, 403, "write collaborator rename"); assert_eq!(http.get("/bob/newpub", None).await.status, 200); // An Admin collaborator cannot transfer (403). The repo stays with bob. let dave_csrf = csrf_for(&http, &dave).await; let dave_transfer = http .post_form( "/bob/newpub/settings/transfer", &[ ("csrf", &dave_csrf), ("new_owner", "dave"), ("confirm", "bob/newpub"), ], Some(&dave), ) .await; assert_eq!(dave_transfer.status, 403, "admin collaborator transfer"); assert_eq!(http.get("/bob/newpub", None).await.status, 200); // Bob transfers the repo to carol. The URL moves to carol, the old one redirects. let csrf = csrf_for(&http, &bob).await; let transfer_wrong = http .post_form( "/bob/newpub/settings/transfer", &[ ("csrf", &csrf), ("new_owner", "carol"), ("confirm", "newpub"), ], Some(&bob), ) .await; assert_eq!(transfer_wrong.status, 200, "wrong confirmation re-renders"); assert!(transfer_wrong.body.contains("exactly to confirm")); let transfer = http .post_form( "/bob/newpub/settings/transfer", &[ ("csrf", &csrf), ("new_owner", "carol"), ("confirm", "bob/newpub"), ], Some(&bob), ) .await; // The owner only offers the repo: it stays with bob until carol accepts in her inbox. assert_eq!(transfer.status, 303, "transfer offered: {}", transfer.body); assert_eq!( transfer.header(LOCATION).as_deref(), Some("/bob/newpub/transfer") ); assert_eq!( http.get("/bob/newpub/tree/main/src", None).await.status, 200, "repo unchanged until accepted" ); let offered = http.get("/bob/newpub/transfer", Some(&bob)).await; assert!(offered.body.contains("Pending transfer to carol")); let inbox = http.get("/notifications", Some(&carol)).await; let accept_action = form_action(&inbox.body, "/accept"); let carol_accept_csrf = csrf_for(&http, &carol).await; let accepted = http .post_form( &accept_action, &[("csrf", &carol_accept_csrf)], Some(&carol), ) .await; assert_eq!(accepted.status, 303, "accept: {}", accepted.body); assert_eq!(accepted.header(LOCATION).as_deref(), Some("/carol/newpub")); assert_eq!( http.get("/carol/newpub/tree/main/src", None).await.status, 200 ); let moved = http.get("/bob/newpub/tree/main/src", None).await; assert_eq!(moved.status, 301, "old owner and name"); assert_eq!( moved.header(LOCATION).as_deref(), Some("/carol/newpub/tree/main/src") ); // A chain: bob/pub -> bob/newpub -> carol/newpub. The oldest name still redirects, and its // clone URL still works for an anonymous fetch of this public repo. let chained = http.get("/bob/pub/tree/main/src", None).await; assert_eq!(chained.status, 301, "chained redirect"); assert_eq!( chained.header(LOCATION).as_deref(), Some("/carol/newpub/tree/main/src") ); let anon_clone = dir.join("anon-clone"); ok( &git( &dir, &[ "clone", &format!("http://127.0.0.1:{}/bob/pub.git", ports.http), path_str(&anon_clone), ], ), "anonymous clone via chained old name", ); assert!(anon_clone.join("src/main.rs").exists()); // A new repo created with an old name takes over: no redirect, the new repo is served. let takeover = create_repo_web(&http, &bob, "pub").await; assert_eq!(takeover.status, 303, "create pub again: {}", takeover.body); assert_eq!(takeover.header(LOCATION).as_deref(), Some("/bob/pub")); let taken_over = http.get("/bob/pub", None).await; assert_eq!(taken_over.status, 200, "new repo takes over the old name"); assert_eq!( http.get("/bob/newpub", None).await.status, 301, "the other old name still redirects" ); // A private repo renamed away: an anonymous or unrelated viewer gets 404, the owner gets 301. let csrf = csrf_for(&http, &bob).await; let secret_rename = http .post_form( "/bob/secret/settings/rename", &[("csrf", &csrf), ("name", "secret2")], Some(&bob), ) .await; assert_eq!(secret_rename.status, 303, "rename private repo"); assert_eq!(http.get("/bob/secret", None).await.status, 404, "anonymous"); assert_eq!( http.get("/bob/secret", Some(&eve)).await.status, 404, "unrelated user" ); assert_eq!( http.get("/bob/nothere", Some(&eve)).await.status, 404, "missing repo looks the same" ); let owner_view = http.get("/bob/secret", Some(&bob)).await; assert_eq!(owner_view.status, 301, "owner follows the redirect"); assert_eq!(owner_view.header(LOCATION).as_deref(), Some("/bob/secret2")); // Git clients get the same answer for a hidden repo as for a missing one. let hidden_git = http .get("/bob/secret.git/info/refs?service=git-upload-pack", None) .await; let missing_git = http .get("/bob/nothere.git/info/refs?service=git-upload-pack", None) .await; assert_eq!(hidden_git.status, 401, "hidden repo over git"); assert_eq!( hidden_git.status, missing_git.status, "hidden matches missing" ); drop(server); }