| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | mod common; |
| 6 | use common::{BIN, Server, describe, launch, pick_ports, tool_on_path, write_config}; |
| 7 | use std::fs; |
| 8 | use std::path::{Path, PathBuf}; |
| 9 | use std::process::{Command, Stdio}; |
| 10 | use std::time::Duration; |
| 11 | |
| 12 | use forge_core::{Store, Visibility}; |
| 13 | use reqwest::header::{COOKIE, HeaderMap, HeaderName, LOCATION, SET_COOKIE}; |
| 14 | |
| 15 | const PW: &str = "alice-password-123"; |
| 16 | |
| 17 | struct Resp { |
| 18 | status: u16, |
| 19 | headers: HeaderMap, |
| 20 | body: String, |
| 21 | } |
| 22 | |
| 23 | impl Resp { |
| 24 | fn header(&self, name: HeaderName) -> Option<String> { |
| 25 | self.headers |
| 26 | .get(name) |
| 27 | .and_then(|v| v.to_str().ok()) |
| 28 | .map(str::to_owned) |
| 29 | } |
| 30 | |
| 31 | fn set_cookies(&self) -> Vec<String> { |
| 32 | self.headers |
| 33 | .get_all(SET_COOKIE) |
| 34 | .iter() |
| 35 | .filter_map(|v| v.to_str().ok().map(str::to_owned)) |
| 36 | .collect() |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | struct Http { |
| 41 | client: reqwest::Client, |
| 42 | base: String, |
| 43 | } |
| 44 | |
| 45 | impl Http { |
| 46 | fn new(port: u16) -> Self { |
| 47 | let client = reqwest::Client::builder() |
| 48 | .redirect(reqwest::redirect::Policy::none()) |
| 49 | .timeout(Duration::from_secs(60)) |
| 50 | .build() |
| 51 | .expect("http client"); |
| 52 | Self { |
| 53 | client, |
| 54 | base: format!("http://127.0.0.1:{port}"), |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | async fn get(&self, path: &str, cookie: Option<&str>) -> Resp { |
| 59 | self.send(self.client.get(format!("{}{path}", self.base)), cookie) |
| 60 | .await |
| 61 | } |
| 62 | |
| 63 | async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp { |
| 64 | self.send( |
| 65 | self.client.post(format!("{}{path}", self.base)).form(form), |
| 66 | cookie, |
| 67 | ) |
| 68 | .await |
| 69 | } |
| 70 | |
| 71 | async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp { |
| 72 | let req = match cookie { |
| 73 | Some(cookie) => req.header(COOKIE, cookie), |
| 74 | None => req, |
| 75 | }; |
| 76 | let res = req.send().await.expect("http request"); |
| 77 | let status = res.status().as_u16(); |
| 78 | let headers = res.headers().clone(); |
| 79 | let body = res.text().await.expect("read body"); |
| 80 | Resp { |
| 81 | status, |
| 82 | headers, |
| 83 | body, |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | |
| 89 | fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> { |
| 90 | resp.set_cookies().iter().find_map(|set| { |
| 91 | let pair = set.split(';').next()?; |
| 92 | let (name, value) = pair.split_once('=')?; |
| 93 | (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}")) |
| 94 | }) |
| 95 | } |
| 96 | |
| 97 | |
| 98 | fn input_value(body: &str, name: &str) -> String { |
| 99 | let doc = scraper::Html::parse_document(body); |
| 100 | let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector"); |
| 101 | doc.select(&selector) |
| 102 | .next() |
| 103 | .and_then(|el| el.value().attr("value")) |
| 104 | .unwrap_or_else(|| panic!("no input named {name} on page")) |
| 105 | .to_owned() |
| 106 | } |
| 107 | |
| 108 | |
| 109 | fn admin(config: &Path, args: &[&str]) { |
| 110 | let out = Command::new(BIN) |
| 111 | .arg("--config") |
| 112 | .arg(config) |
| 113 | .arg("admin") |
| 114 | .args(args) |
| 115 | .env("FORGE_PASSWORD", PW) |
| 116 | .output() |
| 117 | .expect("run admin command"); |
| 118 | assert!( |
| 119 | out.status.success(), |
| 120 | "admin {args:?} failed:\n{}", |
| 121 | describe(&out) |
| 122 | ); |
| 123 | } |
| 124 | |
| 125 | async fn login(http: &Http, username: &str) -> String { |
| 126 | let page = http.get("/login", None).await; |
| 127 | assert_eq!(page.status, 200, "GET /login"); |
| 128 | let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie"); |
| 129 | let precsrf = input_value(&page.body, "precsrf"); |
| 130 | let res = http |
| 131 | .post_form( |
| 132 | "/login", |
| 133 | &[ |
| 134 | ("username", username), |
| 135 | ("password", PW), |
| 136 | ("next", "/"), |
| 137 | ("precsrf", &precsrf), |
| 138 | ], |
| 139 | Some(&precsrf_cookie), |
| 140 | ) |
| 141 | .await; |
| 142 | assert_eq!(res.status, 303, "login as {username}: {}", res.body); |
| 143 | cookie_named(&res, "forge_session").expect("session cookie after login") |
| 144 | } |
| 145 | |
| 146 | |
| 147 | fn init_bare(data_dir: &Path, repo_id: &str) { |
| 148 | let path = forge_git::repo::repo_path(data_dir, repo_id).expect("repo path"); |
| 149 | fs::create_dir_all(path.parent().expect("parent")).expect("create repo parent"); |
| 150 | let out = Command::new("git") |
| 151 | .args(["init", "--bare", "--quiet", "--initial-branch=main"]) |
| 152 | .arg(&path) |
| 153 | .output() |
| 154 | .expect("run git init"); |
| 155 | assert!(out.status.success(), "git init --bare: {}", describe(&out)); |
| 156 | } |
| 157 | |
| 158 | |
| 159 | struct Env { |
| 160 | _root: tempfile::TempDir, |
| 161 | _server: Server, |
| 162 | http: Http, |
| 163 | } |
| 164 | |
| 165 | |
| 166 | |
| 167 | async fn start<F, Fut>(seed: F) -> Env |
| 168 | where |
| 169 | F: FnOnce(PathBuf, Store) -> Fut, |
| 170 | Fut: std::future::Future<Output = ()>, |
| 171 | { |
| 172 | let root = tempfile::Builder::new() |
| 173 | .prefix("fst") |
| 174 | .tempdir_in("/tmp") |
| 175 | .expect("temp dir"); |
| 176 | let dir = root.path().to_path_buf(); |
| 177 | let ports = pick_ports(); |
| 178 | let config = write_config(&dir, ports); |
| 179 | admin(&config, &["create-user", "alice"]); |
| 180 | admin(&config, &["create-user", "bob"]); |
| 181 | let store = Store::open(&dir.join("forge.db")) |
| 182 | .await |
| 183 | .expect("open store"); |
| 184 | seed(dir.clone(), store).await; |
| 185 | let (server, ports) = launch(&dir, ports).await; |
| 186 | let http = Http::new(ports.http); |
| 187 | Env { |
| 188 | _root: root, |
| 189 | _server: server, |
| 190 | http, |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | |
| 195 | async fn seed_repos(dir: PathBuf, store: Store) { |
| 196 | let alice = store |
| 197 | .user_by_username("alice") |
| 198 | .await |
| 199 | .unwrap() |
| 200 | .expect("alice"); |
| 201 | for (name, vis) in [ |
| 202 | ("pub", Visibility::Public), |
| 203 | ("priv", Visibility::Private), |
| 204 | ("pct%x", Visibility::Public), |
| 205 | ] { |
| 206 | let repo = store |
| 207 | .create_repo(&alice.id, name, "", vis, "main") |
| 208 | .await |
| 209 | .expect("create repo"); |
| 210 | init_bare(&dir, &repo.id); |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | |
| 215 | fn commit_main(data_dir: &Path, repo_id: &str) { |
| 216 | let id = forge_git::repo::repo_path(data_dir, repo_id).expect("repo path"); |
| 217 | let blob = plumbing(&id, &["hash-object", "-w", "--stdin"], b"hello\n"); |
| 218 | let tree = plumbing( |
| 219 | &id, |
| 220 | &["mktree"], |
| 221 | format!("100644 blob {blob}\tREADME.md\n").as_bytes(), |
| 222 | ); |
| 223 | let commit = plumbing(&id, &["commit-tree", &tree, "-m", "init"], b""); |
| 224 | let _ = plumbing(&id, &["update-ref", "refs/heads/main", &commit], b""); |
| 225 | } |
| 226 | |
| 227 | |
| 228 | fn plumbing(path: &Path, args: &[&str], stdin: &[u8]) -> String { |
| 229 | use std::io::Write; |
| 230 | let mut child = Command::new("git") |
| 231 | .arg("--git-dir") |
| 232 | .arg(path) |
| 233 | .args(args) |
| 234 | .env("GIT_AUTHOR_NAME", "t") |
| 235 | .env("GIT_AUTHOR_EMAIL", "t@example.com") |
| 236 | .env("GIT_COMMITTER_NAME", "t") |
| 237 | .env("GIT_COMMITTER_EMAIL", "t@example.com") |
| 238 | .stdin(Stdio::piped()) |
| 239 | .stdout(Stdio::piped()) |
| 240 | .stderr(Stdio::piped()) |
| 241 | .spawn() |
| 242 | .expect("run git"); |
| 243 | child |
| 244 | .stdin |
| 245 | .take() |
| 246 | .expect("stdin") |
| 247 | .write_all(stdin) |
| 248 | .expect("write stdin"); |
| 249 | let out = child.wait_with_output().expect("git output"); |
| 250 | assert!(out.status.success(), "git {args:?}: {}", describe(&out)); |
| 251 | String::from_utf8_lossy(&out.stdout).trim().to_owned() |
| 252 | } |
| 253 | |
| 254 | |
| 255 | fn repo_names(body: &str) -> Vec<String> { |
| 256 | let doc = scraper::Html::parse_document(body); |
| 257 | let selector = scraper::Selector::parse(".repo-name").expect("selector"); |
| 258 | doc.select(&selector) |
| 259 | .map(|el| el.text().collect::<String>().trim().to_owned()) |
| 260 | .collect() |
| 261 | } |
| 262 | |
| 263 | |
| 264 | async fn post_star( |
| 265 | http: &Http, |
| 266 | path: &str, |
| 267 | csrf: &str, |
| 268 | cookie: Option<&str>, |
| 269 | referer: Option<&str>, |
| 270 | ) -> Resp { |
| 271 | let mut req = http |
| 272 | .client |
| 273 | .post(format!("{}{path}", http.base)) |
| 274 | .form(&[("csrf", csrf)]); |
| 275 | if let Some(referer) = referer { |
| 276 | req = req.header(reqwest::header::REFERER, referer); |
| 277 | } |
| 278 | http.send(req, cookie).await |
| 279 | } |
| 280 | |
| 281 | #[tokio::test] |
| 282 | async fn star_round_trip_shows_the_count() { |
| 283 | if !tool_on_path("git") { |
| 284 | eprintln!("SKIP stars_e2e: `git` was not found on PATH"); |
| 285 | return; |
| 286 | } |
| 287 | let env = start(seed_repos).await; |
| 288 | let bob = login(&env.http, "bob").await; |
| 289 | |
| 290 | let page = env.http.get("/alice/pub", Some(&bob)).await; |
| 291 | assert_eq!(page.status, 200, "repo page"); |
| 292 | assert!( |
| 293 | page.body.contains("Star 0"), |
| 294 | "star button with zero: {}", |
| 295 | page.body |
| 296 | ); |
| 297 | let csrf = input_value(&page.body, "csrf"); |
| 298 | |
| 299 | let res = post_star(&env.http, "/alice/pub/star", &csrf, Some(&bob), None).await; |
| 300 | assert_eq!(res.status, 303, "star: {}", res.body); |
| 301 | assert_eq!(res.header(LOCATION).as_deref(), Some("/alice/pub")); |
| 302 | let page = env.http.get("/alice/pub", Some(&bob)).await; |
| 303 | assert!(page.body.contains("Unstar 1"), "after star: {}", page.body); |
| 304 | |
| 305 | |
| 306 | let referer = format!("{}/alice/pub/tree/main", env.http.base); |
| 307 | let res = post_star( |
| 308 | &env.http, |
| 309 | "/alice/pub/unstar", |
| 310 | &csrf, |
| 311 | Some(&bob), |
| 312 | Some(&referer), |
| 313 | ) |
| 314 | .await; |
| 315 | assert_eq!(res.status, 303, "unstar: {}", res.body); |
| 316 | assert_eq!( |
| 317 | res.header(LOCATION).as_deref(), |
| 318 | Some("/alice/pub/tree/main") |
| 319 | ); |
| 320 | let page = env.http.get("/alice/pub", Some(&bob)).await; |
| 321 | assert!(page.body.contains("Star 0"), "after unstar: {}", page.body); |
| 322 | |
| 323 | |
| 324 | let foreign = "https://evil.example/alice/pub"; |
| 325 | let res = post_star( |
| 326 | &env.http, |
| 327 | "/alice/pub/star", |
| 328 | &csrf, |
| 329 | Some(&bob), |
| 330 | Some(foreign), |
| 331 | ) |
| 332 | .await; |
| 333 | assert_eq!(res.header(LOCATION).as_deref(), Some("/alice/pub")); |
| 334 | } |
| 335 | |
| 336 | #[tokio::test] |
| 337 | async fn anonymous_star_redirects_to_login() { |
| 338 | let env = start(seed_repos).await; |
| 339 | let res = post_star(&env.http, "/alice/pub/star", "anything", None, None).await; |
| 340 | assert_eq!(res.status, 303); |
| 341 | let location = res.header(LOCATION).unwrap_or_default(); |
| 342 | assert!( |
| 343 | location.starts_with("/login"), |
| 344 | "redirect to login, got {location}" |
| 345 | ); |
| 346 | } |
| 347 | |
| 348 | #[tokio::test] |
| 349 | async fn star_without_a_valid_csrf_token_is_403() { |
| 350 | let env = start(seed_repos).await; |
| 351 | let bob = login(&env.http, "bob").await; |
| 352 | let res = post_star(&env.http, "/alice/pub/star", "wrong", Some(&bob), None).await; |
| 353 | assert_eq!(res.status, 403); |
| 354 | let page = env.http.get("/alice/pub", Some(&bob)).await; |
| 355 | assert!(page.body.contains("Star 0"), "nothing was starred"); |
| 356 | } |
| 357 | |
| 358 | #[tokio::test] |
| 359 | async fn star_on_a_private_repo_is_404_for_a_non_reader() { |
| 360 | let env = start(seed_repos).await; |
| 361 | let bob = login(&env.http, "bob").await; |
| 362 | let res = post_star(&env.http, "/alice/priv/star", "anything", Some(&bob), None).await; |
| 363 | assert_eq!(res.status, 404); |
| 364 | } |
| 365 | |
| 366 | #[tokio::test] |
| 367 | async fn explore_lists_public_repos_and_searches_literally() { |
| 368 | let env = start(seed_repos).await; |
| 369 | |
| 370 | let page = env.http.get("/explore", None).await; |
| 371 | assert_eq!(page.status, 200, "explore"); |
| 372 | let names = repo_names(&page.body); |
| 373 | assert!(names.contains(&"alice/pub".to_owned()), "{names:?}"); |
| 374 | assert!(names.contains(&"alice/pct%x".to_owned()), "{names:?}"); |
| 375 | assert!( |
| 376 | !names.contains(&"alice/priv".to_owned()), |
| 377 | "private hidden: {names:?}" |
| 378 | ); |
| 379 | |
| 380 | |
| 381 | let page = env.http.get("/explore?q=%25", None).await; |
| 382 | assert_eq!(repo_names(&page.body), vec!["alice/pct%x".to_owned()]); |
| 383 | |
| 384 | |
| 385 | let bob = login(&env.http, "bob").await; |
| 386 | let page = env.http.get("/alice/pub", Some(&bob)).await; |
| 387 | let csrf = input_value(&page.body, "csrf"); |
| 388 | let res = post_star(&env.http, "/alice/pub/star", &csrf, Some(&bob), None).await; |
| 389 | assert_eq!(res.status, 303); |
| 390 | let by_name = repo_names(&env.http.get("/explore?sort=name", None).await.body); |
| 391 | assert_eq!( |
| 392 | by_name, |
| 393 | vec!["alice/pct%x".to_owned(), "alice/pub".to_owned()] |
| 394 | ); |
| 395 | let by_stars = repo_names(&env.http.get("/explore?sort=stars", None).await.body); |
| 396 | assert_eq!( |
| 397 | by_stars, |
| 398 | vec!["alice/pub".to_owned(), "alice/pct%x".to_owned()] |
| 399 | ); |
| 400 | } |
| 401 | |
| 402 | #[tokio::test] |
| 403 | async fn profile_starred_section_hides_repos_the_viewer_cannot_read() { |
| 404 | let env = start(|dir, store| async move { |
| 405 | seed_repos(dir, store.clone()).await; |
| 406 | let alice = store |
| 407 | .user_by_username("alice") |
| 408 | .await |
| 409 | .unwrap() |
| 410 | .expect("alice"); |
| 411 | let bob = store.user_by_username("bob").await.unwrap().expect("bob"); |
| 412 | let repos = store.list_repos_by_owner(&alice.id).await.unwrap(); |
| 413 | let find = |name: &str| repos.iter().find(|r| r.name == name).unwrap().id.clone(); |
| 414 | |
| 415 | store.star(&bob.id, &find("pub")).await.unwrap(); |
| 416 | store.star(&bob.id, &find("priv")).await.unwrap(); |
| 417 | }) |
| 418 | .await; |
| 419 | |
| 420 | let anon = env.http.get("/bob", None).await; |
| 421 | assert_eq!(anon.status, 200); |
| 422 | let starred = |body: &str| { |
| 423 | let doc = scraper::Html::parse_document(body); |
| 424 | let selector = scraper::Selector::parse(".profile-starred .repo-name").expect("selector"); |
| 425 | doc.select(&selector) |
| 426 | .map(|el| el.text().collect::<String>().trim().to_owned()) |
| 427 | .collect::<Vec<_>>() |
| 428 | }; |
| 429 | assert_eq!(starred(&anon.body), vec!["alice/pub".to_owned()]); |
| 430 | |
| 431 | let alice = login(&env.http, "alice").await; |
| 432 | let owner = env.http.get("/bob", Some(&alice)).await; |
| 433 | let mut names = starred(&owner.body); |
| 434 | names.sort(); |
| 435 | assert_eq!(names, vec!["alice/priv".to_owned(), "alice/pub".to_owned()]); |
| 436 | } |
| 437 | |
| 438 | #[tokio::test] |
| 439 | async fn archived_banner_shows_on_tree_and_issues_pages() { |
| 440 | let env = start(|dir, store| async move { |
| 441 | seed_repos(dir.clone(), store.clone()).await; |
| 442 | let repo = store |
| 443 | .repo_by_owner_and_name("alice", "pub") |
| 444 | .await |
| 445 | .unwrap() |
| 446 | .expect("pub"); |
| 447 | commit_main(&dir, &repo.id); |
| 448 | store.set_repo_archived(&repo.id, true).await.unwrap(); |
| 449 | }) |
| 450 | .await; |
| 451 | |
| 452 | let banner = "This repository is archived. It is read-only."; |
| 453 | let tree = env.http.get("/alice/pub/tree/main", None).await; |
| 454 | assert_eq!(tree.status, 200, "tree page"); |
| 455 | assert!(tree.body.contains(banner), "banner on tree"); |
| 456 | let issues = env.http.get("/alice/pub/issues", None).await; |
| 457 | assert_eq!(issues.status, 200, "issues page"); |
| 458 | assert!(issues.body.contains(banner), "banner on issues"); |
| 459 | } |