//! Black-box tests for stars, the explore page, the starred profile section and the archived //! banner. Like the other e2e tests, this starts the real `forge` binary and drives it over //! HTTP. Cookies are set by hand because the session cookie is `__Host-` and `Secure`. mod common; use common::{BIN, Server, describe, launch, pick_ports, tool_on_path, write_config}; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::Duration; use forge_core::{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]) { 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") } /// 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)); } /// A running server plus the data it was seeded with. The temp dir lives as long as the test. struct Env { _root: tempfile::TempDir, _server: Server, http: Http, } /// Creates the users and config, then runs `seed` against the store before the server starts. /// The seed closure receives the data dir (for bare repos) and the open store. async fn start(seed: F) -> Env where F: FnOnce(PathBuf, Store) -> Fut, Fut: std::future::Future, { let root = tempfile::Builder::new() .prefix("fst") .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(&config, &["create-user", "bob"]); let store = Store::open(&dir.join("forge.db")) .await .expect("open store"); seed(dir.clone(), store).await; let (server, ports) = launch(&dir, ports).await; let http = Http::new(ports.http); Env { _root: root, _server: server, http, } } /// Alice owns `pub` (public), `priv` (private) and `pct%x` (public, for literal-search checks). async fn seed_repos(dir: PathBuf, store: Store) { let alice = store .user_by_username("alice") .await .unwrap() .expect("alice"); for (name, vis) in [ ("pub", Visibility::Public), ("priv", Visibility::Private), ("pct%x", Visibility::Public), ] { let repo = store .create_repo(&alice.id, name, "", vis, "main") .await .expect("create repo"); init_bare(&dir, &repo.id); } } /// Writes one commit with a README to the bare repo's `main`, using plumbing only (no hooks). fn commit_main(data_dir: &Path, repo_id: &str) { let id = forge_git::repo::repo_path(data_dir, repo_id).expect("repo path"); let blob = plumbing(&id, &["hash-object", "-w", "--stdin"], b"hello\n"); let tree = plumbing( &id, &["mktree"], format!("100644 blob {blob}\tREADME.md\n").as_bytes(), ); let commit = plumbing(&id, &["commit-tree", &tree, "-m", "init"], b""); let _ = plumbing(&id, &["update-ref", "refs/heads/main", &commit], b""); } /// Runs git against the bare repo at `path` with fixed identities and returns trimmed stdout. fn plumbing(path: &Path, args: &[&str], stdin: &[u8]) -> String { use std::io::Write; let mut child = Command::new("git") .arg("--git-dir") .arg(path) .args(args) .env("GIT_AUTHOR_NAME", "t") .env("GIT_AUTHOR_EMAIL", "t@example.com") .env("GIT_COMMITTER_NAME", "t") .env("GIT_COMMITTER_EMAIL", "t@example.com") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("run git"); child .stdin .take() .expect("stdin") .write_all(stdin) .expect("write stdin"); let out = child.wait_with_output().expect("git output"); assert!(out.status.success(), "git {args:?}: {}", describe(&out)); String::from_utf8_lossy(&out.stdout).trim().to_owned() } /// Text of every `.repo-name` link on a page, in document order. fn repo_names(body: &str) -> Vec { let doc = scraper::Html::parse_document(body); let selector = scraper::Selector::parse(".repo-name").expect("selector"); doc.select(&selector) .map(|el| el.text().collect::().trim().to_owned()) .collect() } /// Sends a star POST with an optional Referer header. async fn post_star( http: &Http, path: &str, csrf: &str, cookie: Option<&str>, referer: Option<&str>, ) -> Resp { let mut req = http .client .post(format!("{}{path}", http.base)) .form(&[("csrf", csrf)]); if let Some(referer) = referer { req = req.header(reqwest::header::REFERER, referer); } http.send(req, cookie).await } #[tokio::test] async fn star_round_trip_shows_the_count() { if !tool_on_path("git") { eprintln!("SKIP stars_e2e: `git` was not found on PATH"); return; } let env = start(seed_repos).await; let bob = login(&env.http, "bob").await; let page = env.http.get("/alice/pub", Some(&bob)).await; assert_eq!(page.status, 200, "repo page"); assert!( page.body.contains("Star 0"), "star button with zero: {}", page.body ); let csrf = input_value(&page.body, "csrf"); let res = post_star(&env.http, "/alice/pub/star", &csrf, Some(&bob), None).await; assert_eq!(res.status, 303, "star: {}", res.body); assert_eq!(res.header(LOCATION).as_deref(), Some("/alice/pub")); let page = env.http.get("/alice/pub", Some(&bob)).await; assert!(page.body.contains("Unstar 1"), "after star: {}", page.body); // Back to the tree page through the Referer, when it is a same-origin path. let referer = format!("{}/alice/pub/tree/main", env.http.base); let res = post_star( &env.http, "/alice/pub/unstar", &csrf, Some(&bob), Some(&referer), ) .await; assert_eq!(res.status, 303, "unstar: {}", res.body); assert_eq!( res.header(LOCATION).as_deref(), Some("/alice/pub/tree/main") ); let page = env.http.get("/alice/pub", Some(&bob)).await; assert!(page.body.contains("Star 0"), "after unstar: {}", page.body); // A Referer on another host is ignored. let foreign = "https://evil.example/alice/pub"; let res = post_star( &env.http, "/alice/pub/star", &csrf, Some(&bob), Some(foreign), ) .await; assert_eq!(res.header(LOCATION).as_deref(), Some("/alice/pub")); } #[tokio::test] async fn anonymous_star_redirects_to_login() { let env = start(seed_repos).await; let res = post_star(&env.http, "/alice/pub/star", "anything", None, None).await; assert_eq!(res.status, 303); let location = res.header(LOCATION).unwrap_or_default(); assert!( location.starts_with("/login"), "redirect to login, got {location}" ); } #[tokio::test] async fn star_without_a_valid_csrf_token_is_403() { let env = start(seed_repos).await; let bob = login(&env.http, "bob").await; let res = post_star(&env.http, "/alice/pub/star", "wrong", Some(&bob), None).await; assert_eq!(res.status, 403); let page = env.http.get("/alice/pub", Some(&bob)).await; assert!(page.body.contains("Star 0"), "nothing was starred"); } #[tokio::test] async fn star_on_a_private_repo_is_404_for_a_non_reader() { let env = start(seed_repos).await; let bob = login(&env.http, "bob").await; let res = post_star(&env.http, "/alice/priv/star", "anything", Some(&bob), None).await; assert_eq!(res.status, 404); } #[tokio::test] async fn explore_lists_public_repos_and_searches_literally() { let env = start(seed_repos).await; let page = env.http.get("/explore", None).await; assert_eq!(page.status, 200, "explore"); let names = repo_names(&page.body); assert!(names.contains(&"alice/pub".to_owned()), "{names:?}"); assert!(names.contains(&"alice/pct%x".to_owned()), "{names:?}"); assert!( !names.contains(&"alice/priv".to_owned()), "private hidden: {names:?}" ); // `%` in the search is a literal percent sign, so only `pct%x` matches. let page = env.http.get("/explore?q=%25", None).await; assert_eq!(repo_names(&page.body), vec!["alice/pct%x".to_owned()]); // Sorting by stars puts a starred repo first; the default (name) puts `pct%x` first. let bob = login(&env.http, "bob").await; let page = env.http.get("/alice/pub", Some(&bob)).await; let csrf = input_value(&page.body, "csrf"); let res = post_star(&env.http, "/alice/pub/star", &csrf, Some(&bob), None).await; assert_eq!(res.status, 303); let by_name = repo_names(&env.http.get("/explore?sort=name", None).await.body); assert_eq!( by_name, vec!["alice/pct%x".to_owned(), "alice/pub".to_owned()] ); let by_stars = repo_names(&env.http.get("/explore?sort=stars", None).await.body); assert_eq!( by_stars, vec!["alice/pub".to_owned(), "alice/pct%x".to_owned()] ); } #[tokio::test] async fn profile_starred_section_hides_repos_the_viewer_cannot_read() { let env = start(|dir, store| async move { seed_repos(dir, store.clone()).await; let alice = store .user_by_username("alice") .await .unwrap() .expect("alice"); let bob = store.user_by_username("bob").await.unwrap().expect("bob"); let repos = store.list_repos_by_owner(&alice.id).await.unwrap(); let find = |name: &str| repos.iter().find(|r| r.name == name).unwrap().id.clone(); // Bob stars a public repo and a private one he cannot read. store.star(&bob.id, &find("pub")).await.unwrap(); store.star(&bob.id, &find("priv")).await.unwrap(); }) .await; let anon = env.http.get("/bob", None).await; assert_eq!(anon.status, 200); let starred = |body: &str| { let doc = scraper::Html::parse_document(body); let selector = scraper::Selector::parse(".profile-starred .repo-name").expect("selector"); doc.select(&selector) .map(|el| el.text().collect::().trim().to_owned()) .collect::>() }; assert_eq!(starred(&anon.body), vec!["alice/pub".to_owned()]); let alice = login(&env.http, "alice").await; let owner = env.http.get("/bob", Some(&alice)).await; let mut names = starred(&owner.body); names.sort(); assert_eq!(names, vec!["alice/priv".to_owned(), "alice/pub".to_owned()]); } #[tokio::test] async fn archived_banner_shows_on_tree_and_issues_pages() { let env = start(|dir, store| async move { seed_repos(dir.clone(), store.clone()).await; let repo = store .repo_by_owner_and_name("alice", "pub") .await .unwrap() .expect("pub"); commit_main(&dir, &repo.id); store.set_repo_archived(&repo.id, true).await.unwrap(); }) .await; let banner = "This repository is archived. It is read-only."; let tree = env.http.get("/alice/pub/tree/main", None).await; assert_eq!(tree.status, 200, "tree page"); assert!(tree.body.contains(banner), "banner on tree"); let issues = env.http.get("/alice/pub/issues", None).await; assert_eq!(issues.status, 200, "issues page"); assert!(issues.body.contains(banner), "banner on issues"); }