forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
11.1 KiB
1//! Black-box tests for account deletion: the admin delete page, the self-service section on
2//! /settings/security, and what stays visible afterwards. Like the other e2e tests, this starts
3//! the real `forge` binary and drives it over HTTP. Cookies are set by hand because the
4//! session cookie is `__Host-` and `Secure`.
5
6mod common;
7use common::{BIN, Server, describe, launch, pick_ports, write_config};
8use std::fs;
9use std::future::Future;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use std::time::Duration;
13
14use forge_core::{Store, Visibility};
15use reqwest::header::{COOKIE, HeaderMap, HeaderName, LOCATION, SET_COOKIE};
16
17/// Each test starts a server on ports picked just before, so the tests run one at a time.
18static SERIAL: std::sync::LazyLock<tokio::sync::Mutex<()>> =
19 std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
20
21const PW: &str = "alice-password-123";
22
23struct Resp {
24 status: u16,
25 headers: HeaderMap,
26 body: String,
27}
28
29impl Resp {
30 fn header(&self, name: HeaderName) -> Option<String> {
31 self.headers
32 .get(name)
33 .and_then(|v| v.to_str().ok())
34 .map(str::to_owned)
35 }
36
37 fn set_cookies(&self) -> Vec<String> {
38 self.headers
39 .get_all(SET_COOKIE)
40 .iter()
41 .filter_map(|v| v.to_str().ok().map(str::to_owned))
42 .collect()
43 }
44}
45
46struct Http {
47 client: reqwest::Client,
48 base: String,
49}
50
51impl Http {
52 fn new(port: u16) -> Self {
53 let client = reqwest::Client::builder()
54 .redirect(reqwest::redirect::Policy::none())
55 .timeout(Duration::from_secs(60))
56 .build()
57 .expect("http client");
58 Self {
59 client,
60 base: format!("http://127.0.0.1:{port}"),
61 }
62 }
63
64 async fn get(&self, path: &str, cookie: Option<&str>) -> Resp {
65 self.send(self.client.get(format!("{}{path}", self.base)), cookie)
66 .await
67 }
68
69 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
70 self.send(
71 self.client.post(format!("{}{path}", self.base)).form(form),
72 cookie,
73 )
74 .await
75 }
76
77 async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp {
78 let req = match cookie {
79 Some(cookie) => req.header(COOKIE, cookie),
80 None => req,
81 };
82 let res = req.send().await.expect("http request");
83 let status = res.status().as_u16();
84 let headers = res.headers().clone();
85 let body = res.text().await.expect("read body");
86 Resp {
87 status,
88 headers,
89 body,
90 }
91 }
92}
93
94/// `name=value` for the cookie whose name ends with `suffix`, ignoring cleared cookies.
95fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
96 resp.set_cookies().iter().find_map(|set| {
97 let pair = set.split(';').next()?;
98 let (name, value) = pair.split_once('=')?;
99 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
100 })
101}
102
103/// The value of the first `<input name="...">` on a page.
104fn input_value(body: &str, name: &str) -> String {
105 let doc = scraper::Html::parse_document(body);
106 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
107 doc.select(&selector)
108 .next()
109 .and_then(|el| el.value().attr("value"))
110 .unwrap_or_else(|| panic!("no input named {name} on page"))
111 .to_owned()
112}
113
114/// Runs an admin subcommand with the password in FORGE_PASSWORD and asserts it succeeded.
115fn admin(config: &Path, args: &[&str]) {
116 let out = Command::new(BIN)
117 .arg("--config")
118 .arg(config)
119 .arg("admin")
120 .args(args)
121 .env("FORGE_PASSWORD", PW)
122 .output()
123 .expect("run admin command");
124 assert!(
125 out.status.success(),
126 "admin {args:?} failed:\n{}",
127 describe(&out)
128 );
129}
130
131/// Submits the login form. Returns the response, so callers decide what it means.
132async fn try_login(http: &Http, username: &str) -> Resp {
133 let page = http.get("/login", None).await;
134 assert_eq!(page.status, 200, "GET /login");
135 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
136 let precsrf = input_value(&page.body, "precsrf");
137 http.post_form(
138 "/login",
139 &[
140 ("username", username),
141 ("password", PW),
142 ("next", "/"),
143 ("precsrf", &precsrf),
144 ],
145 Some(&precsrf_cookie),
146 )
147 .await
148}
149
150/// Signs in and returns the session cookie.
151async fn login(http: &Http, username: &str) -> String {
152 let res = try_login(http, username).await;
153 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
154 cookie_named(&res, "forge_session").expect("session cookie after login")
155}
156
157/// Creates the users (`dana` is the first user, so an administrator), then runs `seed` against
158/// the store before the server starts. `seed` gets the data dir and the open store.
159struct Env {
160 _root: tempfile::TempDir,
161 dir: PathBuf,
162 _server: Server,
163 http: Http,
164}
165
166async fn start<F, Fut>(seed: F) -> Env
167where
168 F: FnOnce(PathBuf, Store) -> Fut,
169 Fut: Future<Output = ()>,
170{
171 let root = tempfile::tempdir().expect("temp dir");
172 let dir = root.path().to_path_buf();
173 let ports = pick_ports();
174 let config = write_config(&dir, ports);
175 for user in ["dana", "alice", "bob"] {
176 admin(&config, &["create-user", user]);
177 }
178 let store = Store::open(&dir.join("forge.db"))
179 .await
180 .expect("open store");
181 seed(dir.clone(), store).await;
182 let (server, ports) = launch(&dir, ports).await;
183 let http = Http::new(ports.http);
184 Env {
185 _root: root,
186 dir,
187 _server: server,
188 http,
189 }
190}
191
192/// Bob owns public `proj`. Alice wrote issue #1 and a comment on it.
193async fn seed_issue_by_alice(dir: PathBuf, store: Store) {
194 let alice = store
195 .user_by_username("alice")
196 .await
197 .unwrap()
198 .expect("alice");
199 let bob = store.user_by_username("bob").await.unwrap().expect("bob");
200 store
201 .create_repo(&bob.id, "proj", "", Visibility::Public, "main")
202 .await
203 .expect("create repo");
204 let repo = store
205 .repo_by_owner_and_name("bob", "proj")
206 .await
207 .unwrap()
208 .expect("repo");
209 let issue = store
210 .create_issue(&repo.id, &alice.id, "alice bug report", "body text")
211 .await
212 .expect("create issue");
213 store
214 .add_comment(&issue.id, &alice.id, "alice follow-up comment")
215 .await
216 .expect("add comment");
217 fs::write(dir.join("alice.id"), &alice.id).expect("write alice id");
218}
219
220#[tokio::test]
221async fn admin_delete_moves_content_to_ghost_and_pages_still_render() {
222 let _serial = SERIAL.lock().await;
223 let env = start(seed_issue_by_alice).await;
224 let http = &env.http;
225 let dana = login(http, "dana").await;
226 let alice_id = fs::read_to_string(env.dir.join("alice.id")).expect("alice id");
227 let path = format!("/admin/users/{alice_id}/delete");
228
229 let page = http.get(&path, Some(&dana)).await;
230 assert_eq!(page.status, 200, "{}", page.body);
231 assert!(page.body.contains("Delete user"), "{}", page.body);
232 let csrf = input_value(&page.body, "csrf");
233
234 let wrong = http
235 .post_form(
236 &path,
237 &[("csrf", &csrf), ("confirm_username", "Alicia")],
238 Some(&dana),
239 )
240 .await;
241 assert_eq!(wrong.status, 200);
242 assert!(wrong.body.contains("exactly to confirm"), "{}", wrong.body);
243
244 let done = http
245 .post_form(
246 &path,
247 &[("csrf", &csrf), ("confirm_username", "alice")],
248 Some(&dana),
249 )
250 .await;
251 assert_eq!(done.status, 303, "{}", done.body);
252 assert_eq!(done.header(LOCATION).as_deref(), Some("/admin/users"));
253
254 let issue = http.get("/bob/proj/issues/1", None).await;
255 assert_eq!(issue.status, 200, "{}", issue.body);
256 assert!(
257 issue.body.contains("alice follow-up comment"),
258 "{}",
259 issue.body
260 );
261 assert!(issue.body.contains("Deleted user"), "{}", issue.body);
262
263 let alice_login = try_login(http, "alice").await;
264 assert_ne!(alice_login.status, 303, "deleted account must not sign in");
265 assert!(cookie_named(&alice_login, "forge_session").is_none());
266}
267
268#[tokio::test]
269async fn self_delete_refuses_wrong_password_and_owned_repos() {
270 let _serial = SERIAL.lock().await;
271 let env = start(seed_issue_by_alice).await;
272 let http = &env.http;
273
274 // Alice owns nothing: a wrong password is refused and the account stays.
275 let alice = login(http, "alice").await;
276 let security = http.get("/settings/security", Some(&alice)).await;
277 assert_eq!(security.status, 200);
278 let csrf = input_value(&security.body, "csrf");
279 let wrong = http
280 .post_form(
281 "/settings/security/delete",
282 &[
283 ("csrf", &csrf),
284 ("current_password", "not-the-password"),
285 ("confirm_username", "alice"),
286 ],
287 Some(&alice),
288 )
289 .await;
290 assert_eq!(wrong.status, 200, "{}", wrong.body);
291 assert!(
292 wrong.body.contains("Current password is incorrect."),
293 "{}",
294 wrong.body
295 );
296 assert!(
297 try_login(http, "alice").await.status == 303,
298 "account must still exist"
299 );
300
301 // Bob owns `proj`: deletion is refused and the repo is named.
302 let bob = login(http, "bob").await;
303 let security = http.get("/settings/security", Some(&bob)).await;
304 let csrf = input_value(&security.body, "csrf");
305 let refused = http
306 .post_form(
307 "/settings/security/delete",
308 &[
309 ("csrf", &csrf),
310 ("current_password", PW),
311 ("confirm_username", "bob"),
312 ],
313 Some(&bob),
314 )
315 .await;
316 assert_eq!(refused.status, 200, "{}", refused.body);
317 assert!(
318 refused
319 .body
320 .contains("transfer or delete their repositories first")
321 || refused.body.contains("bob/proj"),
322 "{}",
323 refused.body
324 );
325 assert!(
326 try_login(http, "bob").await.status == 303,
327 "bob must still exist"
328 );
329}
330
331#[tokio::test]
332async fn self_delete_with_correct_password_signs_out_and_removes_login() {
333 let _serial = SERIAL.lock().await;
334 let env = start(seed_issue_by_alice).await;
335 let http = &env.http;
336 let alice = login(http, "alice").await;
337 let security = http.get("/settings/security", Some(&alice)).await;
338 let csrf = input_value(&security.body, "csrf");
339 let done = http
340 .post_form(
341 "/settings/security/delete",
342 &[
343 ("csrf", &csrf),
344 ("current_password", PW),
345 ("confirm_username", "alice"),
346 ],
347 Some(&alice),
348 )
349 .await;
350 assert_eq!(done.status, 303, "{}", done.body);
351 assert_eq!(done.header(LOCATION).as_deref(), Some("/"));
352 let after = http.get("/settings/security", Some(&alice)).await;
353 assert_ne!(after.status, 200, "the old session must be gone");
354 assert_ne!(try_login(http, "alice").await.status, 303);
355}