OXYGENHUB
◆ v0.6.1 oxygenhub / crates / hub / tests / recovery.rs
11.2 KiB · Rust · last changed A new hub gives administrator only to whoever brings its setup code 19 days ago source blame history raw
1
//! Email verification and password reset (hub spec §5.1.1), with mail
2
//! written to a directory (`file://`) so the test reads what was sent.
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
    // The first account's setup code (oxygenhub#245).
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
/// Every message so far, oldest first, as text.
88
/// Each message written to `dir`, as a reader sees it: MIME's
89
/// quoted-printable soft breaks joined and `=3D` decoded (#182).
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
/// Mail is sent in the background for resets; wait for `n` messages.
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
/// The path of the first `http://hub.test/…` link in a message.
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
    // Registration sends a verification link; until it is used, no reset mail.
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
    // The link verifies once.
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
    // Reset by verified email (any case); a second request within two minutes sends nothing.
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
    // Choosing the new password: checks, then every session ends.
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
    // Changing the email unverifies it; only the link for the current address counts.
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
    // A second link to the same address within two minutes is refused.
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
    // An administrator's link, for an account without a verified email.
209
    post(app, "/register", None, "username=bob&email=&password=bob-password-long-enough&setup=test-setup").await;
210
    let admin = get(app, "/admin", 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
}