| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | mod common; |
| 7 | use common::{BIN, Server, describe, launch, pick_ports, write_config}; |
| 8 | use std::fs; |
| 9 | use std::future::Future; |
| 10 | use std::path::{Path, PathBuf}; |
| 11 | use std::process::Command; |
| 12 | use std::time::Duration; |
| 13 | |
| 14 | use forge_core::{Store, Visibility}; |
| 15 | use reqwest::header::{COOKIE, HeaderMap, HeaderName, LOCATION, SET_COOKIE}; |
| 16 | |
| 17 | |
| 18 | static SERIAL: std::sync::LazyLock<tokio::sync::Mutex<()>> = |
| 19 | std::sync::LazyLock::new(|| tokio::sync::Mutex::new(())); |
| 20 | |
| 21 | const PW: &str = "alice-password-123"; |
| 22 | |
| 23 | struct Resp { |
| 24 | status: u16, |
| 25 | headers: HeaderMap, |
| 26 | body: String, |
| 27 | } |
| 28 | |
| 29 | impl 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 | |
| 46 | struct Http { |
| 47 | client: reqwest::Client, |
| 48 | base: String, |
| 49 | } |
| 50 | |
| 51 | impl 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 | |
| 95 | fn 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 | |
| 104 | fn 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 | |
| 115 | fn 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 | |
| 132 | async 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 | |
| 151 | async 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 | |
| 158 | |
| 159 | struct Env { |
| 160 | _root: tempfile::TempDir, |
| 161 | dir: PathBuf, |
| 162 | _server: Server, |
| 163 | http: Http, |
| 164 | } |
| 165 | |
| 166 | async fn start<F, Fut>(seed: F) -> Env |
| 167 | where |
| 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 | |
| 193 | async 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] |
| 221 | async 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] |
| 269 | async 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 | |
| 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 | |
| 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] |
| 332 | async 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 | } |