//! Email verification and password reset (hub spec §5.1.1), with mail //! written to a directory (`file://`) so the test reads what was sent. use axum::body::Body; use axum::http::{header, Request, StatusCode}; use http_body_util::BodyExt; use oxygenhub::config::Config; use oxygenhub::{db, Shared}; use std::path::{Path, PathBuf}; use tower::ServiceExt; fn hermetic() { static ONCE: std::sync::Once = std::sync::Once::new(); static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); ONCE.call_once(|| { let d = tempfile::tempdir().unwrap(); std::env::set_var("OXY_HOME", d.path()); DIR.set(d).ok(); }); } struct Hub { app: Shared, mailbox: PathBuf, _dir: tempfile::TempDir, } async fn hub(mail: bool) -> Hub { hermetic(); let dir = tempfile::tempdir().unwrap(); let mailbox = dir.path().join("mailbox"); std::fs::create_dir_all(&mailbox).unwrap(); let mut config = Config::load(dir.path()).unwrap(); config.server.base_url = "http://hub.test".into(); config.registration.mode = "open".into(); if mail { config.email.smtp = format!("file://{}", mailbox.display()); config.email.from = "OxygenHub ".into(); } let app = oxygenhub::build(config).await.unwrap(); Hub { app, mailbox, _dir: dir } } struct Res { status: StatusCode, body: String, cookie: Option, location: String, } async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res { let mut req = Request::builder().method(method).uri(path); if let Some(c) = cookie { req = req.header(header::COOKIE, format!("oxyhub={c}")); } let body = match form { Some(f) => { req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); Body::from(f.to_string()) } None => Body::empty(), }; let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); let status = res.status(); let h = |k| res.headers().get(k).and_then(|v: &axum::http::HeaderValue| v.to_str().ok()).map(String::from); let cookie = h(header::SET_COOKIE).and_then(|v| v.split(';').next().and_then(|kv| kv.split_once('=')).map(|(_, v)| v.to_string())); let location = h(header::LOCATION).unwrap_or_default(); let bytes = res.into_body().collect().await.unwrap().to_bytes(); Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location } } async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { send(app, "GET", path, cookie, None).await } async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { send(app, "POST", path, cookie, Some(form)).await } fn csrf_of(page: &str) -> String { let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); page[i..].split('"').next().unwrap().to_string() } /// Every message so far, oldest first, as text. fn mail(dir: &Path) -> Vec { let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().flatten().map(|e| e.path()).collect(); names.sort(); names.iter().map(|p| std::fs::read_to_string(p).unwrap()).collect() } /// Mail is sent in the background for resets; wait for `n` messages. async fn mail_count(dir: &Path, n: usize) -> Vec { for _ in 0..100 { let m = mail(dir); if m.len() >= n { return m; } tokio::time::sleep(std::time::Duration::from_millis(20)).await; } mail(dir) } /// The path of the first `http://hub.test/…` link in a message. fn link(message: &str, prefix: &str) -> String { let i = message.find(&format!("http://hub.test{prefix}")).expect("a link") + "http://hub.test".len(); message[i..].split_whitespace().next().unwrap().to_string() } async fn account(app: &Shared, name: &str) -> db::accounts::Account { db::accounts::account_by_name(&app.db, name).await.unwrap().unwrap() } #[tokio::test(flavor = "multi_thread")] async fn verification_and_reset() { let h = hub(true).await; let app = &h.app; let r = post(app, "/register", None, "username=andy&email=andy%40example.com&password=correct-horse-battery").await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); let old_session = r.cookie.unwrap(); // Registration sends a verification link; until it is used, no reset mail. let m = mail(&h.mailbox); assert_eq!(m.len(), 1); assert!(m[0].contains("To: andy ") && m[0].contains("Subject: [OxygenHub] Confirm your email address"), "{}", m[0]); let verify = link(&m[0], "/verify/"); assert!(!account(app, "andy").await.email_verified); let settings = get(app, "/settings", Some(&old_session)).await; assert!(settings.body.contains("Not verified yet") && settings.body.contains("send a new one")); let sent = post(app, "/login/reset", None, "who=andy").await; assert!(sent.body.contains("Check your email")); tokio::time::sleep(std::time::Duration::from_millis(200)).await; assert_eq!(mail(&h.mailbox).len(), 1, "no reset to an unverified address"); // The link verifies once. assert!(get(app, &verify, None).await.body.contains("Email verified")); assert!(account(app, "andy").await.email_verified); assert!(get(app, &verify, None).await.body.contains("already used"), "single use"); assert!(get(app, "/verify/00ff", None).await.body.contains("already used")); // Reset by verified email (any case); a second request within two minutes sends nothing. let unknown = post(app, "/login/reset", None, "who=nobody").await; let known = post(app, "/login/reset", None, "who=ANDY%40example.com").await; assert_eq!(unknown.body, known.body, "the answer does not reveal accounts"); let m = mail_count(&h.mailbox, 2).await; assert_eq!(m.len(), 2); assert!(m[1].contains("Subject: [OxygenHub] Reset your password")); let reset = link(&m[1], "/login/reset/"); post(app, "/login/reset", None, "who=andy").await; tokio::time::sleep(std::time::Duration::from_millis(200)).await; assert_eq!(mail(&h.mailbox).len(), 2, "rate limited"); // Choosing the new password: checks, then every session ends. let form = get(app, &reset, None).await; assert!(form.body.contains("Choose a new password") && form.body.contains("andy")); let differ = post(app, &reset, None, "password=new-password-long-enough&confirm=other-password-long").await; assert!(differ.body.contains("The two passwords differ")); let weak = post(app, &reset, None, "password=short&confirm=short").await; assert!(weak.body.contains("class=\"error\""), "password rules apply"); let done = post(app, &reset, None, "password=new-password-long-enough&confirm=new-password-long-enough").await; assert_eq!((done.status, done.location.as_str()), (StatusCode::SEE_OTHER, "/login?reset=1")); assert!(get(app, "/login?reset=1", None).await.body.contains("Your password is set")); assert!(get(app, &reset, None).await.body.contains("expired or was already used"), "single use"); let stale = get(app, "/settings", Some(&old_session)).await; assert!(stale.status.is_redirection() || !stale.body.contains("name=\"_csrf\""), "the old session ended"); let wrong = post(app, "/login", None, "username=andy&password=correct-horse-battery").await; assert!(wrong.body.contains("Wrong username or password")); let signed = post(app, "/login", None, "username=andy&password=new-password-long-enough").await; assert_eq!(signed.status, StatusCode::SEE_OTHER); let session = signed.cookie.unwrap(); let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap(); assert!(audit.iter().any(|a| a.action == "password.reset" && a.detail == "emailed link")); // Changing the email unverifies it; only the link for the current address counts. let page = get(app, "/settings", Some(&session)).await; let csrf = csrf_of(&page.body); let save = |email: &'static str| { let csrf = csrf.clone(); let session = session.clone(); async move { post(app, "/settings", Some(&session), &format!("_csrf={csrf}&display_name=Andy&email={email}&bio=&website=&location=")).await } }; let saved = save("b%40example.com").await; assert!(saved.body.contains("A verification link is on its way to b@example.com"), "{}", saved.body); assert!(!account(app, "andy").await.email_verified); let to_b = link(mail(&h.mailbox).last().unwrap(), "/verify/"); save("c%40example.com").await; let to_c = link(mail(&h.mailbox).last().unwrap(), "/verify/"); assert!(get(app, &to_b, None).await.body.contains("no longer has"), "an old address's link does nothing"); assert!(!account(app, "andy").await.email_verified); assert!(get(app, &to_c, None).await.body.contains("Email verified")); assert_eq!(account(app, "andy").await.email, "c@example.com"); // A second link to the same address within two minutes is refused. save("d%40example.com").await; let again = post(app, "/settings/email/verify", Some(&session), &format!("_csrf={csrf}")).await; assert!(again.body.contains("less than two minutes ago"), "{}", again.body); // An administrator's link, for an account without a verified email. post(app, "/register", None, "username=bob&email=&password=bob-password-long-enough").await; let admin = get(app, "/admin", Some(&session)).await; assert!(admin.body.contains("value=\"reset_link\"")); let r = post(app, "/admin/account", Some(&session), &format!("_csrf={}&username=bob&action=reset_link", csrf_of(&admin.body))).await; assert!(r.body.contains("Reset link for bob"), "{}", r.body); let i = r.body.find("http://hub.test/login/reset/").unwrap() + "http://hub.test".len(); let bob_link: String = r.body[i..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '/').collect(); assert_eq!(post(app, &bob_link, None, "password=bob-new-password-ok&confirm=bob-new-password-ok").await.location, "/login?reset=1"); let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap(); assert!(audit.iter().any(|a| a.action == "admin.account.reset_link" && a.target == "bob")); assert!(audit.iter().any(|a| a.action == "password.reset" && a.target == "bob" && a.detail == "administrator's link")); } #[tokio::test(flavor = "multi_thread")] async fn without_mail_an_administrator_resets() { let h = hub(false).await; let page = get(&h.app, "/login/reset", None).await; assert!(page.body.contains("does not send email") && !page.body.contains("SEND A RESET LINK")); let sent = post(&h.app, "/login/reset", None, "who=andy").await; assert!(!sent.body.contains("Check your email")); assert!(get(&h.app, "/login", None).await.body.contains("href=\"/login/reset\"")); }