forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
8.9 KiB
1//! Black-box tests for the account mail flows: password reset over HTTP. Like the other e2e
2//! tests, this starts the real `forge` binary. The log transport writes each mail to
3//! `<data_dir>/mail-log/`, which is where the tests read the reset link from.
4
5mod common;
6use common::{launch_with, pick_ports};
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use forge_core::Store;
12use forge_core::password::hash_password;
13use reqwest::header::{COOKIE, HeaderMap, SET_COOKIE};
14
15const LOG_TRANSPORT: &str = "\n[mail]\ntransport = \"log\"\n";
16const OLD_PW: &str = "alice-old-password-1";
17const NEW_PW: &str = "alice-new-password-2";
18
19struct Resp {
20 status: u16,
21 headers: HeaderMap,
22 body: String,
23}
24
25struct Http {
26 client: reqwest::Client,
27 base: String,
28}
29
30impl Http {
31 fn new(port: u16) -> Self {
32 let client = reqwest::Client::builder()
33 .redirect(reqwest::redirect::Policy::none())
34 .timeout(Duration::from_secs(60))
35 .build()
36 .expect("http client");
37 Self {
38 client,
39 base: format!("http://127.0.0.1:{port}"),
40 }
41 }
42
43 async fn get(&self, path: &str, cookie: Option<&str>) -> Resp {
44 let mut req = self.client.get(format!("{}{path}", self.base));
45 if let Some(cookie) = cookie {
46 req = req.header(COOKIE, cookie);
47 }
48 Self::finish(req).await
49 }
50
51 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
52 let mut req = self.client.post(format!("{}{path}", self.base)).form(form);
53 if let Some(cookie) = cookie {
54 req = req.header(COOKIE, cookie);
55 }
56 Self::finish(req).await
57 }
58
59 async fn finish(req: reqwest::RequestBuilder) -> Resp {
60 let res = req.send().await.expect("http request");
61 let status = res.status().as_u16();
62 let headers = res.headers().clone();
63 let body = res.text().await.expect("read body");
64 Resp {
65 status,
66 headers,
67 body,
68 }
69 }
70}
71
72/// The `name=value` pair of the Set-Cookie header whose name starts with `prefix`.
73fn cookie_named(resp: &Resp, prefix: &str) -> Option<String> {
74 resp.headers
75 .get_all(SET_COOKIE)
76 .iter()
77 .filter_map(|v| v.to_str().ok())
78 .find(|raw| raw.starts_with(prefix))
79 .map(|raw| raw.split(';').next().unwrap().to_owned())
80}
81
82/// The value of a hidden `<input name=... value=...>` field.
83fn input_value(html: &str, name: &str) -> String {
84 let marker = format!("name=\"{name}\" value=\"");
85 let start = html.find(&marker).unwrap_or_else(|| panic!("{name} field")) + marker.len();
86 let end = html[start..].find('"').unwrap() + start;
87 html[start..end].to_owned()
88}
89
90/// Creates alice with a verified address. Bob has an address that is not verified. Runs before
91/// the server starts, like the other e2e tests.
92async fn seed(dir: &Path) {
93 let store = Store::open(&dir.join("forge.db"))
94 .await
95 .expect("open store");
96 let alice = store
97 .create_user(
98 "alice",
99 Some("alice@example.com"),
100 &hash_password(OLD_PW).unwrap(),
101 false,
102 )
103 .await
104 .expect("alice");
105 let token = store
106 .create_verify_token(&alice.id, "alice@example.com")
107 .await
108 .expect("verify token");
109 store
110 .redeem_verify_token(&token)
111 .await
112 .expect("verify")
113 .expect("verified");
114 // The worker sends nothing while mail is off, and it is off by default. The log transport
115 // ignores the host, but enabling mail needs one.
116 store
117 .update_mail_settings(&forge_core::mail::MailSettingsUpdate {
118 host: "smtp.example",
119 port: 587,
120 security: forge_core::mail::Security::StartTls,
121 username: "",
122 from_addr: "forge@example.com",
123 from_name: "forge",
124 enabled: true,
125 password: None,
126 })
127 .await
128 .expect("mail settings");
129 store
130 .create_user(
131 "bob",
132 Some("bob@example.com"),
133 &hash_password(OLD_PW).unwrap(),
134 false,
135 )
136 .await
137 .expect("bob");
138}
139
140/// The text of every mail the log transport has written so far.
141fn mail_log(dir: &Path) -> String {
142 let log: PathBuf = dir.join("mail-log");
143 let Ok(entries) = fs::read_dir(&log) else {
144 return String::new();
145 };
146 entries
147 .filter_map(|entry| entry.ok())
148 .filter_map(|entry| fs::read_to_string(entry.path()).ok())
149 .collect::<Vec<_>>()
150 .join("\n----\n")
151}
152
153/// Waits until a mail containing `marker` has been logged, and returns the whole log.
154async fn wait_for_mail(dir: &Path, marker: &str) -> String {
155 for _ in 0..200 {
156 let log = mail_log(dir);
157 if log.contains(marker) {
158 return log;
159 }
160 tokio::time::sleep(Duration::from_millis(50)).await;
161 }
162 panic!("no mail with {marker} was logged");
163}
164
165/// The `/reset/<token>` path from the first reset mail in the log.
166fn reset_path(log: &str) -> String {
167 let start = log.find("/reset/").expect("reset link");
168 log[start..].split_whitespace().next().unwrap().to_owned()
169}
170
171/// Logs in with `username` and `password`. Returns the HTTP status of the attempt.
172async fn login_status(http: &Http, username: &str, password: &str) -> u16 {
173 let page = http.get("/login", None).await;
174 let cookie = cookie_named(&page, "__Host-forge_precsrf").expect("precsrf cookie");
175 let precsrf = input_value(&page.body, "precsrf");
176 let res = http
177 .post_form(
178 "/login",
179 &[
180 ("username", username),
181 ("password", password),
182 ("next", "/"),
183 ("precsrf", &precsrf),
184 ],
185 Some(&cookie),
186 )
187 .await;
188 res.status
189}
190
191/// Submits the forgot form for `email` and returns the answer's status and body.
192async fn forgot(http: &Http, email: &str) -> Resp {
193 let page = http.get("/forgot", None).await;
194 assert_eq!(page.status, 200, "GET /forgot");
195 let cookie = cookie_named(&page, "__Host-forge_precsrf").expect("precsrf cookie");
196 let precsrf = input_value(&page.body, "precsrf");
197 http.post_form(
198 "/forgot",
199 &[("email", email), ("precsrf", &precsrf)],
200 Some(&cookie),
201 )
202 .await
203}
204
205#[tokio::test]
206async fn reset_link_sets_a_new_password_once() {
207 let root = tempfile::tempdir().expect("temp dir");
208 let dir = root.path().to_path_buf();
209 let ports = pick_ports();
210 seed(&dir).await;
211 let (_server, ports) = launch_with(&dir, ports, LOG_TRANSPORT).await;
212 let http = Http::new(ports.http);
213
214 let answer = forgot(&http, "alice@example.com").await;
215 assert_eq!(answer.status, 200, "{}", answer.body);
216 assert!(answer.body.contains("is on its way"));
217
218 let log = wait_for_mail(&dir, "/reset/").await;
219 let path = reset_path(&log);
220
221 let page = http.get(&path, None).await;
222 assert_eq!(page.status, 200, "{}", page.body);
223 assert!(page.body.contains("Set a new password"));
224
225 let cookie = cookie_named(&page, "__Host-forge_precsrf").expect("precsrf cookie");
226 let precsrf = cookie.split_once('=').unwrap().1.to_owned();
227 let form = [
228 ("precsrf", precsrf.as_str()),
229 ("password", NEW_PW),
230 ("confirm", NEW_PW),
231 ];
232 let done = http.post_form(&path, &form, Some(&cookie)).await;
233 assert_eq!(done.status, 200, "{}", done.body);
234 assert!(done.body.contains("Your password is changed"));
235
236 assert_eq!(
237 login_status(&http, "alice", OLD_PW).await,
238 200,
239 "old password refused"
240 );
241 assert_eq!(
242 login_status(&http, "alice", NEW_PW).await,
243 303,
244 "new password works"
245 );
246
247 // Reusing the link is refused, and the password does not change again.
248 let again = http.post_form(&path, &form, Some(&cookie)).await;
249 assert_eq!(again.status, 200);
250 assert!(again.body.contains("This reset link is invalid"));
251 assert_eq!(login_status(&http, "alice", NEW_PW).await, 303);
252}
253
254#[tokio::test]
255async fn an_unverified_address_gets_no_reset_mail_and_the_same_answer() {
256 let root = tempfile::tempdir().expect("temp dir");
257 let dir = root.path().to_path_buf();
258 let ports = pick_ports();
259 seed(&dir).await;
260 let (_server, ports) = launch_with(&dir, ports, LOG_TRANSPORT).await;
261 let http = Http::new(ports.http);
262
263 let unknown = forgot(&http, "nobody@example.com").await;
264 let unverified = forgot(&http, "bob@example.com").await;
265 assert_eq!(unknown.status, 200);
266 assert_eq!(unverified.status, 200);
267 assert_eq!(
268 unknown.body.contains("is on its way"),
269 unverified.body.contains("is on its way")
270 );
271
272 // Give the background lookups time to run, then confirm nothing was sent.
273 tokio::time::sleep(Duration::from_secs(1)).await;
274 assert!(!mail_log(&dir).contains("/reset/"), "no reset mail for bob");
275}