Tahinli/forge
public0 watching
★ 0
| 1 | //! Black-box test for repo deletion. It starts the real `forge` binary, seeds repos through the |
| 2 | //! store before the server starts, and then drives the settings page and the delete form over HTTP. |
| 3 | //! |
| 4 | //! Cookies are handled by hand because the session cookie is `__Host-forge_session` with `Secure`, |
| 5 | //! which a cookie jar would not send over plain http. |
| 6 | |
| 7 | mod common; |
| 8 | use common::{BIN, describe, launch, pick_ports, tool_on_path, write_config}; |
| 9 | use std::fs; |
| 10 | use std::path::{Path, PathBuf}; |
| 11 | use std::process::{Command, Output}; |
| 12 | use std::time::Duration; |
| 13 | |
| 14 | use forge_core::{Role, Store, Visibility}; |
| 15 | use reqwest::header::{COOKIE, HeaderMap, HeaderName, LOCATION, SET_COOKIE}; |
| 16 | |
| 17 | const PW: &str = "alice-password-123"; |
| 18 | |
| 19 | struct Resp { |
| 20 | status: u16, |
| 21 | headers: HeaderMap, |
| 22 | body: String, |
| 23 | } |
| 24 | |
| 25 | impl Resp { |
| 26 | fn header(&self, name: HeaderName) -> Option<String> { |
| 27 | self.headers |
| 28 | .get(name) |
| 29 | .and_then(|v| v.to_str().ok()) |
| 30 | .map(str::to_owned) |
| 31 | } |
| 32 | |
| 33 | fn set_cookies(&self) -> Vec<String> { |
| 34 | self.headers |
| 35 | .get_all(SET_COOKIE) |
| 36 | .iter() |
| 37 | .filter_map(|v| v.to_str().ok().map(str::to_owned)) |
| 38 | .collect() |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | struct Http { |
| 43 | client: reqwest::Client, |
| 44 | base: String, |
| 45 | } |
| 46 | |
| 47 | impl Http { |
| 48 | fn new(port: u16) -> Self { |
| 49 | let client = reqwest::Client::builder() |
| 50 | .redirect(reqwest::redirect::Policy::none()) |
| 51 | .timeout(Duration::from_secs(60)) |
| 52 | .build() |
| 53 | .expect("http client"); |
| 54 | Self { |
| 55 | client, |
| 56 | base: format!("http://127.0.0.1:{port}"), |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | async fn get(&self, path: &str, cookie: Option<&str>) -> Resp { |
| 61 | self.send(self.client.get(format!("{}{path}", self.base)), cookie) |
| 62 | .await |
| 63 | } |
| 64 | |
| 65 | async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp { |
| 66 | self.send( |
| 67 | self.client.post(format!("{}{path}", self.base)).form(form), |
| 68 | cookie, |
| 69 | ) |
| 70 | .await |
| 71 | } |
| 72 | |
| 73 | async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp { |
| 74 | let req = match cookie { |
| 75 | Some(cookie) => req.header(COOKIE, cookie), |
| 76 | None => req, |
| 77 | }; |
| 78 | let res = req.send().await.expect("http request"); |
| 79 | let status = res.status().as_u16(); |
| 80 | let headers = res.headers().clone(); |
| 81 | let body = res.text().await.expect("read body"); |
| 82 | Resp { |
| 83 | status, |
| 84 | headers, |
| 85 | body, |
| 86 | } |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | /// `name=value` for the cookie whose name ends with `suffix`, ignoring cleared cookies. |
| 91 | fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> { |
| 92 | resp.set_cookies().iter().find_map(|set| { |
| 93 | let pair = set.split(';').next()?; |
| 94 | let (name, value) = pair.split_once('=')?; |
| 95 | (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}")) |
| 96 | }) |
| 97 | } |
| 98 | |
| 99 | /// The value of the first `<input name="...">` on a page. |
| 100 | fn input_value(body: &str, name: &str) -> String { |
| 101 | let doc = scraper::Html::parse_document(body); |
| 102 | let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector"); |
| 103 | doc.select(&selector) |
| 104 | .next() |
| 105 | .and_then(|el| el.value().attr("value")) |
| 106 | .unwrap_or_else(|| panic!("no input named {name} on page")) |
| 107 | .to_owned() |
| 108 | } |
| 109 | |
| 110 | /// Runs an admin subcommand with the password in FORGE_PASSWORD and asserts it succeeded. |
| 111 | fn admin(config: &Path, args: &[&str]) -> Output { |
| 112 | let out = Command::new(BIN) |
| 113 | .arg("--config") |
| 114 | .arg(config) |
| 115 | .arg("admin") |
| 116 | .args(args) |
| 117 | .env("FORGE_PASSWORD", PW) |
| 118 | .output() |
| 119 | .expect("run admin command"); |
| 120 | assert!( |
| 121 | out.status.success(), |
| 122 | "admin {args:?} failed:\n{}", |
| 123 | describe(&out) |
| 124 | ); |
| 125 | out |
| 126 | } |
| 127 | |
| 128 | async fn login(http: &Http, username: &str) -> String { |
| 129 | let page = http.get("/login", None).await; |
| 130 | assert_eq!(page.status, 200, "GET /login"); |
| 131 | let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie"); |
| 132 | let precsrf = input_value(&page.body, "precsrf"); |
| 133 | let res = http |
| 134 | .post_form( |
| 135 | "/login", |
| 136 | &[ |
| 137 | ("username", username), |
| 138 | ("password", PW), |
| 139 | ("next", "/"), |
| 140 | ("precsrf", &precsrf), |
| 141 | ], |
| 142 | Some(&precsrf_cookie), |
| 143 | ) |
| 144 | .await; |
| 145 | assert_eq!(res.status, 303, "login as {username}: {}", res.body); |
| 146 | cookie_named(&res, "forge_session").expect("session cookie after login") |
| 147 | } |
| 148 | |
| 149 | /// The session's CSRF token, read from a page every signed-in user can open. |
| 150 | async fn csrf_for(http: &Http, session: &str) -> String { |
| 151 | let page = http.get("/settings/tokens", Some(session)).await; |
| 152 | assert_eq!(page.status, 200, "GET /settings/tokens"); |
| 153 | input_value(&page.body, "csrf") |
| 154 | } |
| 155 | |
| 156 | /// Creates a bare git repo for `repo_id` at its data-dir location, as the create-repo page does. |
| 157 | fn init_bare(data_dir: &Path, repo_id: &str) { |
| 158 | let path = forge_git::repo::repo_path(data_dir, repo_id).expect("repo path"); |
| 159 | fs::create_dir_all(path.parent().expect("parent")).expect("create repo parent"); |
| 160 | let out = Command::new("git") |
| 161 | .args(["init", "--bare", "--quiet"]) |
| 162 | .arg(&path) |
| 163 | .output() |
| 164 | .expect("run git init"); |
| 165 | assert!(out.status.success(), "git init --bare: {}", describe(&out)); |
| 166 | } |
| 167 | |
| 168 | /// Directories in `data_dir/trash` for the repo `id` (lowercase, as the trash names them). |
| 169 | fn trash_entries(data_dir: &Path, repo_id: &str) -> Vec<PathBuf> { |
| 170 | let prefix = format!("{}-", repo_id.to_ascii_lowercase()); |
| 171 | let Ok(read) = fs::read_dir(data_dir.join(forge_git::repo::TRASH_DIR)) else { |
| 172 | return Vec::new(); |
| 173 | }; |
| 174 | read.filter_map(Result::ok) |
| 175 | .map(|e| e.path()) |
| 176 | .filter(|p| { |
| 177 | p.file_name() |
| 178 | .and_then(|n| n.to_str()) |
| 179 | .is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".git")) |
| 180 | }) |
| 181 | .collect() |
| 182 | } |
| 183 | |
| 184 | #[tokio::test] |
| 185 | async fn repo_settings_end_to_end() { |
| 186 | if !tool_on_path("git") { |
| 187 | eprintln!("SKIP repo_settings_e2e: `git` was not found on PATH"); |
| 188 | return; |
| 189 | } |
| 190 | |
| 191 | // Keep the path short: the data dir holds a unix socket with a length limit. |
| 192 | let root = tempfile::Builder::new() |
| 193 | .prefix("frs") |
| 194 | .tempdir_in("/tmp") |
| 195 | .expect("temp dir"); |
| 196 | let dir = root.path().to_path_buf(); |
| 197 | |
| 198 | let ports = pick_ports(); |
| 199 | let config = write_config(&dir, ports); |
| 200 | |
| 201 | // The first user is an admin. Bob owns the repos, carol is an admin collaborator on the private |
| 202 | // one, and alice (admin) must still be able to delete bob's repo. |
| 203 | admin(&config, &["create-user", "alice", "--admin"]); |
| 204 | admin(&config, &["create-user", "bob"]); |
| 205 | admin(&config, &["create-user", "carol"]); |
| 206 | |
| 207 | let (public_id, private_id) = { |
| 208 | let store = Store::open(&dir.join("forge.db")) |
| 209 | .await |
| 210 | .expect("open store"); |
| 211 | let bob = store.user_by_username("bob").await.unwrap().expect("bob"); |
| 212 | let carol = store |
| 213 | .user_by_username("carol") |
| 214 | .await |
| 215 | .unwrap() |
| 216 | .expect("carol"); |
| 217 | let public = store |
| 218 | .create_repo(&bob.id, "pub", "", Visibility::Public, "main") |
| 219 | .await |
| 220 | .expect("public repo"); |
| 221 | let private = store |
| 222 | .create_repo(&bob.id, "priv", "", Visibility::Private, "main") |
| 223 | .await |
| 224 | .expect("private repo"); |
| 225 | store |
| 226 | .set_collaborator(&private.id, &carol.id, Role::Admin) |
| 227 | .await |
| 228 | .expect("carol collaborator"); |
| 229 | init_bare(&dir, &public.id); |
| 230 | init_bare(&dir, &private.id); |
| 231 | (public.id, private.id) |
| 232 | }; |
| 233 | |
| 234 | let (server, ports) = launch(&dir, ports).await; |
| 235 | let http = Http::new(ports.http); |
| 236 | |
| 237 | let bob = login(&http, "bob").await; |
| 238 | let carol = login(&http, "carol").await; |
| 239 | let alice = login(&http, "alice").await; |
| 240 | |
| 241 | // Anonymous on a private repo: 404, the same as a missing repo. |
| 242 | let anon = http.get("/bob/priv/settings", None).await; |
| 243 | assert_eq!(anon.status, 404, "anonymous settings on private repo"); |
| 244 | |
| 245 | // A collaborator with the Admin role gets the settings page (branch rules) but no danger zone, |
| 246 | // and a POST to the delete form is 403. The repo stays. |
| 247 | let carol_page = http.get("/bob/priv/settings", Some(&carol)).await; |
| 248 | assert_eq!(carol_page.status, 200, "admin collaborator settings page"); |
| 249 | assert!( |
| 250 | !carol_page.body.contains("Danger zone"), |
| 251 | "admin collaborator must not see the danger zone" |
| 252 | ); |
| 253 | let carol_csrf = csrf_for(&http, &carol).await; |
| 254 | let carol_delete = http |
| 255 | .post_form( |
| 256 | "/bob/priv/settings/delete", |
| 257 | &[("csrf", &carol_csrf), ("confirm", "bob/priv")], |
| 258 | Some(&carol), |
| 259 | ) |
| 260 | .await; |
| 261 | assert_eq!(carol_delete.status, 403, "collaborator delete"); |
| 262 | assert_eq!(http.get("/bob/priv", Some(&carol)).await.status, 200); |
| 263 | |
| 264 | // The owner sees the danger zone, and the Settings tab appears on the repo header. |
| 265 | let owner_page = http.get("/bob/pub/settings", Some(&bob)).await; |
| 266 | assert_eq!(owner_page.status, 200, "owner settings page"); |
| 267 | assert!( |
| 268 | owner_page.body.contains("Danger zone"), |
| 269 | "route did not reach the settings page" |
| 270 | ); |
| 271 | assert!( |
| 272 | owner_page.body.contains("href=\"/bob/pub/settings\""), |
| 273 | "Settings tab missing" |
| 274 | ); |
| 275 | let collaborator_repo_page = http.get("/bob/priv", Some(&carol)).await; |
| 276 | assert!( |
| 277 | collaborator_repo_page |
| 278 | .body |
| 279 | .contains("href=\"/bob/priv/settings\""), |
| 280 | "Settings tab shows to an Admin collaborator" |
| 281 | ); |
| 282 | |
| 283 | // A wrong confirmation is an error and keeps the repo. |
| 284 | let owner_csrf = csrf_for(&http, &bob).await; |
| 285 | let wrong = http |
| 286 | .post_form( |
| 287 | "/bob/pub/settings/delete", |
| 288 | &[("csrf", &owner_csrf), ("confirm", "pub")], |
| 289 | Some(&bob), |
| 290 | ) |
| 291 | .await; |
| 292 | assert_eq!(wrong.status, 200, "wrong confirmation re-renders the page"); |
| 293 | assert!( |
| 294 | wrong.body.contains("exactly to confirm"), |
| 295 | "missing mismatch error" |
| 296 | ); |
| 297 | assert_eq!( |
| 298 | http.get("/bob/pub", Some(&bob)).await.status, |
| 299 | 200, |
| 300 | "repo kept" |
| 301 | ); |
| 302 | assert!( |
| 303 | trash_entries(&dir, &public_id).is_empty(), |
| 304 | "nothing trashed on mismatch" |
| 305 | ); |
| 306 | |
| 307 | // No CSRF token is rejected, even with the right confirmation. |
| 308 | let no_csrf = http |
| 309 | .post_form( |
| 310 | "/bob/pub/settings/delete", |
| 311 | &[("confirm", "bob/pub")], |
| 312 | Some(&bob), |
| 313 | ) |
| 314 | .await; |
| 315 | assert_eq!(no_csrf.status, 403, "missing CSRF token"); |
| 316 | assert_eq!( |
| 317 | http.get("/bob/pub", Some(&bob)).await.status, |
| 318 | 200, |
| 319 | "repo kept without CSRF" |
| 320 | ); |
| 321 | |
| 322 | // The owner deletes: redirect home with a notice, the repo is gone, the directory is trashed. |
| 323 | let deleted = http |
| 324 | .post_form( |
| 325 | "/bob/pub/settings/delete", |
| 326 | &[("csrf", &owner_csrf), ("confirm", "bob/pub")], |
| 327 | Some(&bob), |
| 328 | ) |
| 329 | .await; |
| 330 | assert_eq!(deleted.status, 303, "owner delete: {}", deleted.body); |
| 331 | assert_eq!(deleted.header(LOCATION).as_deref(), Some("/")); |
| 332 | let notice = cookie_named(&deleted, "forge_notice").expect("notice cookie"); |
| 333 | assert_eq!( |
| 334 | http.get("/bob/pub", Some(&bob)).await.status, |
| 335 | 404, |
| 336 | "repo page after delete" |
| 337 | ); |
| 338 | assert_eq!( |
| 339 | http.get("/bob/pub/settings", Some(&bob)).await.status, |
| 340 | 404, |
| 341 | "settings after delete" |
| 342 | ); |
| 343 | let trashed = trash_entries(&dir, &public_id); |
| 344 | assert_eq!(trashed.len(), 1, "one trash entry for the deleted repo"); |
| 345 | assert!( |
| 346 | trashed[0].join("HEAD").is_file(), |
| 347 | "trash entry is a bare repo" |
| 348 | ); |
| 349 | |
| 350 | // The next home page shows the notice once. |
| 351 | let home = http.get("/", Some(&format!("{bob}; {notice}"))).await; |
| 352 | assert!(home.body.contains("Repository"), "home page notice missing"); |
| 353 | assert!( |
| 354 | home.body.contains("bob/pub"), |
| 355 | "home page notice names the repo" |
| 356 | ); |
| 357 | |
| 358 | // An admin who does not own the repo can delete it. |
| 359 | let admin_page = http.get("/bob/priv/settings", Some(&alice)).await; |
| 360 | assert_eq!(admin_page.status, 200, "admin settings page"); |
| 361 | let admin_csrf = csrf_for(&http, &alice).await; |
| 362 | let admin_delete = http |
| 363 | .post_form( |
| 364 | "/bob/priv/settings/delete", |
| 365 | &[("csrf", &admin_csrf), ("confirm", "bob/priv")], |
| 366 | Some(&alice), |
| 367 | ) |
| 368 | .await; |
| 369 | assert_eq!(admin_delete.status, 303, "admin delete"); |
| 370 | assert_eq!( |
| 371 | http.get("/bob/priv", Some(&alice)).await.status, |
| 372 | 404, |
| 373 | "admin-deleted repo" |
| 374 | ); |
| 375 | assert_eq!( |
| 376 | trash_entries(&dir, &private_id).len(), |
| 377 | 1, |
| 378 | "admin delete is trashed" |
| 379 | ); |
| 380 | |
| 381 | // The admin CLI lists both trash entries, then purges them. |
| 382 | let listed = String::from_utf8_lossy(&admin(&config, &["trash-list"]).stdout).into_owned(); |
| 383 | assert!( |
| 384 | listed.contains(&public_id.to_ascii_lowercase()), |
| 385 | "trash-list: {listed}" |
| 386 | ); |
| 387 | assert!( |
| 388 | listed.contains(&private_id.to_ascii_lowercase()), |
| 389 | "trash-list: {listed}" |
| 390 | ); |
| 391 | let purged = admin(&config, &["trash-purge", "--older-than-days", "0"]); |
| 392 | assert!( |
| 393 | String::from_utf8_lossy(&purged.stdout).contains("purged 2 trashed repositories"), |
| 394 | "{}", |
| 395 | describe(&purged) |
| 396 | ); |
| 397 | assert!(trash_entries(&dir, &public_id).is_empty()); |
| 398 | assert!(trash_entries(&dir, &private_id).is_empty()); |
| 399 | drop(server); |
| 400 | } |
| 401 | |
| 402 | /// A repo whose bare directory was removed by hand can still be deleted from the UI: the DB rows |
| 403 | /// go, nothing is trashed, and the request succeeds. |
| 404 | #[tokio::test] |
| 405 | async fn repo_settings_deletes_rows_when_repo_dir_is_missing() { |
| 406 | if !tool_on_path("git") { |
| 407 | eprintln!("SKIP repo_settings_e2e: `git` was not found on PATH"); |
| 408 | return; |
| 409 | } |
| 410 | |
| 411 | let root = tempfile::Builder::new() |
| 412 | .prefix("frm") |
| 413 | .tempdir_in("/tmp") |
| 414 | .expect("temp dir"); |
| 415 | let dir = root.path().to_path_buf(); |
| 416 | let ports = pick_ports(); |
| 417 | let config = write_config(&dir, ports); |
| 418 | admin(&config, &["create-user", "bob"]); |
| 419 | |
| 420 | let (repo_id, bare_dir) = { |
| 421 | let store = Store::open(&dir.join("forge.db")) |
| 422 | .await |
| 423 | .expect("open store"); |
| 424 | let bob = store.user_by_username("bob").await.unwrap().expect("bob"); |
| 425 | let repo = store |
| 426 | .create_repo(&bob.id, "gone", "", Visibility::Public, "main") |
| 427 | .await |
| 428 | .expect("repo"); |
| 429 | init_bare(&dir, &repo.id); |
| 430 | let bare = forge_git::repo::repo_path(&dir, &repo.id).expect("repo path"); |
| 431 | // Simulate the broken state: the DB row exists but the bare directory is gone. |
| 432 | fs::remove_dir_all(&bare).expect("remove bare dir by hand"); |
| 433 | (repo.id, bare) |
| 434 | }; |
| 435 | |
| 436 | let (server, ports) = launch(&dir, ports).await; |
| 437 | let http = Http::new(ports.http); |
| 438 | let bob = login(&http, "bob").await; |
| 439 | let csrf = csrf_for(&http, &bob).await; |
| 440 | let deleted = http |
| 441 | .post_form( |
| 442 | "/bob/gone/settings/delete", |
| 443 | &[("csrf", &csrf), ("confirm", "bob/gone")], |
| 444 | Some(&bob), |
| 445 | ) |
| 446 | .await; |
| 447 | assert_eq!( |
| 448 | deleted.status, 303, |
| 449 | "delete with missing dir: {}", |
| 450 | deleted.body |
| 451 | ); |
| 452 | assert_eq!(deleted.header(LOCATION).as_deref(), Some("/")); |
| 453 | assert_eq!( |
| 454 | http.get("/bob/gone", Some(&bob)).await.status, |
| 455 | 404, |
| 456 | "repo page after delete" |
| 457 | ); |
| 458 | assert!(!bare_dir.exists(), "missing dir must not be recreated"); |
| 459 | assert!( |
| 460 | trash_entries(&dir, &repo_id).is_empty(), |
| 461 | "nothing to trash when the dir is missing" |
| 462 | ); |
| 463 | // Stop the server first: the database file is locked while it runs. |
| 464 | drop(server); |
| 465 | let store = Store::open(&dir.join("forge.db")) |
| 466 | .await |
| 467 | .expect("open store"); |
| 468 | assert!( |
| 469 | store.repo_by_id(&repo_id).await.unwrap().is_none(), |
| 470 | "DB row removed" |
| 471 | ); |
| 472 | let log = fs::read_to_string(dir.join("serve.log")).unwrap_or_default(); |
| 473 | assert!( |
| 474 | log.contains("repo dir missing, deleting DB rows only") && log.contains(&repo_id), |
| 475 | "missing warning in log:\n{log}" |
| 476 | ); |
| 477 | } |
| 478 | |
| 479 | /// Rows of the branch rules table as their cell texts, in page order. The built-in default-branch |
| 480 | /// row is a single cell with a `colspan`, so it appears as a one-cell row. |
| 481 | fn branch_rows(body: &str) -> Vec<Vec<String>> { |
| 482 | let doc = scraper::Html::parse_document(body); |
| 483 | let rows = scraper::Selector::parse("table.branch-rules tbody tr").expect("selector"); |
| 484 | let cells = scraper::Selector::parse("td").expect("selector"); |
| 485 | doc.select(&rows) |
| 486 | .map(|row| { |
| 487 | row.select(&cells) |
| 488 | .map(|cell| cell.text().collect::<String>().trim().to_owned()) |
| 489 | .collect() |
| 490 | }) |
| 491 | .collect() |
| 492 | } |
| 493 | |
| 494 | /// The cells of the rule row for `pattern`, if the table has one. |
| 495 | fn rule_row(body: &str, pattern: &str) -> Option<Vec<String>> { |
| 496 | branch_rows(body) |
| 497 | .into_iter() |
| 498 | .find(|cells| cells.first().is_some_and(|c| c == pattern) && cells.len() == 5) |
| 499 | } |
| 500 | |
| 501 | /// Owner adds, updates, invalidates and removes branch rules. A Write collaborator and an Admin |
| 502 | /// collaborator get the right access: the Admin can manage rules but sees no danger zone. |
| 503 | #[tokio::test] |
| 504 | async fn branch_protection_end_to_end() { |
| 505 | if !tool_on_path("git") { |
| 506 | eprintln!("SKIP repo_settings_e2e: `git` was not found on PATH"); |
| 507 | return; |
| 508 | } |
| 509 | |
| 510 | let root = tempfile::Builder::new() |
| 511 | .prefix("frb") |
| 512 | .tempdir_in("/tmp") |
| 513 | .expect("temp dir"); |
| 514 | let dir = root.path().to_path_buf(); |
| 515 | let ports = pick_ports(); |
| 516 | let config = write_config(&dir, ports); |
| 517 | |
| 518 | // The first user is an instance admin, so bob (the owner) is an ordinary user. |
| 519 | admin(&config, &["create-user", "alice", "--admin"]); |
| 520 | admin(&config, &["create-user", "bob"]); |
| 521 | admin(&config, &["create-user", "carol"]); |
| 522 | admin(&config, &["create-user", "dan"]); |
| 523 | |
| 524 | { |
| 525 | let store = Store::open(&dir.join("forge.db")) |
| 526 | .await |
| 527 | .expect("open store"); |
| 528 | let bob = store.user_by_username("bob").await.unwrap().expect("bob"); |
| 529 | let carol = store |
| 530 | .user_by_username("carol") |
| 531 | .await |
| 532 | .unwrap() |
| 533 | .expect("carol"); |
| 534 | let dan = store.user_by_username("dan").await.unwrap().expect("dan"); |
| 535 | let app = store |
| 536 | .create_repo(&bob.id, "app", "", Visibility::Public, "main") |
| 537 | .await |
| 538 | .expect("repo"); |
| 539 | store |
| 540 | .set_collaborator(&app.id, &carol.id, Role::Admin) |
| 541 | .await |
| 542 | .expect("carol collaborator"); |
| 543 | store |
| 544 | .set_collaborator(&app.id, &dan.id, Role::Write) |
| 545 | .await |
| 546 | .expect("dan collaborator"); |
| 547 | init_bare(&dir, &app.id); |
| 548 | } |
| 549 | |
| 550 | let (server, ports) = launch(&dir, ports).await; |
| 551 | let http = Http::new(ports.http); |
| 552 | let bob = login(&http, "bob").await; |
| 553 | let carol = login(&http, "carol").await; |
| 554 | let dan = login(&http, "dan").await; |
| 555 | |
| 556 | // Owner: the built-in row is there before any rule, and the danger zone is shown. |
| 557 | let page = http.get("/bob/app/settings", Some(&bob)).await; |
| 558 | assert_eq!(page.status, 200, "owner settings page"); |
| 559 | assert!(page.body.contains("Branch protection"), "section missing"); |
| 560 | assert!( |
| 561 | page.body.contains("(default branch): protected by default"), |
| 562 | "built-in row missing" |
| 563 | ); |
| 564 | assert!( |
| 565 | page.body.contains("Danger zone"), |
| 566 | "owner sees the danger zone" |
| 567 | ); |
| 568 | assert_eq!( |
| 569 | input_value(&page.body, "pattern"), |
| 570 | "main", |
| 571 | "pattern is prefilled with the default branch" |
| 572 | ); |
| 573 | let owner_csrf = csrf_for(&http, &bob).await; |
| 574 | |
| 575 | // Adding main with force push allowed: it appears, and the built-in row disappears. |
| 576 | let added = http |
| 577 | .post_form( |
| 578 | "/bob/app/settings/branches", |
| 579 | &[ |
| 580 | ("csrf", &owner_csrf), |
| 581 | ("pattern", "main"), |
| 582 | ("allow_force_push", "on"), |
| 583 | ], |
| 584 | Some(&bob), |
| 585 | ) |
| 586 | .await; |
| 587 | assert_eq!(added.status, 303, "add rule: {}", added.body); |
| 588 | assert_eq!(added.header(LOCATION).as_deref(), Some("/bob/app/settings")); |
| 589 | let notice = cookie_named(&added, "forge_settings_notice").expect("settings notice cookie"); |
| 590 | let page = http |
| 591 | .get("/bob/app/settings", Some(&format!("{bob}; {notice}"))) |
| 592 | .await; |
| 593 | assert!( |
| 594 | page.body.contains("Branch rule saved."), |
| 595 | "save notice missing" |
| 596 | ); |
| 597 | assert_eq!( |
| 598 | rule_row(&page.body, "main"), |
| 599 | Some(vec![ |
| 600 | "main".into(), |
| 601 | "yes".into(), |
| 602 | "no".into(), |
| 603 | "none".into(), |
| 604 | "Remove".into() |
| 605 | ]), |
| 606 | "main row after add" |
| 607 | ); |
| 608 | assert!( |
| 609 | !page.body.contains("(default branch): protected by default"), |
| 610 | "built-in row must disappear once a rule matches" |
| 611 | ); |
| 612 | |
| 613 | // Updating the same pattern toggles the value and does not add a second row. |
| 614 | let updated = http |
| 615 | .post_form( |
| 616 | "/bob/app/settings/branches", |
| 617 | &[("csrf", &owner_csrf), ("pattern", "main")], |
| 618 | Some(&bob), |
| 619 | ) |
| 620 | .await; |
| 621 | assert_eq!(updated.status, 303, "update rule: {}", updated.body); |
| 622 | let page = http.get("/bob/app/settings", Some(&bob)).await; |
| 623 | assert_eq!( |
| 624 | rule_row(&page.body, "main"), |
| 625 | Some(vec![ |
| 626 | "main".into(), |
| 627 | "no".into(), |
| 628 | "no".into(), |
| 629 | "none".into(), |
| 630 | "Remove".into() |
| 631 | ]), |
| 632 | "main row after update" |
| 633 | ); |
| 634 | assert_eq!( |
| 635 | branch_rows(&page.body).len(), |
| 636 | 1, |
| 637 | "update must not add a row" |
| 638 | ); |
| 639 | |
| 640 | // An invalid pattern is refused inline and nothing is saved. |
| 641 | let invalid = http |
| 642 | .post_form( |
| 643 | "/bob/app/settings/branches", |
| 644 | &[("csrf", &owner_csrf), ("pattern", "bad..name")], |
| 645 | Some(&bob), |
| 646 | ) |
| 647 | .await; |
| 648 | assert_eq!(invalid.status, 200, "invalid pattern re-renders the page"); |
| 649 | assert!( |
| 650 | invalid.body.contains("role=\"alert\""), |
| 651 | "inline error missing" |
| 652 | ); |
| 653 | assert!( |
| 654 | invalid.body.contains("branch pattern"), |
| 655 | "error text missing" |
| 656 | ); |
| 657 | assert!( |
| 658 | invalid.body.contains("value=\"bad..name\""), |
| 659 | "the entered pattern is kept in the form" |
| 660 | ); |
| 661 | let page = http.get("/bob/app/settings", Some(&bob)).await; |
| 662 | assert!( |
| 663 | rule_row(&page.body, "bad..name").is_none(), |
| 664 | "invalid rule must not be saved" |
| 665 | ); |
| 666 | |
| 667 | // No CSRF token is rejected. |
| 668 | let no_csrf = http |
| 669 | .post_form( |
| 670 | "/bob/app/settings/branches", |
| 671 | &[("pattern", "release/*"), ("allow_deletion", "on")], |
| 672 | Some(&bob), |
| 673 | ) |
| 674 | .await; |
| 675 | assert_eq!(no_csrf.status, 403, "missing CSRF on save"); |
| 676 | let no_csrf_delete = http |
| 677 | .post_form( |
| 678 | "/bob/app/settings/branches/delete", |
| 679 | &[("rule_id", "x")], |
| 680 | Some(&bob), |
| 681 | ) |
| 682 | .await; |
| 683 | assert_eq!(no_csrf_delete.status, 403, "missing CSRF on remove"); |
| 684 | let page = http.get("/bob/app/settings", Some(&bob)).await; |
| 685 | assert!( |
| 686 | rule_row(&page.body, "release/*").is_none(), |
| 687 | "rule saved without CSRF" |
| 688 | ); |
| 689 | assert!( |
| 690 | rule_row(&page.body, "main").is_some(), |
| 691 | "main kept without CSRF" |
| 692 | ); |
| 693 | |
| 694 | // Remove works: the row goes and the built-in row comes back. |
| 695 | let rule_id = input_value(&page.body, "rule_id"); |
| 696 | let removed = http |
| 697 | .post_form( |
| 698 | "/bob/app/settings/branches/delete", |
| 699 | &[("csrf", &owner_csrf), ("rule_id", &rule_id)], |
| 700 | Some(&bob), |
| 701 | ) |
| 702 | .await; |
| 703 | assert_eq!(removed.status, 303, "remove rule: {}", removed.body); |
| 704 | let page = http.get("/bob/app/settings", Some(&bob)).await; |
| 705 | assert!(rule_row(&page.body, "main").is_none(), "main removed"); |
| 706 | assert!( |
| 707 | page.body.contains("(default branch): protected by default"), |
| 708 | "built-in row back after remove" |
| 709 | ); |
| 710 | |
| 711 | // A Write collaborator cannot open the settings or post rules. |
| 712 | let dan_page = http.get("/bob/app/settings", Some(&dan)).await; |
| 713 | assert_eq!(dan_page.status, 403, "write collaborator settings page"); |
| 714 | let dan_csrf = csrf_for(&http, &dan).await; |
| 715 | let dan_save = http |
| 716 | .post_form( |
| 717 | "/bob/app/settings/branches", |
| 718 | &[("csrf", &dan_csrf), ("pattern", "main")], |
| 719 | Some(&dan), |
| 720 | ) |
| 721 | .await; |
| 722 | assert_eq!(dan_save.status, 403, "write collaborator save"); |
| 723 | assert!( |
| 724 | http.get("/bob/app/settings", Some(&bob)) |
| 725 | .await |
| 726 | .body |
| 727 | .contains("(default branch): protected by default"), |
| 728 | "write collaborator must not change rules" |
| 729 | ); |
| 730 | |
| 731 | // An Admin collaborator manages rules, sees no danger zone and cannot delete the repo. |
| 732 | let carol_page = http.get("/bob/app/settings", Some(&carol)).await; |
| 733 | assert_eq!(carol_page.status, 200, "admin collaborator settings page"); |
| 734 | assert!(carol_page.body.contains("Branch protection")); |
| 735 | assert!( |
| 736 | !carol_page.body.contains("Danger zone"), |
| 737 | "admin collaborator must not see the danger zone" |
| 738 | ); |
| 739 | assert!( |
| 740 | http.get("/bob/app", Some(&carol)) |
| 741 | .await |
| 742 | .body |
| 743 | .contains("href=\"/bob/app/settings\""), |
| 744 | "Settings tab shows for an Admin collaborator" |
| 745 | ); |
| 746 | let carol_csrf = csrf_for(&http, &carol).await; |
| 747 | let carol_save = http |
| 748 | .post_form( |
| 749 | "/bob/app/settings/branches", |
| 750 | &[ |
| 751 | ("csrf", &carol_csrf), |
| 752 | ("pattern", "release/*"), |
| 753 | ("allow_force_push", "on"), |
| 754 | ], |
| 755 | Some(&carol), |
| 756 | ) |
| 757 | .await; |
| 758 | assert_eq!(carol_save.status, 303, "admin collaborator save"); |
| 759 | let page = http.get("/bob/app/settings", Some(&carol)).await; |
| 760 | assert_eq!( |
| 761 | rule_row(&page.body, "release/*"), |
| 762 | Some(vec![ |
| 763 | "release/*".into(), |
| 764 | "yes".into(), |
| 765 | "no".into(), |
| 766 | "none".into(), |
| 767 | "Remove".into() |
| 768 | ]), |
| 769 | "admin collaborator's rule" |
| 770 | ); |
| 771 | let carol_delete_rule = http |
| 772 | .post_form( |
| 773 | "/bob/app/settings/branches/delete", |
| 774 | &[ |
| 775 | ("csrf", &carol_csrf), |
| 776 | ("rule_id", &input_value(&page.body, "rule_id")), |
| 777 | ], |
| 778 | Some(&carol), |
| 779 | ) |
| 780 | .await; |
| 781 | assert_eq!(carol_delete_rule.status, 303, "admin collaborator remove"); |
| 782 | assert!( |
| 783 | rule_row( |
| 784 | &http.get("/bob/app/settings", Some(&bob)).await.body, |
| 785 | "release/*" |
| 786 | ) |
| 787 | .is_none(), |
| 788 | "admin collaborator removed the rule" |
| 789 | ); |
| 790 | let carol_delete_repo = http |
| 791 | .post_form( |
| 792 | "/bob/app/settings/delete", |
| 793 | &[("csrf", &carol_csrf), ("confirm", "bob/app")], |
| 794 | Some(&carol), |
| 795 | ) |
| 796 | .await; |
| 797 | assert_eq!( |
| 798 | carol_delete_repo.status, 403, |
| 799 | "admin collaborator repo delete" |
| 800 | ); |
| 801 | assert_eq!( |
| 802 | http.get("/bob/app", Some(&bob)).await.status, |
| 803 | 200, |
| 804 | "repo kept after the admin collaborator's delete" |
| 805 | ); |
| 806 | drop(server); |
| 807 | } |
| 808 | |
| 809 | /// Creates commits in a scratch work tree and pushes them as `branches` into the bare repo. |
| 810 | fn seed_branches(dir: &Path, repo_id: &str, branches: &[&str]) { |
| 811 | let bare = forge_git::repo::repo_path(dir, repo_id).expect("repo path"); |
| 812 | let work = dir.join(format!("seed-{repo_id}")); |
| 813 | fs::create_dir_all(&work).expect("seed work dir"); |
| 814 | let run = |args: &[&str]| { |
| 815 | let out = Command::new("git") |
| 816 | .arg("-C") |
| 817 | .arg(&work) |
| 818 | .args([ |
| 819 | "-c", |
| 820 | "user.name=seed", |
| 821 | "-c", |
| 822 | "user.email=seed@example.invalid", |
| 823 | ]) |
| 824 | .args(["-c", "commit.gpgsign=false"]) |
| 825 | .args(args) |
| 826 | .output() |
| 827 | .expect("run git"); |
| 828 | assert!(out.status.success(), "git {args:?}: {}", describe(&out)); |
| 829 | }; |
| 830 | run(&["init", "--quiet"]); |
| 831 | run(&["commit", "--quiet", "--allow-empty", "-m", "seed"]); |
| 832 | let refspecs: Vec<String> = branches |
| 833 | .iter() |
| 834 | .map(|b| format!("HEAD:refs/heads/{b}")) |
| 835 | .collect(); |
| 836 | let mut push = vec!["push", "--quiet"]; |
| 837 | let bare_str = bare.to_str().expect("utf-8 path"); |
| 838 | push.push(bare_str); |
| 839 | push.extend(refspecs.iter().map(String::as_str)); |
| 840 | run(&push); |
| 841 | } |
| 842 | |
| 843 | /// The symbolic target of HEAD in the bare repo, e.g. `refs/heads/dev`. |
| 844 | fn git_head(data_dir: &Path, repo_id: &str) -> String { |
| 845 | let bare = forge_git::repo::repo_path(data_dir, repo_id).expect("repo path"); |
| 846 | let out = Command::new("git") |
| 847 | .arg("--git-dir") |
| 848 | .arg(&bare) |
| 849 | .args(["symbolic-ref", "HEAD"]) |
| 850 | .output() |
| 851 | .expect("run git symbolic-ref"); |
| 852 | assert!(out.status.success(), "symbolic-ref: {}", describe(&out)); |
| 853 | String::from_utf8_lossy(&out.stdout).trim().to_owned() |
| 854 | } |
| 855 | |
| 856 | /// The value of the `selected` option of the default-branch select, if the page has one. |
| 857 | fn selected_branch(body: &str) -> Option<String> { |
| 858 | let doc = scraper::Html::parse_document(body); |
| 859 | let selector = |
| 860 | scraper::Selector::parse("select[name=\"default_branch\"] option[selected]").expect("sel"); |
| 861 | doc.select(&selector) |
| 862 | .next() |
| 863 | .and_then(|el| el.value().attr("value")) |
| 864 | .map(str::to_owned) |
| 865 | } |
| 866 | |
| 867 | /// Cookie header with the session plus a one-shot settings notice set by the previous response. |
| 868 | fn with_notice(session: &str, res: &Resp) -> String { |
| 869 | match cookie_named(res, "forge_settings_notice") { |
| 870 | Some(notice) => format!("{session}; {notice}"), |
| 871 | None => session.to_owned(), |
| 872 | } |
| 873 | } |
| 874 | |
| 875 | #[tokio::test] |
| 876 | async fn general_settings_end_to_end() { |
| 877 | if !tool_on_path("git") { |
| 878 | eprintln!("SKIP general_settings_end_to_end: `git` was not found on PATH"); |
| 879 | return; |
| 880 | } |
| 881 | |
| 882 | let root = tempfile::Builder::new() |
| 883 | .prefix("fgen") |
| 884 | .tempdir_in("/tmp") |
| 885 | .expect("temp dir"); |
| 886 | let dir = root.path().to_path_buf(); |
| 887 | let ports = pick_ports(); |
| 888 | let config = write_config(&dir, ports); |
| 889 | |
| 890 | admin(&config, &["create-user", "alice", "--admin"]); |
| 891 | admin(&config, &["create-user", "bob"]); |
| 892 | admin(&config, &["create-user", "carol"]); |
| 893 | |
| 894 | let gen_id = { |
| 895 | let store = Store::open(&dir.join("forge.db")) |
| 896 | .await |
| 897 | .expect("open store"); |
| 898 | let bob = store.user_by_username("bob").await.unwrap().expect("bob"); |
| 899 | let carol = store |
| 900 | .user_by_username("carol") |
| 901 | .await |
| 902 | .unwrap() |
| 903 | .expect("carol"); |
| 904 | let gen_repo = store |
| 905 | .create_repo(&bob.id, "gen", "", Visibility::Public, "main") |
| 906 | .await |
| 907 | .expect("gen repo"); |
| 908 | let empty = store |
| 909 | .create_repo(&bob.id, "empty", "", Visibility::Public, "main") |
| 910 | .await |
| 911 | .expect("empty repo"); |
| 912 | store |
| 913 | .set_collaborator(&gen_repo.id, &carol.id, Role::Write) |
| 914 | .await |
| 915 | .expect("carol write collaborator"); |
| 916 | init_bare(&dir, &gen_repo.id); |
| 917 | init_bare(&dir, &empty.id); |
| 918 | seed_branches(&dir, &gen_repo.id, &["main", "dev"]); |
| 919 | gen_repo.id |
| 920 | }; |
| 921 | |
| 922 | let (server, ports) = launch(&dir, ports).await; |
| 923 | let http = Http::new(ports.http); |
| 924 | let bob = login(&http, "bob").await; |
| 925 | let carol = login(&http, "carol").await; |
| 926 | |
| 927 | // The General section is the first card on the page, with the sub-navigation above it. |
| 928 | let page = http.get("/bob/gen/settings", Some(&bob)).await; |
| 929 | assert_eq!(page.status, 200, "owner settings page"); |
| 930 | assert!( |
| 931 | page.body.contains("Description"), |
| 932 | "description field missing" |
| 933 | ); |
| 934 | assert_eq!(selected_branch(&page.body).as_deref(), Some("main")); |
| 935 | assert!( |
| 936 | page.body |
| 937 | .contains("href=\"/bob/gen/settings/collaborators\"") |
| 938 | ); |
| 939 | assert!(page.body.contains("href=\"/bob/gen/settings/rename\"")); |
| 940 | |
| 941 | // Description: control characters are stripped and it shows on the repo header. |
| 942 | let csrf = csrf_for(&http, &bob).await; |
| 943 | let saved = http |
| 944 | .post_form( |
| 945 | "/bob/gen/settings/general", |
| 946 | &[ |
| 947 | ("csrf", &csrf), |
| 948 | ("description", "Hello\u{7} forge "), |
| 949 | ("default_branch", "main"), |
| 950 | ], |
| 951 | Some(&bob), |
| 952 | ) |
| 953 | .await; |
| 954 | assert_eq!(saved.status, 303, "general save: {}", saved.body); |
| 955 | let home = http.get("/bob/gen", Some(&bob)).await; |
| 956 | assert!( |
| 957 | home.body.contains("Hello forge"), |
| 958 | "description not on header" |
| 959 | ); |
| 960 | |
| 961 | // Default branch: an existing branch updates the DB value and git HEAD. |
| 962 | let switched = http |
| 963 | .post_form( |
| 964 | "/bob/gen/settings/general", |
| 965 | &[ |
| 966 | ("csrf", &csrf), |
| 967 | ("description", "Hello forge"), |
| 968 | ("default_branch", "dev"), |
| 969 | ], |
| 970 | Some(&bob), |
| 971 | ) |
| 972 | .await; |
| 973 | assert_eq!(switched.status, 303, "branch switch: {}", switched.body); |
| 974 | assert_eq!(git_head(&dir, &gen_id), "refs/heads/dev"); |
| 975 | let after = http.get("/bob/gen/settings", Some(&bob)).await; |
| 976 | assert_eq!(selected_branch(&after.body).as_deref(), Some("dev")); |
| 977 | |
| 978 | // A nonexistent branch is rejected: HEAD and the DB keep dev. |
| 979 | let missing = http |
| 980 | .post_form( |
| 981 | "/bob/gen/settings/general", |
| 982 | &[ |
| 983 | ("csrf", &csrf), |
| 984 | ("description", "Hello forge"), |
| 985 | ("default_branch", "nope"), |
| 986 | ], |
| 987 | Some(&bob), |
| 988 | ) |
| 989 | .await; |
| 990 | assert_eq!(missing.status, 303); |
| 991 | let notice_page = http |
| 992 | .get("/bob/gen/settings", Some(&with_notice(&bob, &missing))) |
| 993 | .await; |
| 994 | assert!( |
| 995 | notice_page.body.contains("That branch does not exist"), |
| 996 | "missing branch notice" |
| 997 | ); |
| 998 | assert_eq!(git_head(&dir, &gen_id), "refs/heads/dev"); |
| 999 | assert_eq!( |
| 1000 | selected_branch(&http.get("/bob/gen/settings", Some(&bob)).await.body).as_deref(), |
| 1001 | Some("dev") |
| 1002 | ); |
| 1003 | |
| 1004 | // Over-long description is rejected. |
| 1005 | let long = "x".repeat(351); |
| 1006 | let too_long = http |
| 1007 | .post_form( |
| 1008 | "/bob/gen/settings/general", |
| 1009 | &[ |
| 1010 | ("csrf", &csrf), |
| 1011 | ("description", &long), |
| 1012 | ("default_branch", "dev"), |
| 1013 | ], |
| 1014 | Some(&bob), |
| 1015 | ) |
| 1016 | .await; |
| 1017 | assert_eq!(too_long.status, 303); |
| 1018 | let long_page = http |
| 1019 | .get("/bob/gen/settings", Some(&with_notice(&bob, &too_long))) |
| 1020 | .await; |
| 1021 | assert!(long_page.body.contains("at most 350 characters")); |
| 1022 | |
| 1023 | // Visibility: changing to private needs "I understand"; then anonymous gets 404. |
| 1024 | let vis_csrf = csrf_for(&http, &bob).await; |
| 1025 | let no_ack = http |
| 1026 | .post_form( |
| 1027 | "/bob/gen/settings/visibility", |
| 1028 | &[("csrf", &vis_csrf), ("visibility", "private")], |
| 1029 | Some(&bob), |
| 1030 | ) |
| 1031 | .await; |
| 1032 | assert_eq!(no_ack.status, 303); |
| 1033 | assert_eq!( |
| 1034 | http.get("/bob/gen", None).await.status, |
| 1035 | 200, |
| 1036 | "still public without the confirmation" |
| 1037 | ); |
| 1038 | let private = http |
| 1039 | .post_form( |
| 1040 | "/bob/gen/settings/visibility", |
| 1041 | &[ |
| 1042 | ("csrf", &vis_csrf), |
| 1043 | ("visibility", "private"), |
| 1044 | ("understand", "on"), |
| 1045 | ], |
| 1046 | Some(&bob), |
| 1047 | ) |
| 1048 | .await; |
| 1049 | assert_eq!(private.status, 303); |
| 1050 | assert_eq!( |
| 1051 | http.get("/bob/gen", None).await.status, |
| 1052 | 404, |
| 1053 | "anonymous on private" |
| 1054 | ); |
| 1055 | assert_eq!( |
| 1056 | http.get("/bob/gen", Some(&bob)).await.status, |
| 1057 | 200, |
| 1058 | "owner on private" |
| 1059 | ); |
| 1060 | let public_again = http |
| 1061 | .post_form( |
| 1062 | "/bob/gen/settings/visibility", |
| 1063 | &[ |
| 1064 | ("csrf", &vis_csrf), |
| 1065 | ("visibility", "public"), |
| 1066 | ("understand", "on"), |
| 1067 | ], |
| 1068 | Some(&bob), |
| 1069 | ) |
| 1070 | .await; |
| 1071 | assert_eq!(public_again.status, 303); |
| 1072 | assert_eq!( |
| 1073 | http.get("/bob/gen", None).await.status, |
| 1074 | 200, |
| 1075 | "anonymous on public" |
| 1076 | ); |
| 1077 | |
| 1078 | // Archive, then unarchive. The banner shows only while archived. |
| 1079 | let arch_csrf = csrf_for(&http, &bob).await; |
| 1080 | let archive = http |
| 1081 | .post_form( |
| 1082 | "/bob/gen/settings/archive", |
| 1083 | &[("csrf", &arch_csrf), ("archived", "1")], |
| 1084 | Some(&bob), |
| 1085 | ) |
| 1086 | .await; |
| 1087 | assert_eq!(archive.status, 303); |
| 1088 | assert!( |
| 1089 | http.get("/bob/gen", Some(&bob)) |
| 1090 | .await |
| 1091 | .body |
| 1092 | .contains("This repository is archived"), |
| 1093 | "archived banner" |
| 1094 | ); |
| 1095 | let unarchive = http |
| 1096 | .post_form( |
| 1097 | "/bob/gen/settings/archive", |
| 1098 | &[("csrf", &arch_csrf), ("archived", "0")], |
| 1099 | Some(&bob), |
| 1100 | ) |
| 1101 | .await; |
| 1102 | assert_eq!(unarchive.status, 303); |
| 1103 | assert!( |
| 1104 | !http |
| 1105 | .get("/bob/gen", Some(&bob)) |
| 1106 | .await |
| 1107 | .body |
| 1108 | .contains("This repository is archived"), |
| 1109 | "banner after unarchive" |
| 1110 | ); |
| 1111 | |
| 1112 | // A Write collaborator cannot reach the settings: GET and every POST are 403. |
| 1113 | let carol_page = http.get("/bob/gen/settings", Some(&carol)).await; |
| 1114 | assert_eq!(carol_page.status, 403, "write collaborator settings page"); |
| 1115 | let carol_csrf = csrf_for(&http, &carol).await; |
| 1116 | let carol_general = http |
| 1117 | .post_form( |
| 1118 | "/bob/gen/settings/general", |
| 1119 | &[ |
| 1120 | ("csrf", &carol_csrf), |
| 1121 | ("description", "pwned"), |
| 1122 | ("default_branch", "main"), |
| 1123 | ], |
| 1124 | Some(&carol), |
| 1125 | ) |
| 1126 | .await; |
| 1127 | assert_eq!(carol_general.status, 403, "write collaborator general save"); |
| 1128 | let carol_archive = http |
| 1129 | .post_form( |
| 1130 | "/bob/gen/settings/archive", |
| 1131 | &[("csrf", &carol_csrf), ("archived", "1")], |
| 1132 | Some(&carol), |
| 1133 | ) |
| 1134 | .await; |
| 1135 | assert_eq!(carol_archive.status, 403, "write collaborator archive"); |
| 1136 | assert_eq!( |
| 1137 | git_head(&dir, &gen_id), |
| 1138 | "refs/heads/dev", |
| 1139 | "HEAD untouched by carol" |
| 1140 | ); |
| 1141 | |
| 1142 | // No CSRF token, or a wrong one, is rejected on every General POST. |
| 1143 | let no_csrf = http |
| 1144 | .post_form( |
| 1145 | "/bob/gen/settings/general", |
| 1146 | &[("description", "no token"), ("default_branch", "main")], |
| 1147 | Some(&bob), |
| 1148 | ) |
| 1149 | .await; |
| 1150 | assert_eq!(no_csrf.status, 403, "general without csrf"); |
| 1151 | let bad_csrf = http |
| 1152 | .post_form( |
| 1153 | "/bob/gen/settings/visibility", |
| 1154 | &[ |
| 1155 | ("csrf", "forged"), |
| 1156 | ("visibility", "private"), |
| 1157 | ("understand", "on"), |
| 1158 | ], |
| 1159 | Some(&bob), |
| 1160 | ) |
| 1161 | .await; |
| 1162 | assert_eq!(bad_csrf.status, 403, "visibility with a wrong csrf"); |
| 1163 | assert_eq!( |
| 1164 | http.get("/bob/gen", None).await.status, |
| 1165 | 200, |
| 1166 | "visibility unchanged" |
| 1167 | ); |
| 1168 | |
| 1169 | // An empty repo says so and keeps the current value. |
| 1170 | let empty_page = http.get("/bob/empty/settings", Some(&bob)).await; |
| 1171 | assert!( |
| 1172 | empty_page.body.contains("No branches yet"), |
| 1173 | "empty repo notice" |
| 1174 | ); |
| 1175 | assert!(selected_branch(&empty_page.body).is_none()); |
| 1176 | let empty_csrf = csrf_for(&http, &bob).await; |
| 1177 | let empty_save = http |
| 1178 | .post_form( |
| 1179 | "/bob/empty/settings/general", |
| 1180 | &[("csrf", &empty_csrf), ("description", "Fresh repo")], |
| 1181 | Some(&bob), |
| 1182 | ) |
| 1183 | .await; |
| 1184 | assert_eq!(empty_save.status, 303); |
| 1185 | assert!( |
| 1186 | http.get("/bob/empty", Some(&bob)) |
| 1187 | .await |
| 1188 | .body |
| 1189 | .contains("Fresh repo"), |
| 1190 | "empty repo description saved" |
| 1191 | ); |
| 1192 | drop(server); |
| 1193 | } |