| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
use axum::body::Body; |
| 5 |
use axum::http::{header, Request, StatusCode}; |
| 6 |
use http_body_util::BodyExt; |
| 7 |
use oxygenhub::config::Config; |
| 8 |
use oxygenhub::{db, Shared}; |
| 9 |
use std::path::{Path, PathBuf}; |
| 10 |
use tower::ServiceExt; |
| 11 |
|
| 12 |
fn hermetic() { |
| 13 |
static ONCE: std::sync::Once = std::sync::Once::new(); |
| 14 |
static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new(); |
| 15 |
ONCE.call_once(|| { |
| 16 |
let d = tempfile::tempdir().unwrap(); |
| 17 |
std::env::set_var("OXY_HOME", d.path()); |
| 18 |
DIR.set(d).ok(); |
| 19 |
}); |
| 20 |
} |
| 21 |
|
| 22 |
struct Hub { |
| 23 |
app: Shared, |
| 24 |
mailbox: PathBuf, |
| 25 |
_dir: tempfile::TempDir, |
| 26 |
} |
| 27 |
|
| 28 |
async fn hub(mail: bool) -> Hub { |
| 29 |
hermetic(); |
| 30 |
let dir = tempfile::tempdir().unwrap(); |
| 31 |
let mailbox = dir.path().join("mailbox"); |
| 32 |
std::fs::create_dir_all(&mailbox).unwrap(); |
| 33 |
let mut config = Config::load(dir.path()).unwrap(); |
| 34 |
|
| 35 |
config.registration.setup_code = "test-setup".into(); |
| 36 |
config.server.base_url = "http://hub.test".into(); |
| 37 |
config.registration.mode = "open".into(); |
| 38 |
if mail { |
| 39 |
config.email.smtp = format!("file://{}", mailbox.display()); |
| 40 |
config.email.from = "OxygenHub <[email protected]>".into(); |
| 41 |
} |
| 42 |
let app = oxygenhub::build(config).await.unwrap(); |
| 43 |
Hub { app, mailbox, _dir: dir } |
| 44 |
} |
| 45 |
|
| 46 |
struct Res { |
| 47 |
status: StatusCode, |
| 48 |
body: String, |
| 49 |
cookie: Option<String>, |
| 50 |
location: String, |
| 51 |
} |
| 52 |
|
| 53 |
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res { |
| 54 |
let mut req = Request::builder().method(method).uri(path); |
| 55 |
if let Some(c) = cookie { |
| 56 |
req = req.header(header::COOKIE, format!("oxyhub={c}")); |
| 57 |
} |
| 58 |
let body = match form { |
| 59 |
Some(f) => { |
| 60 |
req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
| 61 |
Body::from(f.to_string()) |
| 62 |
} |
| 63 |
None => Body::empty(), |
| 64 |
}; |
| 65 |
let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
| 66 |
let status = res.status(); |
| 67 |
let h = |k| res.headers().get(k).and_then(|v: &axum::http::HeaderValue| v.to_str().ok()).map(String::from); |
| 68 |
let cookie = h(header::SET_COOKIE).and_then(|v| v.split(';').next().and_then(|kv| kv.split_once('=')).map(|(_, v)| v.to_string())); |
| 69 |
let location = h(header::LOCATION).unwrap_or_default(); |
| 70 |
let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
| 71 |
Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location } |
| 72 |
} |
| 73 |
|
| 74 |
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { |
| 75 |
send(app, "GET", path, cookie, None).await |
| 76 |
} |
| 77 |
|
| 78 |
async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { |
| 79 |
send(app, "POST", path, cookie, Some(form)).await |
| 80 |
} |
| 81 |
|
| 82 |
fn csrf_of(page: &str) -> String { |
| 83 |
let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); |
| 84 |
page[i..].split('"').next().unwrap().to_string() |
| 85 |
} |
| 86 |
|
| 87 |
|
| 88 |
|
| 89 |
|
| 90 |
fn mail(dir: &Path) -> Vec<String> { |
| 91 |
let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().flatten().map(|e| e.path()).collect(); |
| 92 |
names.sort(); |
| 93 |
names.iter().map(|p| std::fs::read_to_string(p).unwrap().replace("=\r\n", "").replace("=\n", "").replace("=3D", "=")).collect() |
| 94 |
} |
| 95 |
|
| 96 |
|
| 97 |
async fn mail_count(dir: &Path, n: usize) -> Vec<String> { |
| 98 |
for _ in 0..100 { |
| 99 |
let m = mail(dir); |
| 100 |
if m.len() >= n { |
| 101 |
return m; |
| 102 |
} |
| 103 |
tokio::time::sleep(std::time::Duration::from_millis(20)).await; |
| 104 |
} |
| 105 |
mail(dir) |
| 106 |
} |
| 107 |
|
| 108 |
|
| 109 |
fn link(message: &str, prefix: &str) -> String { |
| 110 |
let i = message.find(&format!("http://hub.test{prefix}")).expect("a link") + "http://hub.test".len(); |
| 111 |
message[i..].split_whitespace().next().unwrap().to_string() |
| 112 |
} |
| 113 |
|
| 114 |
async fn account(app: &Shared, name: &str) -> db::accounts::Account { |
| 115 |
db::accounts::account_by_name(&app.db, name).await.unwrap().unwrap() |
| 116 |
} |
| 117 |
|
| 118 |
#[tokio::test(flavor = "multi_thread")] |
| 119 |
async fn verification_and_reset() { |
| 120 |
let h = hub(true).await; |
| 121 |
let app = &h.app; |
| 122 |
let r = post(app, "/register", None, "username=andy&email=andy%40example.com&password=correct-horse-battery&setup=test-setup").await; |
| 123 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 124 |
let old_session = r.cookie.unwrap(); |
| 125 |
|
| 126 |
|
| 127 |
let m = mail(&h.mailbox); |
| 128 |
assert_eq!(m.len(), 1); |
| 129 |
assert!( |
| 130 |
m[0].contains("To: andy <[email protected]>") |
| 131 |
&& m[0].contains("Subject: [OxygenHub] =?utf-8?b?4pyJ77iP?= Confirm your email address"), |
| 132 |
"{}", |
| 133 |
m[0] |
| 134 |
); |
| 135 |
let verify = link(&m[0], "/verify/"); |
| 136 |
assert!(!account(app, "andy").await.email_verified); |
| 137 |
let settings = get(app, "/settings", Some(&old_session)).await; |
| 138 |
assert!(settings.body.contains("Not verified yet") && settings.body.contains("send a new one")); |
| 139 |
let sent = post(app, "/login/reset", None, "who=andy").await; |
| 140 |
assert!(sent.body.contains("Check your email")); |
| 141 |
tokio::time::sleep(std::time::Duration::from_millis(200)).await; |
| 142 |
assert_eq!(mail(&h.mailbox).len(), 1, "no reset to an unverified address"); |
| 143 |
|
| 144 |
|
| 145 |
assert!(get(app, &verify, None).await.body.contains("Email verified")); |
| 146 |
assert!(account(app, "andy").await.email_verified); |
| 147 |
assert!(get(app, &verify, None).await.body.contains("already used"), "single use"); |
| 148 |
assert!(get(app, "/verify/00ff", None).await.body.contains("already used")); |
| 149 |
|
| 150 |
|
| 151 |
let unknown = post(app, "/login/reset", None, "who=nobody").await; |
| 152 |
let known = post(app, "/login/reset", None, "who=ANDY%40example.com").await; |
| 153 |
assert_eq!(unknown.body, known.body, "the answer does not reveal accounts"); |
| 154 |
let m = mail_count(&h.mailbox, 2).await; |
| 155 |
assert_eq!(m.len(), 2); |
| 156 |
assert!(m[1].contains("Subject: [OxygenHub] =?utf-8?b?") && m[1].contains("?= Reset your password"), "an emoji, then the subject"); |
| 157 |
let reset = link(&m[1], "/login/reset/"); |
| 158 |
post(app, "/login/reset", None, "who=andy").await; |
| 159 |
tokio::time::sleep(std::time::Duration::from_millis(200)).await; |
| 160 |
assert_eq!(mail(&h.mailbox).len(), 2, "rate limited"); |
| 161 |
|
| 162 |
|
| 163 |
let form = get(app, &reset, None).await; |
| 164 |
assert!(form.body.contains("Choose a new password") && form.body.contains("andy")); |
| 165 |
let differ = post(app, &reset, None, "password=new-password-long-enough&confirm=other-password-long").await; |
| 166 |
assert!(differ.body.contains("The two passwords differ")); |
| 167 |
let weak = post(app, &reset, None, "password=short&confirm=short").await; |
| 168 |
assert!(weak.body.contains("class=\"error\""), "password rules apply"); |
| 169 |
let done = post(app, &reset, None, "password=new-password-long-enough&confirm=new-password-long-enough").await; |
| 170 |
assert_eq!((done.status, done.location.as_str()), (StatusCode::SEE_OTHER, "/login?reset=1")); |
| 171 |
assert!(get(app, "/login?reset=1", None).await.body.contains("Your password is set")); |
| 172 |
assert!(get(app, &reset, None).await.body.contains("expired or was already used"), "single use"); |
| 173 |
let stale = get(app, "/settings", Some(&old_session)).await; |
| 174 |
assert!(stale.status.is_redirection() || !stale.body.contains("name=\"_csrf\""), "the old session ended"); |
| 175 |
let wrong = post(app, "/login", None, "username=andy&password=correct-horse-battery&setup=test-setup").await; |
| 176 |
assert!(wrong.body.contains("Wrong username or password")); |
| 177 |
let signed = post(app, "/login", None, "username=andy&password=new-password-long-enough").await; |
| 178 |
assert_eq!(signed.status, StatusCode::SEE_OTHER); |
| 179 |
let session = signed.cookie.unwrap(); |
| 180 |
let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap(); |
| 181 |
assert!(audit.iter().any(|a| a.action == "password.reset" && a.detail == "emailed link")); |
| 182 |
|
| 183 |
|
| 184 |
let page = get(app, "/settings", Some(&session)).await; |
| 185 |
let csrf = csrf_of(&page.body); |
| 186 |
let save = |email: &'static str| { |
| 187 |
let csrf = csrf.clone(); |
| 188 |
let session = session.clone(); |
| 189 |
async move { |
| 190 |
post(app, "/settings", Some(&session), &format!("_csrf={csrf}&display_name=Andy&email={email}&bio=&website=&location=")).await |
| 191 |
} |
| 192 |
}; |
| 193 |
let saved = save("b%40example.com").await; |
| 194 |
assert!(saved.body.contains("A verification link is on its way to [email protected]"), "{}", saved.body); |
| 195 |
assert!(!account(app, "andy").await.email_verified); |
| 196 |
let to_b = link(mail(&h.mailbox).last().unwrap(), "/verify/"); |
| 197 |
save("c%40example.com").await; |
| 198 |
let to_c = link(mail(&h.mailbox).last().unwrap(), "/verify/"); |
| 199 |
assert!(get(app, &to_b, None).await.body.contains("no longer has"), "an old address's link does nothing"); |
| 200 |
assert!(!account(app, "andy").await.email_verified); |
| 201 |
assert!(get(app, &to_c, None).await.body.contains("Email verified")); |
| 202 |
assert_eq!(account(app, "andy").await.email, "[email protected]"); |
| 203 |
|
| 204 |
save("d%40example.com").await; |
| 205 |
let again = post(app, "/settings/email/verify", Some(&session), &format!("_csrf={csrf}")).await; |
| 206 |
assert!(again.body.contains("less than two minutes ago"), "{}", again.body); |
| 207 |
|
| 208 |
|
| 209 |
post(app, "/register", None, "username=bob&email=&password=bob-password-long-enough&setup=test-setup").await; |
| 210 |
let admin = get(app, "/admin/accounts", Some(&session)).await; |
| 211 |
assert!(admin.body.contains("value=\"reset_link\"")); |
| 212 |
let r = post(app, "/admin/account", Some(&session), &format!("_csrf={}&username=bob&action=reset_link", csrf_of(&admin.body))).await; |
| 213 |
assert!(r.body.contains("Reset link for bob"), "{}", r.body); |
| 214 |
let i = r.body.find("http://hub.test/login/reset/").unwrap() + "http://hub.test".len(); |
| 215 |
let bob_link: String = r.body[i..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '/').collect(); |
| 216 |
assert_eq!(post(app, &bob_link, None, "password=bob-new-password-ok&confirm=bob-new-password-ok").await.location, "/login?reset=1"); |
| 217 |
let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap(); |
| 218 |
assert!(audit.iter().any(|a| a.action == "admin.account.reset_link" && a.target == "bob")); |
| 219 |
assert!(audit.iter().any(|a| a.action == "password.reset" && a.target == "bob" && a.detail == "administrator's link")); |
| 220 |
} |
| 221 |
|
| 222 |
#[tokio::test(flavor = "multi_thread")] |
| 223 |
async fn without_mail_an_administrator_resets() { |
| 224 |
let h = hub(false).await; |
| 225 |
let page = get(&h.app, "/login/reset", None).await; |
| 226 |
assert!(page.body.contains("does not send email") && !page.body.contains("SEND A RESET LINK")); |
| 227 |
let sent = post(&h.app, "/login/reset", None, "who=andy").await; |
| 228 |
assert!(!sent.body.contains("Check your email")); |
| 229 |
assert!(get(&h.app, "/login", None).await.body.contains("href=\"/login/reset\"")); |
| 230 |
} |