forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
25.8 KiB
1//! Deleting and restoring the head branch of a finished pull request:
2//! `POST /{owner}/{repo}/pulls/{number}/delete_branch` and `POST /{owner}/{repo}/pulls/{number}/restore_branch`.
3//!
4//! Both need a merged or closed pull request, a signed-in viewer with a valid CSRF token and Write
5//! on the head repo (403 otherwise). The head repo must still exist. Deleting needs the branch to
6//! sit at the pull request's head commit, not to be the default branch and to be deletable under
7//! branch protection. Restoring needs the branch to be missing and the head commit to still exist
8//! in the head repo. The ref moves with a compare-and-swap, so a branch that moved meanwhile is
9//! refused. A refusal sends the viewer back to the pull request with `?branch_error=<code>`, and a
10//! success is recorded as a `head_ref_deleted` or `head_ref_restored` event on the pull request.
11
12use forge_core::protection;
13use forge_core::{Action, IssueState, PullRequest, User, authorize};
14use forge_git::browse::{default_branch_commit, read_commit};
15use forge_git::pulls::{delete_ref_cas, update_ref_cas};
16use forge_git::repo::repo_path;
17use serde::Deserialize;
18use serde_json::json;
19use topcoat::Result;
20use topcoat::context::{Cx, app_context};
21use topcoat::router::content::Form;
22use topcoat::router::error::{forbidden, not_found, see_other};
23use topcoat::router::request::uri;
24use topcoat::router::response::Response;
25use topcoat::router::{path_param, route};
26use topcoat::view::{View, component, view};
27
28use crate::auth::require_csrf;
29use crate::i18n::Lang;
30use crate::i18n::pulls::{self as g, K};
31use crate::pages::issue_parts::parse_number;
32use crate::pages::repo_ctx::{RepoCtx, load_repo};
33use crate::post_update::after_ref_update;
34use crate::state::AppState;
35use crate::views::repo_href;
36
37path_param!(owner);
38path_param!(repo);
39path_param!(number);
40
41const ZERO_OID: &str = "0000000000000000000000000000000000000000";
42
43/// Refusal codes, sent as `?branch_error=<code>`. The pull request page owns the wording; a code
44/// outside this set is never echoed back.
45pub const ERR_PROTECTED: &str = "protected";
46pub const ERR_DEFAULT_BRANCH: &str = "default_branch";
47pub const ERR_MOVED: &str = "moved";
48pub const ERR_MISSING: &str = "missing";
49pub const ERR_NOT_ALLOWED: &str = "not_allowed";
50
51#[derive(Deserialize)]
52struct BranchForm {
53 #[serde(default)]
54 csrf: String,
55}
56
57#[derive(Clone, Copy)]
58enum Op {
59 Delete,
60 Restore,
61}
62
63/// The branch form the pull request page shows: which action it posts to and for which branch.
64pub 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")]
72async 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")]
77async fn restore_branch_post(cx: &Cx, Form(form): Form<BranchForm>) -> Result<Response> {
78 branch_post(cx, &form.csrf, Op::Restore).await
79}
80
81async 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
141fn back_to_pull(pull_href: &str, code: &str) -> Result<Response> {
142 Err(see_other(format!("{pull_href}?branch_error={code}")).into())
143}
144
145/// A merged or closed pull request. Only these can have their head branch deleted or restored.
146fn finished(pr: &PullRequest) -> bool {
147 pr.merged_at.is_some() || pr.issue.state != IssueState::Open
148}
149
150/// The head repo of `pr` when it still exists.
151async 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/// Deletes the head branch. `None` when it was deleted, `Some(code)` when it was refused.
162async 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/// Recreates the head branch at the pull request's head commit. `None` when it was restored,
197/// `Some(code)` when it was refused.
198async 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/// The refusal that applies to deleting `branch` of `head`, if any: the default branch, or a
231/// branch that branch protection does not allow to be deleted.
232async 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/// Runs the post-update work for a head ref that moved from `old` to `new`. Failures are logged:
248/// the ref has already moved.
249async 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/// The branch action the pull request page should show to the viewer, if any. Only finished
263/// pull requests get one, and only for viewers with Write on the head repo.
264pub(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/// The message for a `branch_error` code. Unknown codes have no message.
321pub(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/// The message for the `branch_error` query value of the request, when it is a known code.
333pub(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/// The branch box on a finished pull request: an error line and, when the viewer may act, the
342/// delete or restore form.
343#[component]
344pub(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)]
374mod 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 /// Writes `contents` to `file` in the work tree and commits it.
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 /// A private repo `alice/proj` with `main` holding `file.txt`, a bare copy on disk and a work
433 /// tree that pushes to it.
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 /// Commits `file` on a new branch cut from main, pushes it and opens a pull request from
502 /// it. Returns the pull request number.
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 /// Asserts a refusal: a 303 back to the pull request with `branch_error=<code>`.
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 // The timeline names the branch.
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 // A finished pull request whose head is the default branch itself.
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}