| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | use forge_core::protection; |
| 13 | use forge_core::{Action, IssueState, PullRequest, User, authorize}; |
| 14 | use forge_git::browse::{default_branch_commit, read_commit}; |
| 15 | use forge_git::pulls::{delete_ref_cas, update_ref_cas}; |
| 16 | use forge_git::repo::repo_path; |
| 17 | use serde::Deserialize; |
| 18 | use serde_json::json; |
| 19 | use topcoat::Result; |
| 20 | use topcoat::context::{Cx, app_context}; |
| 21 | use topcoat::router::content::Form; |
| 22 | use topcoat::router::error::{forbidden, not_found, see_other}; |
| 23 | use topcoat::router::request::uri; |
| 24 | use topcoat::router::response::Response; |
| 25 | use topcoat::router::{path_param, route}; |
| 26 | use topcoat::view::{View, component, view}; |
| 27 | |
| 28 | use crate::auth::require_csrf; |
| 29 | use crate::i18n::Lang; |
| 30 | use crate::i18n::pulls::{self as g, K}; |
| 31 | use crate::pages::issue_parts::parse_number; |
| 32 | use crate::pages::repo_ctx::{RepoCtx, load_repo}; |
| 33 | use crate::post_update::after_ref_update; |
| 34 | use crate::state::AppState; |
| 35 | use crate::views::repo_href; |
| 36 | |
| 37 | path_param!(owner); |
| 38 | path_param!(repo); |
| 39 | path_param!(number); |
| 40 | |
| 41 | const ZERO_OID: &str = "0000000000000000000000000000000000000000"; |
| 42 | |
| 43 | |
| 44 | |
| 45 | pub const ERR_PROTECTED: &str = "protected"; |
| 46 | pub const ERR_DEFAULT_BRANCH: &str = "default_branch"; |
| 47 | pub const ERR_MOVED: &str = "moved"; |
| 48 | pub const ERR_MISSING: &str = "missing"; |
| 49 | pub const ERR_NOT_ALLOWED: &str = "not_allowed"; |
| 50 | |
| 51 | #[derive(Deserialize)] |
| 52 | struct BranchForm { |
| 53 | #[serde(default)] |
| 54 | csrf: String, |
| 55 | } |
| 56 | |
| 57 | #[derive(Clone, Copy)] |
| 58 | enum Op { |
| 59 | Delete, |
| 60 | Restore, |
| 61 | } |
| 62 | |
| 63 | |
| 64 | pub struct BranchAction { |
| 65 | pub restore: bool, |
| 66 | pub branch: String, |
| 67 | pub action: String, |
| 68 | pub csrf: String, |
| 69 | } |
| 70 | |
| 71 | #[route(POST "/{owner}/{repo}/pulls/{number}/delete_branch")] |
| 72 | async fn delete_branch_post(cx: &Cx, Form(form): Form<BranchForm>) -> Result<Response> { |
| 73 | branch_post(cx, &form.csrf, Op::Delete).await |
| 74 | } |
| 75 | |
| 76 | #[route(POST "/{owner}/{repo}/pulls/{number}/restore_branch")] |
| 77 | async fn restore_branch_post(cx: &Cx, Form(form): Form<BranchForm>) -> Result<Response> { |
| 78 | branch_post(cx, &form.csrf, Op::Restore).await |
| 79 | } |
| 80 | |
| 81 | async fn branch_post(cx: &Cx, csrf: &str, op: Op) -> Result<Response> { |
| 82 | let number = parse_number(path_param::<Number>(cx))?; |
| 83 | let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?; |
| 84 | let (user, _) = require_csrf(cx, csrf).await?; |
| 85 | let state = app_context::<AppState>(cx); |
| 86 | let Some(pr) = state.store.pull_by_number(&ctx.repo.id, number).await? else { |
| 87 | return Err(not_found().into()); |
| 88 | }; |
| 89 | let pull_href = repo_href( |
| 90 | &ctx.repo.owner_name, |
| 91 | &ctx.repo.name, |
| 92 | "pulls", |
| 93 | &number.to_string(), |
| 94 | ); |
| 95 | if ctx.repo.archived { |
| 96 | return Err(forbidden().into()); |
| 97 | } |
| 98 | let head = head_repo(state, &pr).await?; |
| 99 | match &head { |
| 100 | Some(head) => { |
| 101 | let role = state.store.collaborator_role(&head.id, &user.id).await?; |
| 102 | let org_role = state |
| 103 | .store |
| 104 | .org_repo_role(&head.owner_id, &user.id, &head.id) |
| 105 | .await?; |
| 106 | if head.archived || authorize(Some(&user), head, role, org_role, Action::Write).is_err() |
| 107 | { |
| 108 | return Err(forbidden().into()); |
| 109 | } |
| 110 | } |
| 111 | None => { |
| 112 | if authorize( |
| 113 | Some(&user), |
| 114 | &ctx.repo, |
| 115 | ctx.role, |
| 116 | ctx.org_role, |
| 117 | Action::Write, |
| 118 | ) |
| 119 | .is_err() |
| 120 | { |
| 121 | return Err(forbidden().into()); |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | if !finished(&pr) { |
| 126 | return back_to_pull(&pull_href, ERR_NOT_ALLOWED); |
| 127 | } |
| 128 | let Some(head) = head else { |
| 129 | return back_to_pull(&pull_href, ERR_MISSING); |
| 130 | }; |
| 131 | let refusal = match op { |
| 132 | Op::Delete => delete_branch(state, &head, &user, &pr).await?, |
| 133 | Op::Restore => restore_branch(state, &head, &user, &pr).await?, |
| 134 | }; |
| 135 | match refusal { |
| 136 | None => Err(see_other(pull_href).into()), |
| 137 | Some(code) => back_to_pull(&pull_href, code), |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | fn back_to_pull(pull_href: &str, code: &str) -> Result<Response> { |
| 142 | Err(see_other(format!("{pull_href}?branch_error={code}")).into()) |
| 143 | } |
| 144 | |
| 145 | |
| 146 | fn finished(pr: &PullRequest) -> bool { |
| 147 | pr.merged_at.is_some() || pr.issue.state != IssueState::Open |
| 148 | } |
| 149 | |
| 150 | |
| 151 | async fn head_repo( |
| 152 | state: &AppState, |
| 153 | pr: &PullRequest, |
| 154 | ) -> forge_core::Result<Option<forge_core::Repo>> { |
| 155 | match &pr.head_repo_id { |
| 156 | Some(id) => state.store.repo_by_id(id).await, |
| 157 | None => Ok(None), |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | |
| 162 | async fn delete_branch( |
| 163 | state: &AppState, |
| 164 | head: &forge_core::Repo, |
| 165 | user: &User, |
| 166 | pr: &PullRequest, |
| 167 | ) -> forge_core::Result<Option<&'static str>> { |
| 168 | if let Some(code) = deletion_refusal(state, head, &pr.head_branch).await? { |
| 169 | return Ok(Some(code)); |
| 170 | } |
| 171 | let path = repo_path(&state.git.data_dir, &head.id)?; |
| 172 | let refname = format!("refs/heads/{}", pr.head_branch); |
| 173 | match default_branch_commit(&path, &pr.head_branch)? { |
| 174 | None => return Ok(Some(ERR_MISSING)), |
| 175 | Some(tip) if tip != pr.head_oid => return Ok(Some(ERR_MOVED)), |
| 176 | Some(_) => {} |
| 177 | } |
| 178 | match delete_ref_cas(&state.git, &path, &refname, &pr.head_oid).await { |
| 179 | Ok(()) => {} |
| 180 | Err(forge_git::error::Error::RefMoved(_)) => return Ok(Some(ERR_MOVED)), |
| 181 | Err(e) => return Err(e.into()), |
| 182 | } |
| 183 | record_ref_change(state, head, user, &refname, &pr.head_oid, ZERO_OID).await; |
| 184 | state |
| 185 | .store |
| 186 | .add_event( |
| 187 | &pr.issue.id, |
| 188 | Some(&user.id), |
| 189 | "head_ref_deleted", |
| 190 | json!({"branch": pr.head_branch}), |
| 191 | ) |
| 192 | .await?; |
| 193 | Ok(None) |
| 194 | } |
| 195 | |
| 196 | |
| 197 | |
| 198 | async fn restore_branch( |
| 199 | state: &AppState, |
| 200 | head: &forge_core::Repo, |
| 201 | user: &User, |
| 202 | pr: &PullRequest, |
| 203 | ) -> forge_core::Result<Option<&'static str>> { |
| 204 | let path = repo_path(&state.git.data_dir, &head.id)?; |
| 205 | let refname = format!("refs/heads/{}", pr.head_branch); |
| 206 | if default_branch_commit(&path, &pr.head_branch)?.is_some() { |
| 207 | return Ok(Some(ERR_MOVED)); |
| 208 | } |
| 209 | if read_commit(&path, &pr.head_oid)?.is_none() { |
| 210 | return Ok(Some(ERR_MISSING)); |
| 211 | } |
| 212 | match update_ref_cas(&state.git, &path, &refname, &pr.head_oid, ZERO_OID).await { |
| 213 | Ok(()) => {} |
| 214 | Err(forge_git::error::Error::RefMoved(_)) => return Ok(Some(ERR_MOVED)), |
| 215 | Err(e) => return Err(e.into()), |
| 216 | } |
| 217 | record_ref_change(state, head, user, &refname, ZERO_OID, &pr.head_oid).await; |
| 218 | state |
| 219 | .store |
| 220 | .add_event( |
| 221 | &pr.issue.id, |
| 222 | Some(&user.id), |
| 223 | "head_ref_restored", |
| 224 | json!({"branch": pr.head_branch}), |
| 225 | ) |
| 226 | .await?; |
| 227 | Ok(None) |
| 228 | } |
| 229 | |
| 230 | |
| 231 | |
| 232 | async fn deletion_refusal( |
| 233 | state: &AppState, |
| 234 | head: &forge_core::Repo, |
| 235 | branch: &str, |
| 236 | ) -> forge_core::Result<Option<&'static str>> { |
| 237 | if branch == head.default_branch { |
| 238 | return Ok(Some(ERR_DEFAULT_BRANCH)); |
| 239 | } |
| 240 | let rules = state.store.list_branch_rules(&head.id).await?; |
| 241 | if !protection::effective(&rules, &head.default_branch, branch).allow_deletion { |
| 242 | return Ok(Some(ERR_PROTECTED)); |
| 243 | } |
| 244 | Ok(None) |
| 245 | } |
| 246 | |
| 247 | |
| 248 | |
| 249 | async fn record_ref_change( |
| 250 | state: &AppState, |
| 251 | head: &forge_core::Repo, |
| 252 | user: &User, |
| 253 | refname: &str, |
| 254 | old: &str, |
| 255 | new: &str, |
| 256 | ) { |
| 257 | if let Err(e) = after_ref_update(state, head, &user.id, refname, old, new).await { |
| 258 | tracing::error!(error = %e, repo_id = %head.id, ref_name = %refname, "branch: post-update failed"); |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | |
| 263 | |
| 264 | pub(crate) async fn branch_action( |
| 265 | state: &AppState, |
| 266 | ctx: &RepoCtx, |
| 267 | pr: &PullRequest, |
| 268 | csrf: &str, |
| 269 | ) -> forge_core::Result<Option<BranchAction>> { |
| 270 | let Some((user, _)) = ctx.viewer.as_ref() else { |
| 271 | return Ok(None); |
| 272 | }; |
| 273 | if ctx.repo.archived || !finished(pr) { |
| 274 | return Ok(None); |
| 275 | } |
| 276 | let Some(head) = head_repo(state, pr).await? else { |
| 277 | return Ok(None); |
| 278 | }; |
| 279 | let role = state.store.collaborator_role(&head.id, &user.id).await?; |
| 280 | let org_role = state |
| 281 | .store |
| 282 | .org_repo_role(&head.owner_id, &user.id, &head.id) |
| 283 | .await?; |
| 284 | if head.archived || authorize(Some(user), &head, role, org_role, Action::Write).is_err() { |
| 285 | return Ok(None); |
| 286 | } |
| 287 | let path = repo_path(&state.git.data_dir, &head.id)?; |
| 288 | let href = repo_href( |
| 289 | &ctx.repo.owner_name, |
| 290 | &ctx.repo.name, |
| 291 | "pulls", |
| 292 | &pr.issue.number.to_string(), |
| 293 | ); |
| 294 | let (restore, action) = match default_branch_commit(&path, &pr.head_branch)? { |
| 295 | Some(tip) if tip == pr.head_oid => { |
| 296 | if deletion_refusal(state, &head, &pr.head_branch) |
| 297 | .await? |
| 298 | .is_some() |
| 299 | { |
| 300 | return Ok(None); |
| 301 | } |
| 302 | (false, "delete_branch") |
| 303 | } |
| 304 | Some(_) => return Ok(None), |
| 305 | None => { |
| 306 | if read_commit(&path, &pr.head_oid)?.is_none() { |
| 307 | return Ok(None); |
| 308 | } |
| 309 | (true, "restore_branch") |
| 310 | } |
| 311 | }; |
| 312 | Ok(Some(BranchAction { |
| 313 | restore, |
| 314 | branch: pr.head_branch.clone(), |
| 315 | action: format!("{href}/{action}"), |
| 316 | csrf: csrf.to_owned(), |
| 317 | })) |
| 318 | } |
| 319 | |
| 320 | |
| 321 | pub(crate) fn branch_error_text(lang: Lang, code: &str) -> Option<&'static str> { |
| 322 | Some(match code { |
| 323 | ERR_PROTECTED => g::t(lang, K::BranchErrProtected), |
| 324 | ERR_DEFAULT_BRANCH => g::t(lang, K::BranchErrDefault), |
| 325 | ERR_MOVED => g::t(lang, K::BranchErrMoved), |
| 326 | ERR_MISSING => g::t(lang, K::BranchErrMissing), |
| 327 | ERR_NOT_ALLOWED => g::t(lang, K::BranchErrNotAllowed), |
| 328 | _ => return None, |
| 329 | }) |
| 330 | } |
| 331 | |
| 332 | |
| 333 | pub(crate) fn branch_error_from(cx: &Cx, lang: Lang) -> Option<String> { |
| 334 | let request = uri(cx); |
| 335 | let query = request.query()?; |
| 336 | let (_, code) = |
| 337 | form_urlencoded::parse(query.as_bytes()).find(|(key, _)| *key == "branch_error")?; |
| 338 | branch_error_text(lang, &code).map(str::to_owned) |
| 339 | } |
| 340 | |
| 341 | |
| 342 | |
| 343 | #[component] |
| 344 | pub(crate) async fn branch_box( |
| 345 | lang: Lang, |
| 346 | action: Option<BranchAction>, |
| 347 | error: Option<String>, |
| 348 | ) -> Result<impl View> { |
| 349 | Ok(view! { |
| 350 | <section class="merge-box merge-muted"> |
| 351 | if let Some(text) = error { |
| 352 | <p class="alert merge-error" role="alert">(text.as_str())</p> |
| 353 | } |
| 354 | if let Some(a) = action { |
| 355 | if a.restore { |
| 356 | <form method="post" action=(a.action.as_str()) class="stack"> |
| 357 | <input type="hidden" name="csrf" value=(a.csrf.as_str())> |
| 358 | <p class="merge-headline">(g::t(lang, K::BranchPrefix)) <code>(a.branch.as_str())</code> (g::t(lang, K::BranchWasDeleted))</p> |
| 359 | <div><button type="submit">(g::t(lang, K::RestoreBranch))</button></div> |
| 360 | </form> |
| 361 | } else { |
| 362 | <form method="post" action=(a.action.as_str()) class="stack"> |
| 363 | <input type="hidden" name="csrf" value=(a.csrf.as_str())> |
| 364 | <p class="merge-headline">(g::t(lang, K::BranchPrefix)) <code>(a.branch.as_str())</code> (g::t(lang, K::BranchStillHere))</p> |
| 365 | <div><button type="submit">(g::t(lang, K::DeleteBranch))</button></div> |
| 366 | </form> |
| 367 | } |
| 368 | } |
| 369 | </section> |
| 370 | }) |
| 371 | } |
| 372 | |
| 373 | #[cfg(test)] |
| 374 | mod tests { |
| 375 | use std::path::{Path, PathBuf}; |
| 376 | |
| 377 | use forge_core::protection::RuleFlags; |
| 378 | use forge_core::{Repo, Role, Store, Visibility}; |
| 379 | use forge_git::repo::{init_bare, repo_path}; |
| 380 | use topcoat::router::request::Request; |
| 381 | use topcoat::router::{Body, Router, to_bytes}; |
| 382 | |
| 383 | use super::*; |
| 384 | use crate::auth::SESSION_COOKIE; |
| 385 | use crate::test_support::{app_router, create_session, test_state}; |
| 386 | |
| 387 | const CSRF: &str = "branch-test-csrf"; |
| 388 | |
| 389 | fn git(dir: &Path, args: &[&str]) -> String { |
| 390 | let out = std::process::Command::new("git") |
| 391 | .arg("-C") |
| 392 | .arg(dir) |
| 393 | .args(args) |
| 394 | .env("GIT_AUTHOR_NAME", "test") |
| 395 | .env("GIT_AUTHOR_EMAIL", "test@example.com") |
| 396 | .env("GIT_COMMITTER_NAME", "test") |
| 397 | .env("GIT_COMMITTER_EMAIL", "test@example.com") |
| 398 | .output() |
| 399 | .expect("git runs"); |
| 400 | assert!( |
| 401 | out.status.success(), |
| 402 | "git {args:?} failed: {}", |
| 403 | String::from_utf8_lossy(&out.stderr) |
| 404 | ); |
| 405 | String::from_utf8_lossy(&out.stdout).trim().to_string() |
| 406 | } |
| 407 | |
| 408 | |
| 409 | fn commit(work: &Path, file: &str, contents: &str) -> String { |
| 410 | std::fs::write(work.join(file), contents).expect("write file"); |
| 411 | git(work, &["add", file]); |
| 412 | git(work, &["commit", "-m", contents]); |
| 413 | git(work, &["rev-parse", "HEAD"]) |
| 414 | } |
| 415 | |
| 416 | fn tip(dir: &Path, rev: &str) -> String { |
| 417 | git(dir, &["rev-parse", rev]) |
| 418 | } |
| 419 | |
| 420 | struct Fixture { |
| 421 | _tmp: tempfile::TempDir, |
| 422 | state: AppState, |
| 423 | app: Router, |
| 424 | repo: Repo, |
| 425 | alice: User, |
| 426 | bob: User, |
| 427 | alice_cookie: String, |
| 428 | bare: PathBuf, |
| 429 | work: PathBuf, |
| 430 | } |
| 431 | |
| 432 | |
| 433 | |
| 434 | async fn fixture() -> Fixture { |
| 435 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 436 | let state = test_state(tmp.path()).await; |
| 437 | let store: Store = state.store.clone(); |
| 438 | let alice = store |
| 439 | .create_user("alice", None, "unused", false) |
| 440 | .await |
| 441 | .expect("alice"); |
| 442 | let bob = store |
| 443 | .create_user("bob", None, "unused", false) |
| 444 | .await |
| 445 | .expect("bob"); |
| 446 | let repo = store |
| 447 | .create_repo(&alice.id, "proj", "", Visibility::Private, "main") |
| 448 | .await |
| 449 | .expect("repo"); |
| 450 | let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path"); |
| 451 | init_bare(&state.git, &bare, "main") |
| 452 | .await |
| 453 | .expect("init bare"); |
| 454 | let work = tmp.path().join("work"); |
| 455 | git(tmp.path(), &["init", "-b", "main", "work"]); |
| 456 | commit(&work, "file.txt", "one"); |
| 457 | git(&work, &["push", bare.to_str().expect("utf8"), "main"]); |
| 458 | |
| 459 | create_session(&store, &alice.id, "alice-token", CSRF).await; |
| 460 | let alice_cookie = format!("__Host-{SESSION_COOKIE}=alice-token"); |
| 461 | let app = app_router(state.clone()); |
| 462 | Fixture { |
| 463 | _tmp: tmp, |
| 464 | state, |
| 465 | app, |
| 466 | repo, |
| 467 | alice, |
| 468 | bob, |
| 469 | alice_cookie, |
| 470 | bare, |
| 471 | work, |
| 472 | } |
| 473 | } |
| 474 | |
| 475 | impl Fixture { |
| 476 | fn bare_str(&self) -> &str { |
| 477 | self.bare.to_str().expect("utf8 path") |
| 478 | } |
| 479 | |
| 480 | fn branch_tip(&self, branch: &str) -> String { |
| 481 | tip(&self.bare, &format!("refs/heads/{branch}")) |
| 482 | } |
| 483 | |
| 484 | fn branch_exists(&self, branch: &str) -> bool { |
| 485 | !git( |
| 486 | &self.bare, |
| 487 | &["for-each-ref", &format!("refs/heads/{branch}")], |
| 488 | ) |
| 489 | .is_empty() |
| 490 | } |
| 491 | |
| 492 | async fn pull(&self, number: i64) -> PullRequest { |
| 493 | self.state |
| 494 | .store |
| 495 | .pull_by_number(&self.repo.id, number) |
| 496 | .await |
| 497 | .expect("lookup") |
| 498 | .expect("pull exists") |
| 499 | } |
| 500 | |
| 501 | |
| 502 | |
| 503 | async fn open_feature(&self, branch: &str, file: &str, contents: &str) -> i64 { |
| 504 | git(&self.work, &["checkout", "-q", "main"]); |
| 505 | git(&self.work, &["checkout", "-q", "-B", branch]); |
| 506 | commit(&self.work, file, contents); |
| 507 | git(&self.work, &["push", "-q", "-f", self.bare_str(), branch]); |
| 508 | let base = self.branch_tip("main"); |
| 509 | let head = self.branch_tip(branch); |
| 510 | let pr = self |
| 511 | .state |
| 512 | .store |
| 513 | .create_pull( |
| 514 | &self.repo.id, |
| 515 | &self.alice.id, |
| 516 | &format!("Add {branch}"), |
| 517 | "", |
| 518 | "main", |
| 519 | &self.repo.id, |
| 520 | branch, |
| 521 | &head, |
| 522 | &base, |
| 523 | ) |
| 524 | .await |
| 525 | .expect("create pull"); |
| 526 | pr.issue.number |
| 527 | } |
| 528 | |
| 529 | async fn send(&self, method: &str, path: &str, cookie: &str, body: String) -> Reply { |
| 530 | let mut builder = Request::builder() |
| 531 | .method(method) |
| 532 | .uri(path) |
| 533 | .header("cookie", cookie); |
| 534 | if method == "POST" { |
| 535 | builder = builder.header("content-type", "application/x-www-form-urlencoded"); |
| 536 | } |
| 537 | let res = self |
| 538 | .app |
| 539 | .handle(builder.body(Body::from(body)).expect("request")) |
| 540 | .await; |
| 541 | let status = res.status().as_u16(); |
| 542 | let location = res |
| 543 | .headers() |
| 544 | .get("location") |
| 545 | .and_then(|v| v.to_str().ok()) |
| 546 | .map(str::to_owned); |
| 547 | let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); |
| 548 | Reply { |
| 549 | status, |
| 550 | location, |
| 551 | body: String::from_utf8_lossy(&bytes).into_owned(), |
| 552 | } |
| 553 | } |
| 554 | |
| 555 | async fn sign_in(&self, user: &User, token: &str) -> String { |
| 556 | create_session(&self.state.store, &user.id, token, CSRF).await; |
| 557 | format!("__Host-{SESSION_COOKIE}={token}") |
| 558 | } |
| 559 | |
| 560 | async fn merge(&self, number: i64) -> Reply { |
| 561 | self.send( |
| 562 | "POST", |
| 563 | &format!("/alice/proj/pulls/{number}/merge"), |
| 564 | &self.alice_cookie, |
| 565 | csrf_form(&[("style", "merge")]), |
| 566 | ) |
| 567 | .await |
| 568 | } |
| 569 | |
| 570 | async fn branch_post(&self, number: i64, op: &str, cookie: &str) -> Reply { |
| 571 | self.send( |
| 572 | "POST", |
| 573 | &format!("/alice/proj/pulls/{number}/{op}"), |
| 574 | cookie, |
| 575 | csrf_form(&[]), |
| 576 | ) |
| 577 | .await |
| 578 | } |
| 579 | } |
| 580 | |
| 581 | struct Reply { |
| 582 | status: u16, |
| 583 | location: Option<String>, |
| 584 | body: String, |
| 585 | } |
| 586 | |
| 587 | fn csrf_form(pairs: &[(&str, &str)]) -> String { |
| 588 | let mut form = form_urlencoded::Serializer::new(String::new()); |
| 589 | form.append_pair("csrf", CSRF); |
| 590 | for (key, value) in pairs { |
| 591 | form.append_pair(key, value); |
| 592 | } |
| 593 | form.finish() |
| 594 | } |
| 595 | |
| 596 | |
| 597 | fn assert_refused(reply: &Reply, number: i64, code: &str) { |
| 598 | assert_eq!(reply.status, 303, "body: {}", reply.body); |
| 599 | assert_eq!( |
| 600 | reply.location.as_deref(), |
| 601 | Some(format!("/alice/proj/pulls/{number}?branch_error={code}").as_str()) |
| 602 | ); |
| 603 | } |
| 604 | |
| 605 | #[tokio::test] |
| 606 | async fn merge_then_delete_removes_branch_and_records_event() { |
| 607 | let fx = fixture().await; |
| 608 | let number = fx.open_feature("feature", "f.txt", "feature").await; |
| 609 | let merged = fx.merge(number).await; |
| 610 | assert_eq!(merged.status, 303, "body: {}", merged.body); |
| 611 | |
| 612 | let deleted = fx |
| 613 | .branch_post(number, "delete_branch", &fx.alice_cookie) |
| 614 | .await; |
| 615 | assert_eq!(deleted.status, 303, "body: {}", deleted.body); |
| 616 | assert_eq!( |
| 617 | deleted.location.as_deref(), |
| 618 | Some(format!("/alice/proj/pulls/{number}").as_str()) |
| 619 | ); |
| 620 | assert!(!fx.branch_exists("feature")); |
| 621 | |
| 622 | let pr = fx.pull(number).await; |
| 623 | let events = fx |
| 624 | .state |
| 625 | .store |
| 626 | .list_events(&pr.issue.id) |
| 627 | .await |
| 628 | .expect("events"); |
| 629 | let deletion = events |
| 630 | .iter() |
| 631 | .find(|e| e.kind == "head_ref_deleted") |
| 632 | .expect("deletion event"); |
| 633 | assert_eq!(deletion.data["branch"], "feature"); |
| 634 | |
| 635 | |
| 636 | let page = fx |
| 637 | .send( |
| 638 | "GET", |
| 639 | &format!("/alice/proj/pulls/{number}"), |
| 640 | &fx.alice_cookie, |
| 641 | String::new(), |
| 642 | ) |
| 643 | .await; |
| 644 | assert!( |
| 645 | page.body.contains("deleted the feature branch"), |
| 646 | "{}", |
| 647 | page.body |
| 648 | ); |
| 649 | assert!(page.body.contains("Restore branch"), "{}", page.body); |
| 650 | |
| 651 | let restored = fx |
| 652 | .branch_post(number, "restore_branch", &fx.alice_cookie) |
| 653 | .await; |
| 654 | assert_eq!(restored.status, 303, "body: {}", restored.body); |
| 655 | assert_eq!(fx.branch_tip("feature"), pr.head_oid); |
| 656 | let events = fx |
| 657 | .state |
| 658 | .store |
| 659 | .list_events(&pr.issue.id) |
| 660 | .await |
| 661 | .expect("events"); |
| 662 | assert!(events.iter().any(|e| e.kind == "head_ref_restored")); |
| 663 | } |
| 664 | |
| 665 | #[tokio::test] |
| 666 | async fn protected_branch_is_refused() { |
| 667 | let fx = fixture().await; |
| 668 | let number = fx.open_feature("feature", "f.txt", "feature").await; |
| 669 | fx.merge(number).await; |
| 670 | fx.state |
| 671 | .store |
| 672 | .upsert_branch_rule(&fx.repo.id, "feature", RuleFlags::basic(false, false)) |
| 673 | .await |
| 674 | .expect("rule"); |
| 675 | |
| 676 | let reply = fx |
| 677 | .branch_post(number, "delete_branch", &fx.alice_cookie) |
| 678 | .await; |
| 679 | assert_refused(&reply, number, "protected"); |
| 680 | assert!(fx.branch_exists("feature")); |
| 681 | } |
| 682 | |
| 683 | #[tokio::test] |
| 684 | async fn branch_moved_after_merge_is_refused() { |
| 685 | let fx = fixture().await; |
| 686 | let number = fx.open_feature("feature", "f.txt", "feature").await; |
| 687 | fx.merge(number).await; |
| 688 | git(&fx.work, &["checkout", "-q", "feature"]); |
| 689 | commit(&fx.work, "more.txt", "more"); |
| 690 | git(&fx.work, &["push", "-q", fx.bare_str(), "feature"]); |
| 691 | let moved_to = fx.branch_tip("feature"); |
| 692 | |
| 693 | let reply = fx |
| 694 | .branch_post(number, "delete_branch", &fx.alice_cookie) |
| 695 | .await; |
| 696 | assert_refused(&reply, number, "moved"); |
| 697 | assert_eq!(fx.branch_tip("feature"), moved_to); |
| 698 | } |
| 699 | |
| 700 | #[tokio::test] |
| 701 | async fn read_only_collaborator_is_forbidden_and_sees_no_button() { |
| 702 | let fx = fixture().await; |
| 703 | let number = fx.open_feature("feature", "f.txt", "feature").await; |
| 704 | fx.merge(number).await; |
| 705 | fx.state |
| 706 | .store |
| 707 | .set_collaborator(&fx.repo.id, &fx.bob.id, Role::Read) |
| 708 | .await |
| 709 | .expect("collaborator"); |
| 710 | let bob_cookie = fx.sign_in(&fx.bob, "bob-token").await; |
| 711 | |
| 712 | let reply = fx.branch_post(number, "delete_branch", &bob_cookie).await; |
| 713 | assert_eq!(reply.status, 403, "body: {}", reply.body); |
| 714 | assert!(fx.branch_exists("feature")); |
| 715 | |
| 716 | let page = fx |
| 717 | .send( |
| 718 | "GET", |
| 719 | &format!("/alice/proj/pulls/{number}"), |
| 720 | &bob_cookie, |
| 721 | String::new(), |
| 722 | ) |
| 723 | .await; |
| 724 | assert!(!page.body.contains("Delete branch"), "{}", page.body); |
| 725 | } |
| 726 | |
| 727 | #[tokio::test] |
| 728 | async fn default_branch_is_refused() { |
| 729 | let fx = fixture().await; |
| 730 | |
| 731 | let main = fx.branch_tip("main"); |
| 732 | let pr = fx |
| 733 | .state |
| 734 | .store |
| 735 | .create_pull( |
| 736 | &fx.repo.id, |
| 737 | &fx.alice.id, |
| 738 | "Main", |
| 739 | "", |
| 740 | "dev", |
| 741 | &fx.repo.id, |
| 742 | "main", |
| 743 | &main, |
| 744 | &main, |
| 745 | ) |
| 746 | .await |
| 747 | .expect("create pull"); |
| 748 | fx.state |
| 749 | .store |
| 750 | .mark_merged(&pr.issue.id, &fx.alice.id, &main) |
| 751 | .await |
| 752 | .expect("mark merged"); |
| 753 | |
| 754 | let number = pr.issue.number; |
| 755 | let reply = fx |
| 756 | .branch_post(number, "delete_branch", &fx.alice_cookie) |
| 757 | .await; |
| 758 | assert_refused(&reply, number, "default_branch"); |
| 759 | assert!(fx.branch_exists("main")); |
| 760 | } |
| 761 | |
| 762 | #[test] |
| 763 | fn unknown_branch_error_codes_have_no_message() { |
| 764 | assert!(branch_error_text(Lang::En, "protected").is_some()); |
| 765 | assert!(branch_error_text(Lang::En, "<script>").is_none()); |
| 766 | } |
| 767 | } |