//! End-to-end: discussions over HTTP against a running server. Covers the settings toggle that //! shows and hides the tab, creating a discussion, a reply and a nested reply (refused), marking //! an answer in Q&A, and the lock: once locked, a plain viewer's comment is refused while the //! owner can still write. mod common; use common::{BIN, launch, pick_ports, tool_on_path, write_config}; use std::path::Path; use std::process::Command; use std::time::Duration; use reqwest::header::{LOCATION, SET_COOKIE}; const ALICE_PW: &str = "alice-password-123"; const BOB_PW: &str = "bob-password-4567"; struct Resp { status: u16, location: Option, set_cookies: Vec, body: String, } 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 { let mut req = self.client.get(format!("{}{path}", self.base)); if let Some(cookie) = cookie { req = req.header(reqwest::header::COOKIE, cookie); } send(req).await } async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp { let mut req = self.client.post(format!("{}{path}", self.base)).form(form); if let Some(cookie) = cookie { req = req.header(reqwest::header::COOKIE, cookie); } send(req).await } } async fn send(req: reqwest::RequestBuilder) -> Resp { let res = req.send().await.expect("http request"); let status = res.status().as_u16(); let location = res .headers() .get(LOCATION) .and_then(|v| v.to_str().ok()) .map(str::to_owned); let set_cookies = res .headers() .get_all(SET_COOKIE) .iter() .filter_map(|v| v.to_str().ok().map(str::to_owned)) .collect(); let body = res.text().await.unwrap_or_default(); Resp { status, location, set_cookies, body, } } /// Returns `name=value` for the cookie whose name ends with `suffix`. Cleared cookies are ignored. 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() } fn run_admin(config: &Path, username: &str, extra: &[&str], password: &str) { let out = Command::new(BIN) .arg("--config") .arg(config) .args(["admin", "create-user", username]) .args(extra) .env("FORGE_PASSWORD", password) .output() .expect("run admin create-user"); assert!( out.status.success(), "create-user {username} failed:\n{}", String::from_utf8_lossy(&out.stderr) ); } async fn login(http: &Http, username: &str, password: &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", password), ("next", "/"), ("precsrf", &precsrf), ], Some(precsrf_cookie.as_str()), ) .await; assert_eq!(res.status, 303, "login as {username}: {}", res.body); cookie_named(&res, "forge_session").expect("session cookie after login") } /// Creates a public repo through `/new`. async fn create_repo(http: &Http, session: &str, name: &str) { let page = http.get("/new", Some(session)).await; assert_eq!(page.status, 200, "GET /new"); let csrf = input_value(&page.body, "csrf"); let res = http .post_form( "/new", &[ ("csrf", &csrf), ("name", name), ("description", ""), ("visibility", "public"), ("default_branch", "main"), ], Some(session), ) .await; assert_eq!(res.status, 303, "create repo {name}: {}", res.body); assert_eq!( res.location.as_deref(), Some(format!("/alice/{name}").as_str()) ); } /// The `#comment-` anchor of a redirect location, as the id. fn anchored_id(location: Option<&str>) -> String { location .and_then(|l| l.rsplit_once("#comment-")) .map(|(_, id)| id.to_owned()) .expect("redirect anchored on a comment") } #[tokio::test] async fn discussions_create_reply_answer_lock_and_toggle() { if !tool_on_path("git") { eprintln!("SKIP discussions_e2e: `git` was not found on PATH"); return; } let root = tempfile::tempdir().expect("temp dir"); let dir = root.path().to_path_buf(); let ports = pick_ports(); let config = write_config(&dir, ports); run_admin(&config, "alice", &["--admin"], ALICE_PW); run_admin(&config, "bob", &[], BOB_PW); let (_server, ports) = launch(&dir, ports).await; let http = Http::new(ports.http); let alice = login(&http, "alice", ALICE_PW).await; let bob = login(&http, "bob", BOB_PW).await; create_repo(&http, &alice, "pub").await; // Off by default: the tab is absent and the pages do not exist. let repo_page = http.get("/alice/pub", Some(alice.as_str())).await; assert_eq!(repo_page.status, 200); assert!( !repo_page.body.contains("href=\"/alice/pub/discussions\""), "the Discussions tab shows while discussions are off" ); assert_eq!( http.get("/alice/pub/discussions", Some(alice.as_str())) .await .status, 404, "list while off" ); // Turn them on from settings, as the owner. let settings = http.get("/alice/pub/settings", Some(alice.as_str())).await; assert_eq!(settings.status, 200, "GET settings"); let csrf = input_value(&settings.body, "csrf"); let res = http .post_form( "/alice/pub/settings/discussions", &[("csrf", &csrf), ("enabled", "1")], Some(alice.as_str()), ) .await; assert_eq!(res.status, 303, "enable: {}", res.body); let repo_page = http.get("/alice/pub", Some(alice.as_str())).await; assert!( repo_page.body.contains("href=\"/alice/pub/discussions\""), "the Discussions tab is missing after turning it on" ); // Alice opens a Q&A discussion. let form = http .get("/alice/pub/discussions/new", Some(alice.as_str())) .await; assert_eq!(form.status, 200, "GET new discussion"); let csrf = input_value(&form.body, "csrf"); let res = http .post_form( "/alice/pub/discussions/new", &[ ("csrf", &csrf), ("category", "qa"), ("title", "How do I rebase?"), ("body", "Any tips?"), ], Some(alice.as_str()), ) .await; assert_eq!(res.status, 303, "create: {}", res.body); assert_eq!(res.location.as_deref(), Some("/alice/pub/discussions/1")); let discussion = "/alice/pub/discussions/1"; // Bob answers at the top level, then replies to that comment. let page = http.get(discussion, Some(bob.as_str())).await; assert_eq!(page.status, 200, "GET discussion as bob"); let csrf_bob = input_value(&page.body, "csrf"); let res = http .post_form( &format!("{discussion}/comments"), &[("csrf", &csrf_bob), ("body", "Try git rebase -i.")], Some(bob.as_str()), ) .await; assert_eq!(res.status, 303, "top comment: {}", res.body); let top = anchored_id(res.location.as_deref()); let res = http .post_form( &format!("{discussion}/comments"), &[ ("csrf", &csrf_bob), ("parent", &top), ("body", "Thanks, that worked."), ], Some(bob.as_str()), ) .await; assert_eq!(res.status, 303, "reply: {}", res.body); let reply = anchored_id(res.location.as_deref()); // A reply to a reply is refused, and the page says why. let res = http .post_form( &format!("{discussion}/comments"), &[ ("csrf", &csrf_bob), ("parent", &reply), ("body", "Too deep"), ], Some(bob.as_str()), ) .await; assert_eq!(res.status, 200, "nested reply shows the refusal page"); assert!( res.body.contains("Could not save") && res.body.contains("one level"), "nested reply was not refused: {}", res.body ); // Alice (the author) marks the top comment as the answer. let page = http.get(discussion, Some(alice.as_str())).await; let csrf_alice = input_value(&page.body, "csrf"); let res = http .post_form( &format!("{discussion}/answer"), &[("csrf", &csrf_alice), ("comment_id", &top), ("answer", "1")], Some(alice.as_str()), ) .await; assert_eq!(res.status, 303, "answer: {}", res.body); let page = http.get(discussion, Some(bob.as_str())).await; assert!( page.body.contains("Answered"), "the answered badge is missing after marking an answer" ); // Alice locks the conversation. Bob can no longer comment, and cannot lock it either. let page = http.get(discussion, Some(alice.as_str())).await; let csrf_alice = input_value(&page.body, "csrf"); let res = http .post_form( &format!("{discussion}/lock"), &[("csrf", &csrf_alice), ("locked", "1")], Some(alice.as_str()), ) .await; assert_eq!(res.status, 303, "lock: {}", res.body); let res = http .post_form( &format!("{discussion}/comments"), &[("csrf", &csrf_bob), ("body", "Still here")], Some(bob.as_str()), ) .await; assert_eq!(res.status, 403, "bob comments on a locked discussion"); let res = http .post_form( &format!("{discussion}/lock"), &[("csrf", &csrf_bob), ("locked", "0")], Some(bob.as_str()), ) .await; assert_eq!(res.status, 403, "bob locks a discussion"); // Turning discussions off hides the tab and the pages. let settings = http.get("/alice/pub/settings", Some(alice.as_str())).await; let csrf = input_value(&settings.body, "csrf"); let res = http .post_form( "/alice/pub/settings/discussions", &[("csrf", &csrf), ("enabled", "0")], Some(alice.as_str()), ) .await; assert_eq!(res.status, 303, "disable: {}", res.body); let repo_page = http.get("/alice/pub", Some(alice.as_str())).await; assert!( !repo_page.body.contains("href=\"/alice/pub/discussions\""), "the tab stays after turning discussions off" ); assert_eq!( http.get(discussion, Some(alice.as_str())).await.status, 404, "discussion page while off" ); }