//! Black-box tests for account deletion: the admin delete page, the self-service section on //! /settings/security, and what stays visible afterwards. 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, write_config}; use std::fs; use std::future::Future; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; use forge_core::{Store, Visibility}; use reqwest::header::{COOKIE, HeaderMap, HeaderName, LOCATION, SET_COOKIE}; /// Each test starts a server on ports picked just before, so the tests run one at a time. static SERIAL: std::sync::LazyLock> = std::sync::LazyLock::new(|| tokio::sync::Mutex::new(())); 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) ); } /// Submits the login form. Returns the response, so callers decide what it means. async fn try_login(http: &Http, username: &str) -> Resp { 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"); http.post_form( "/login", &[ ("username", username), ("password", PW), ("next", "/"), ("precsrf", &precsrf), ], Some(&precsrf_cookie), ) .await } /// Signs in and returns the session cookie. async fn login(http: &Http, username: &str) -> String { let res = try_login(http, username).await; assert_eq!(res.status, 303, "login as {username}: {}", res.body); cookie_named(&res, "forge_session").expect("session cookie after login") } /// Creates the users (`dana` is the first user, so an administrator), then runs `seed` against /// the store before the server starts. `seed` gets the data dir and the open store. struct Env { _root: tempfile::TempDir, dir: PathBuf, _server: Server, http: Http, } async fn start(seed: F) -> Env where F: FnOnce(PathBuf, Store) -> Fut, Fut: Future, { let root = tempfile::tempdir().expect("temp dir"); let dir = root.path().to_path_buf(); let ports = pick_ports(); let config = write_config(&dir, ports); for user in ["dana", "alice", "bob"] { admin(&config, &["create-user", user]); } 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, dir, _server: server, http, } } /// Bob owns public `proj`. Alice wrote issue #1 and a comment on it. async fn seed_issue_by_alice(dir: PathBuf, store: Store) { let alice = store .user_by_username("alice") .await .unwrap() .expect("alice"); let bob = store.user_by_username("bob").await.unwrap().expect("bob"); store .create_repo(&bob.id, "proj", "", Visibility::Public, "main") .await .expect("create repo"); let repo = store .repo_by_owner_and_name("bob", "proj") .await .unwrap() .expect("repo"); let issue = store .create_issue(&repo.id, &alice.id, "alice bug report", "body text") .await .expect("create issue"); store .add_comment(&issue.id, &alice.id, "alice follow-up comment") .await .expect("add comment"); fs::write(dir.join("alice.id"), &alice.id).expect("write alice id"); } #[tokio::test] async fn admin_delete_moves_content_to_ghost_and_pages_still_render() { let _serial = SERIAL.lock().await; let env = start(seed_issue_by_alice).await; let http = &env.http; let dana = login(http, "dana").await; let alice_id = fs::read_to_string(env.dir.join("alice.id")).expect("alice id"); let path = format!("/admin/users/{alice_id}/delete"); let page = http.get(&path, Some(&dana)).await; assert_eq!(page.status, 200, "{}", page.body); assert!(page.body.contains("Delete user"), "{}", page.body); let csrf = input_value(&page.body, "csrf"); let wrong = http .post_form( &path, &[("csrf", &csrf), ("confirm_username", "Alicia")], Some(&dana), ) .await; assert_eq!(wrong.status, 200); assert!(wrong.body.contains("exactly to confirm"), "{}", wrong.body); let done = http .post_form( &path, &[("csrf", &csrf), ("confirm_username", "alice")], Some(&dana), ) .await; assert_eq!(done.status, 303, "{}", done.body); assert_eq!(done.header(LOCATION).as_deref(), Some("/admin/users")); let issue = http.get("/bob/proj/issues/1", None).await; assert_eq!(issue.status, 200, "{}", issue.body); assert!( issue.body.contains("alice follow-up comment"), "{}", issue.body ); assert!(issue.body.contains("Deleted user"), "{}", issue.body); let alice_login = try_login(http, "alice").await; assert_ne!(alice_login.status, 303, "deleted account must not sign in"); assert!(cookie_named(&alice_login, "forge_session").is_none()); } #[tokio::test] async fn self_delete_refuses_wrong_password_and_owned_repos() { let _serial = SERIAL.lock().await; let env = start(seed_issue_by_alice).await; let http = &env.http; // Alice owns nothing: a wrong password is refused and the account stays. let alice = login(http, "alice").await; let security = http.get("/settings/security", Some(&alice)).await; assert_eq!(security.status, 200); let csrf = input_value(&security.body, "csrf"); let wrong = http .post_form( "/settings/security/delete", &[ ("csrf", &csrf), ("current_password", "not-the-password"), ("confirm_username", "alice"), ], Some(&alice), ) .await; assert_eq!(wrong.status, 200, "{}", wrong.body); assert!( wrong.body.contains("Current password is incorrect."), "{}", wrong.body ); assert!( try_login(http, "alice").await.status == 303, "account must still exist" ); // Bob owns `proj`: deletion is refused and the repo is named. let bob = login(http, "bob").await; let security = http.get("/settings/security", Some(&bob)).await; let csrf = input_value(&security.body, "csrf"); let refused = http .post_form( "/settings/security/delete", &[ ("csrf", &csrf), ("current_password", PW), ("confirm_username", "bob"), ], Some(&bob), ) .await; assert_eq!(refused.status, 200, "{}", refused.body); assert!( refused .body .contains("transfer or delete their repositories first") || refused.body.contains("bob/proj"), "{}", refused.body ); assert!( try_login(http, "bob").await.status == 303, "bob must still exist" ); } #[tokio::test] async fn self_delete_with_correct_password_signs_out_and_removes_login() { let _serial = SERIAL.lock().await; let env = start(seed_issue_by_alice).await; let http = &env.http; let alice = login(http, "alice").await; let security = http.get("/settings/security", Some(&alice)).await; let csrf = input_value(&security.body, "csrf"); let done = http .post_form( "/settings/security/delete", &[ ("csrf", &csrf), ("current_password", PW), ("confirm_username", "alice"), ], Some(&alice), ) .await; assert_eq!(done.status, 303, "{}", done.body); assert_eq!(done.header(LOCATION).as_deref(), Some("/")); let after = http.get("/settings/security", Some(&alice)).await; assert_ne!(after.status, 200, "the old session must be gone"); assert_ne!(try_login(http, "alice").await.status, 303); }