//! Black-box tests for the account mail flows: password reset over HTTP. Like the other e2e //! tests, this starts the real `forge` binary. The log transport writes each mail to //! `/mail-log/`, which is where the tests read the reset link from. mod common; use common::{launch_with, pick_ports}; use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; use forge_core::Store; use forge_core::password::hash_password; use reqwest::header::{COOKIE, HeaderMap, SET_COOKIE}; const LOG_TRANSPORT: &str = "\n[mail]\ntransport = \"log\"\n"; const OLD_PW: &str = "alice-old-password-1"; const NEW_PW: &str = "alice-new-password-2"; struct Resp { status: u16, headers: HeaderMap, 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(COOKIE, cookie); } Self::finish(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(COOKIE, cookie); } Self::finish(req).await } async fn finish(req: reqwest::RequestBuilder) -> Resp { 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, } } } /// The `name=value` pair of the Set-Cookie header whose name starts with `prefix`. fn cookie_named(resp: &Resp, prefix: &str) -> Option { resp.headers .get_all(SET_COOKIE) .iter() .filter_map(|v| v.to_str().ok()) .find(|raw| raw.starts_with(prefix)) .map(|raw| raw.split(';').next().unwrap().to_owned()) } /// The value of a hidden `` field. fn input_value(html: &str, name: &str) -> String { let marker = format!("name=\"{name}\" value=\""); let start = html.find(&marker).unwrap_or_else(|| panic!("{name} field")) + marker.len(); let end = html[start..].find('"').unwrap() + start; html[start..end].to_owned() } /// Creates alice with a verified address. Bob has an address that is not verified. Runs before /// the server starts, like the other e2e tests. async fn seed(dir: &Path) { let store = Store::open(&dir.join("forge.db")) .await .expect("open store"); let alice = store .create_user( "alice", Some("alice@example.com"), &hash_password(OLD_PW).unwrap(), false, ) .await .expect("alice"); let token = store .create_verify_token(&alice.id, "alice@example.com") .await .expect("verify token"); store .redeem_verify_token(&token) .await .expect("verify") .expect("verified"); // The worker sends nothing while mail is off, and it is off by default. The log transport // ignores the host, but enabling mail needs one. store .update_mail_settings(&forge_core::mail::MailSettingsUpdate { host: "smtp.example", port: 587, security: forge_core::mail::Security::StartTls, username: "", from_addr: "forge@example.com", from_name: "forge", enabled: true, password: None, }) .await .expect("mail settings"); store .create_user( "bob", Some("bob@example.com"), &hash_password(OLD_PW).unwrap(), false, ) .await .expect("bob"); } /// The text of every mail the log transport has written so far. fn mail_log(dir: &Path) -> String { let log: PathBuf = dir.join("mail-log"); let Ok(entries) = fs::read_dir(&log) else { return String::new(); }; entries .filter_map(|entry| entry.ok()) .filter_map(|entry| fs::read_to_string(entry.path()).ok()) .collect::>() .join("\n----\n") } /// Waits until a mail containing `marker` has been logged, and returns the whole log. async fn wait_for_mail(dir: &Path, marker: &str) -> String { for _ in 0..200 { let log = mail_log(dir); if log.contains(marker) { return log; } tokio::time::sleep(Duration::from_millis(50)).await; } panic!("no mail with {marker} was logged"); } /// The `/reset/` path from the first reset mail in the log. fn reset_path(log: &str) -> String { let start = log.find("/reset/").expect("reset link"); log[start..].split_whitespace().next().unwrap().to_owned() } /// Logs in with `username` and `password`. Returns the HTTP status of the attempt. async fn login_status(http: &Http, username: &str, password: &str) -> u16 { let page = http.get("/login", None).await; let cookie = cookie_named(&page, "__Host-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(&cookie), ) .await; res.status } /// Submits the forgot form for `email` and returns the answer's status and body. async fn forgot(http: &Http, email: &str) -> Resp { let page = http.get("/forgot", None).await; assert_eq!(page.status, 200, "GET /forgot"); let cookie = cookie_named(&page, "__Host-forge_precsrf").expect("precsrf cookie"); let precsrf = input_value(&page.body, "precsrf"); http.post_form( "/forgot", &[("email", email), ("precsrf", &precsrf)], Some(&cookie), ) .await } #[tokio::test] async fn reset_link_sets_a_new_password_once() { let root = tempfile::tempdir().expect("temp dir"); let dir = root.path().to_path_buf(); let ports = pick_ports(); seed(&dir).await; let (_server, ports) = launch_with(&dir, ports, LOG_TRANSPORT).await; let http = Http::new(ports.http); let answer = forgot(&http, "alice@example.com").await; assert_eq!(answer.status, 200, "{}", answer.body); assert!(answer.body.contains("is on its way")); let log = wait_for_mail(&dir, "/reset/").await; let path = reset_path(&log); let page = http.get(&path, None).await; assert_eq!(page.status, 200, "{}", page.body); assert!(page.body.contains("Set a new password")); let cookie = cookie_named(&page, "__Host-forge_precsrf").expect("precsrf cookie"); let precsrf = cookie.split_once('=').unwrap().1.to_owned(); let form = [ ("precsrf", precsrf.as_str()), ("password", NEW_PW), ("confirm", NEW_PW), ]; let done = http.post_form(&path, &form, Some(&cookie)).await; assert_eq!(done.status, 200, "{}", done.body); assert!(done.body.contains("Your password is changed")); assert_eq!( login_status(&http, "alice", OLD_PW).await, 200, "old password refused" ); assert_eq!( login_status(&http, "alice", NEW_PW).await, 303, "new password works" ); // Reusing the link is refused, and the password does not change again. let again = http.post_form(&path, &form, Some(&cookie)).await; assert_eq!(again.status, 200); assert!(again.body.contains("This reset link is invalid")); assert_eq!(login_status(&http, "alice", NEW_PW).await, 303); } #[tokio::test] async fn an_unverified_address_gets_no_reset_mail_and_the_same_answer() { let root = tempfile::tempdir().expect("temp dir"); let dir = root.path().to_path_buf(); let ports = pick_ports(); seed(&dir).await; let (_server, ports) = launch_with(&dir, ports, LOG_TRANSPORT).await; let http = Http::new(ports.http); let unknown = forgot(&http, "nobody@example.com").await; let unverified = forgot(&http, "bob@example.com").await; assert_eq!(unknown.status, 200); assert_eq!(unverified.status, 200); assert_eq!( unknown.body.contains("is on its way"), unverified.body.contains("is on its way") ); // Give the background lookups time to run, then confirm nothing was sent. tokio::time::sleep(Duration::from_secs(1)).await; assert!(!mail_log(&dir).contains("/reset/"), "no reset mail for bob"); }