//! Black-box test for the repo wiki. It seeds users and a repo through the store, starts the real //! `forge` binary and drives the wiki pages, the HTTPS clone and push of `.wiki.git`, the //! read-only and stale-edit refusals, and the backup of the wiki. //! //! 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() } /// 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 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", ); } /// Reads the wiki page `name` as the viewer, returns its body. async fn page_body(http: &Http, path: &str, session: Option<&str>) -> Resp { let res = http.get(path, session).await; assert_eq!(res.status, 200, "GET {path}: {}", res.body); res } #[tokio::test] async fn wiki_end_to_end() { if !tool_on_path("git") { eprintln!("SKIP wiki_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("fwk") .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 a public repo. Carol has Write on it. Dave has only the Read that a public repo gives. admin(&config, &["create-user", "alice", "--admin"]); for name in ["bob", "carol", "dave"] { 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 repo = store .create_repo(&bob.id, "proj", "", Visibility::Public, "main") .await .expect("repo"); store .set_collaborator(&repo.id, &carol.id, Role::Write) .await .expect("carol write"); 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; // The wiki starts empty, and the tab is there. let empty = page_body(&http, "/bob/proj/wiki", Some(&bob)).await; assert!(empty.body.contains("no pages yet"), "{}", empty.body); // Bob creates Home on the web. The save redirects to the page. let csrf = input_value( &http.get("/bob/proj/wiki/-/new", Some(&bob)).await.body, "csrf", ); let saved = http .post_form( "/bob/proj/wiki/-/new", &[ ("csrf", &csrf), ("name", "Home"), ("base", ""), ("content", "# Welcome\n\nSee [[Getting Started]].\n"), ("message", "first page"), ], Some(&bob), ) .await; assert_eq!(saved.status, 303, "create Home: {}", saved.body); assert_eq!( saved.header(LOCATION).as_deref(), Some("/bob/proj/wiki/Home") ); let home = page_body(&http, "/bob/proj/wiki/Home", Some(&bob)).await; assert!(home.body.contains("Welcome"), "{}", home.body); assert!( home.body.contains("/bob/proj/wiki/Getting-Started"), "wiki link should resolve: {}", home.body ); // Dave can read the page but not edit it. page_body(&http, "/bob/proj/wiki/Home", Some(&dave)).await; let refused = http.get("/bob/proj/wiki/Home/edit", Some(&dave)).await; assert_eq!(refused.status, 403, "read-only user must not edit"); // Carol clones the wiki over HTTPS, sees Home, and pushes a change. let carol_token = create_token(&http, &carol, "wiki-e2e").await; let work = dir.join("wiki-work"); let url = format!( "http://carol:{carol_token}@127.0.0.1:{}/bob/proj.wiki.git", ports.http ); ok( &git(&dir, &["clone", "-q", &url, path_str(&work)]), "clone wiki over HTTPS", ); let text = fs::read_to_string(work.join("Home.md")).expect("Home.md in clone"); assert!(text.contains("Welcome"), "{text}"); commit_file(&work, "Home.md", "# Welcome again\n", "edit from git"); ok( &git(&work, &["push", "-q", "origin", "HEAD:main"]), "push wiki change over HTTPS", ); let after_push = page_body(&http, "/bob/proj/wiki/Home", Some(&carol)).await; assert!( after_push.body.contains("Welcome again"), "{}", after_push.body ); // The push is in the admin audit log (read over HTTP, the server holds the database open). let alice = login(&http, "alice").await; let audit = http .get("/admin/audit?action=wiki_pushed", Some(&alice)) .await; assert_eq!(audit.status, 200, "audit page"); assert!( audit.body.contains("wiki_pushed"), "the wiki push is audited: {}", audit.body ); assert!(audit.body.contains("carol"), "the pusher is named"); assert!(audit.body.contains("bob/proj"), "the repo is named"); // A save that started from the old head is refused with the conflict message, not applied. let stale_csrf = input_value( &http .get("/bob/proj/wiki/Home/edit", Some(&carol)) .await .body, "csrf", ); let stale = http .post_form( "/bob/proj/wiki/Home/edit", &[ ("csrf", &stale_csrf), ("base", "0000000000000000000000000000000000000000"), ("content", "# Lost write\n"), ("message", "stale"), ], Some(&carol), ) .await; assert_eq!(stale.status, 200, "{}", stale.body); assert!( stale.body.contains("while you were editing"), "{}", stale.body ); let still = page_body(&http, "/bob/proj/wiki/Home", Some(&bob)).await; assert!(still.body.contains("Welcome again"), "{}", still.body); // The history lists both edits. let history = page_body(&http, "/bob/proj/wiki/Home/history", Some(&bob)).await; assert!(history.body.contains("first page"), "{}", history.body); assert!(history.body.contains("edit from git"), "{}", history.body); // Dave cannot push to the wiki. let dave_token = create_token(&http, &dave, "wiki-e2e").await; let dave_url = format!( "http://dave:{dave_token}@127.0.0.1:{}/bob/proj.wiki.git", ports.http ); let dave_work = dir.join("wiki-dave"); ok( &git(&dir, &["clone", "-q", &dave_url, path_str(&dave_work)]), "dave clones the public wiki", ); commit_file(&dave_work, "Hack.md", "x\n", "not allowed"); let denied = git(&dave_work, &["push", "-q", "origin", "HEAD:main"]); assert!(!denied.status.success(), "read-only push must fail"); // Bob turns the wiki off on the settings page. Carol, who is not an admin, cannot. let settings = page_body(&http, "/bob/proj/settings/wiki", Some(&bob)).await; assert!( settings.body.contains("Enable the wiki"), "{}", settings.body ); let forbidden_toggle = http.get("/bob/proj/settings/wiki", Some(&carol)).await; assert_eq!( forbidden_toggle.status, 404, "only admins see wiki settings" ); let off = http .post_form( "/bob/proj/settings/wiki", &[("csrf", &input_value(&settings.body, "csrf"))], Some(&bob), ) .await; assert_eq!(off.status, 303, "{}", off.body); let hidden = http.get("/bob/proj/wiki", Some(&bob)).await; assert_eq!(hidden.status, 404, "disabled wiki must be hidden"); commit_file(&work, "Other.md", "x\n", "after disable"); let refused_push = git(&work, &["push", "-q", "origin", "HEAD:main"]); assert!( !refused_push.status.success(), "push to a disabled wiki must fail" ); drop(server); }