//! Page: one issue at `/{owner}/{repo}/issues/{n}`, plus its comment, edit and delete routes. //! The body is the first timeline entry, then comments and events merged by time. Triage holders //! get label, assignee and milestone forms in the sidebar (posted to `issue_meta`); others get //! read-only lists. Archived repos are read-only: no write forms, and every POST gets 403. //! The timeline, sidebar and comment POST logic are shared with the pull request pages //! (`issue_parts`). A pull request number under `/issues/{n}` redirects to `/pulls/{n}`. use forge_core::{Comment, Issue, IssueState, StateReason}; use forge_core::{validate_body, validate_title}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, forbidden, not_found, see_other}; use topcoat::router::{page, path_param, route}; use topcoat::view::{View, view}; use crate::auth::require_user; use crate::i18n; use crate::i18n::issues::{self as g, K}; use crate::notify; use crate::pages::issue_parts::{ CommentForm, ItemKind, Posted, check_write, comment_for, comment_form, csrf_of, discussion_timeline, issue_for, item_for, item_sidebar, load_discussion, load_sidebar, lock_message, may_change, may_converse, parse_number, post_comment, triage, }; use crate::pages::issue_pin::pin_box; use crate::pages::notifications::notify_box_for; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoCtx, load_repo}; use crate::state::AppState; use crate::views::{alert, repo_href, shell, user_facing_message}; use crate::webhooks::emit::{Sender, emit_comment, emit_item_action}; path_param!(owner); path_param!(repo); path_param!(number); path_param!(comment_id); /// The issue page: header, timeline, comment form and sidebar. `draft` and `error` belong to /// the comment form, so a failed comment keeps its text. async fn issue_page( cx: &Cx, ctx: RepoCtx, issue: Issue, draft: String, error: Option, ) -> Result { let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); let state = app_context::(cx); let owner = ctx.repo.owner_name.clone(); let name = ctx.repo.name.clone(); let number = issue.number; let issue_href = repo_href(&owner, &name, "issues", &number.to_string()); let discussion = load_discussion(lang, state, &ctx, &issue, ItemKind::Issue, "").await?; let sidebar = load_sidebar(state, &ctx.repo.id, &issue).await?; let pinned = state.store.is_pinned(&ctx.repo.id, &issue.id).await?; let pin_action = format!("{issue_href}/pin"); let notify = notify_box_for(state, &ctx, lang, &issue.id, &issue_href).await?; let author = discussion.author.clone(); let opened = discussion.opened.clone(); let csrf = csrf_of(&ctx); let archived = ctx.repo.archived; let signed_in = ctx.viewer.is_some(); let editable_issue = may_change(&ctx, &issue.author_id); let write_meta = !archived && triage(&ctx); let can_comment = signed_in && !archived && may_converse(&ctx, &issue); let locked_for_viewer = !may_converse(&ctx, &issue); let lock_notice = lock_message(lang, &issue); let open = matches!(issue.state, IssueState::Open); let (state_class, state_text) = match (&issue.state, &issue.state_reason) { (IssueState::Open, _) => ("issue-badge issue-badge-open", g::t(lang, K::Open)), (IssueState::Closed, Some(StateReason::NotPlanned)) => ( "issue-badge issue-badge-not-planned", g::t(lang, K::StateClosedNotPlanned), ), (IssueState::Closed, _) => ("issue-badge issue-badge-completed", g::t(lang, K::Closed)), }; let comments_label = g::comment_count(lang, issue.comment_count); let page_title = format!("{} #{number} \u{b7} {owner}/{name}", issue.title); let number_label = format!("#{number}"); let login_href = "/login".to_owned(); let comment_action = format!("{issue_href}/comment"); let labels_href = repo_href(&owner, &name, "labels.css", ""); let header = ctx.head(state, "issues").await?; Ok(view! { cx => shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), extra_stylesheet: Some(labels_href.as_str()), repo_head(head: header)

(issue.title.as_str()) " " (number_label.as_str())

discussion_timeline(lang: lang, discussion: discussion) if can_comment { comment_form(lang: lang, action: comment_action.clone(), state_action: comment_action.clone(), csrf: csrf.clone(), draft: draft, error: error, can_close: editable_issue, open: open) } if archived {

(g::t(lang, K::ArchivedIssuesReadOnly))

} if locked_for_viewer {

(lock_notice.as_str())

} else if !signed_in {

(g::comment_login_lead(lang))(g::t(lang, K::LogInLink))(g::t(lang, K::ToComment))

}
) }) } #[page("/{owner}/{repo}/issues/{number}")] async fn issue_show(cx: &Cx) -> Result { let number = parse_number(path_param::(cx))?; let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let state = app_context::(cx); let Some(issue) = state.store.issue_by_number(&ctx.repo.id, number).await? else { if state .store .pull_by_number(&ctx.repo.id, number) .await? .is_some() { let location = repo_href( &ctx.repo.owner_name, &ctx.repo.name, "pulls", &number.to_string(), ); return Err(see_other(location).into()); } return Err(not_found().into()); }; issue_page(cx, ctx, issue, String::new(), None).await } /// Adds a comment and/or changes the state. Only the author or a Triage holder may close or /// reopen; without that the request is 403 and nothing is written. #[page(POST "/{owner}/{repo}/issues/{number}/comment")] async fn issue_comment(cx: &Cx, Form(form): Form) -> Result { let number = parse_number(path_param::(cx))?; let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let (user, session) = require_user(cx).await?; check_write(&ctx, &session, &form.csrf)?; let state = app_context::(cx); let issue = issue_for(state, &ctx, number).await?; let lang = i18n::lang(cx, Some(&user)); match post_comment(lang, state, &ctx, &user, &issue, false, &form).await? { Posted::Saved { comment_id } => { let anchor = match comment_id { Some(id) => format!("comment-{id}"), None => format!("issue-{number}"), }; Err(see_other(format!( "/{}/{}/issues/{number}#{anchor}", ctx.repo.owner_name, ctx.repo.name )) .into()) } Posted::Refused { message } => issue_page(cx, ctx, issue, form.body, Some(message)).await, } } #[derive(Deserialize)] struct IssueForm { #[serde(default)] csrf: String, #[serde(default)] title: String, #[serde(default)] body: String, } /// Edit form for the title and body of an issue or pull request: author or Triage. async fn edit_item_page( cx: &Cx, ctx: RepoCtx, kind: ItemKind, number: i64, title: String, body: String, error: Option, ) -> Result { let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); let state = app_context::(cx); let owner = ctx.repo.owner_name.clone(); let name = ctx.repo.name.clone(); let csrf = csrf_of(&ctx); let segment = kind.segment(); let action = repo_href(&owner, &name, segment, &format!("{number}/edit")); let back = repo_href(&owner, &name, segment, &number.to_string()); let page_title = g::edit_title(lang, number, &format!("{owner}/{name}")); let heading = g::edit_heading(lang, kind == ItemKind::Pull, number); let header = ctx.head(state, kind_tab(kind)).await?; Ok(view! { cx => shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header)

(heading.as_str())

if let Some(message) = error { alert(message: message.as_str()) }
(g::t(lang, K::Cancel))
) }) } /// The nav tab an item's pages highlight. fn kind_tab(kind: ItemKind) -> &'static str { match kind { ItemKind::Issue => "issues", ItemKind::Pull => "pulls", } } /// Shows the edit form. Only the author or Triage; anyone else is 403. async fn item_edit_form(cx: &Cx, kind: ItemKind) -> Result { let number = parse_number(path_param::(cx))?; let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; require_user(cx).await?; let item = item_for(app_context::(cx), &ctx, kind, number).await?; if !may_change(&ctx, &item.author_id) { return Err(forbidden().into()); } edit_item_page(cx, ctx, kind, number, item.title, item.body, None).await } /// Saves the title and body. Only the author or Triage; the store records the edit history. async fn item_edit_save(cx: &Cx, kind: ItemKind, form: IssueForm) -> Result { let number = parse_number(path_param::(cx))?; let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let (user, session) = require_user(cx).await?; check_write(&ctx, &session, &form.csrf)?; let state = app_context::(cx); let item = item_for(state, &ctx, kind, number).await?; if !may_change(&ctx, &item.author_id) { return Err(forbidden().into()); } let title = match validate_title(&form.title) { Ok(title) => title, Err(e) => { let message = user_facing_message(&e).unwrap_or_else(|| e.to_string()); return edit_item_page(cx, ctx, kind, number, form.title, form.body, Some(message)) .await; } }; if let Err(e) = validate_body(&form.body) { let message = user_facing_message(&e).unwrap_or_else(|| e.to_string()); return edit_item_page(cx, ctx, kind, number, title, form.body, Some(message)).await; } state .store .edit_issue(&item.id, &user.id, &title, &form.body) .await?; let pull = matches!(kind, ItemKind::Pull); emit_item_action(state, &ctx.repo, &Sender::of(&user), pull, number, "edited").await; notify::dispatch_edit(state, &ctx.repo, &item, &user.id, &item.body, &form.body).await; Err(see_other(format!( "/{}/{}/{}/{number}", ctx.repo.owner_name, ctx.repo.name, kind.segment() )) .into()) } /// Edit form for one comment: its author or Triage. async fn edit_comment_page( cx: &Cx, ctx: RepoCtx, kind: ItemKind, number: i64, comment_id: String, body: String, error: Option, ) -> Result { let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); let state = app_context::(cx); let owner = ctx.repo.owner_name.clone(); let name = ctx.repo.name.clone(); let csrf = csrf_of(&ctx); let segment = kind.segment(); let action = repo_href( &owner, &name, segment, &format!("{number}/comments/{comment_id}/edit"), ); let back = format!( "{}#comment-{comment_id}", repo_href(&owner, &name, segment, &number.to_string()) ); let page_title = g::edit_comment_title(lang, number, &format!("{owner}/{name}")); let header = ctx.head(state, kind_tab(kind)).await?; Ok(view! { cx => shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header)

(g::t(lang, K::EditComment))

if let Some(message) = error { alert(message: message.as_str()) }
(g::t(lang, K::Cancel))
) }) } /// Shows the edit form for a comment. Its author or Triage; anyone else is 403. async fn item_comment_edit_form(cx: &Cx, kind: ItemKind) -> Result { let number = parse_number(path_param::(cx))?; let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; require_user(cx).await?; let state = app_context::(cx); let item = item_for(state, &ctx, kind, number).await?; let comment = comment_for(state, &item, path_param::(cx)).await?; if !may_change(&ctx, &comment.author_id) { return Err(forbidden().into()); } edit_comment_page( cx, ctx, kind, number, comment.id.clone(), comment.body, None, ) .await } /// Saves an edited comment. Its author or Triage; the store records the previous body. async fn item_comment_edit_save(cx: &Cx, kind: ItemKind, form: CommentForm) -> Result { let number = parse_number(path_param::(cx))?; let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let (user, session) = require_user(cx).await?; check_write(&ctx, &session, &form.csrf)?; let state = app_context::(cx); let item = item_for(state, &ctx, kind, number).await?; let comment = comment_for(state, &item, path_param::(cx)).await?; if !may_change(&ctx, &comment.author_id) { return Err(forbidden().into()); } if form.body.trim().is_empty() { let lang = i18n::lang(cx, Some(&user)); return edit_comment_page( cx, ctx, kind, number, comment.id.clone(), form.body, Some(g::t(lang, K::CommentEmpty).to_owned()), ) .await; } if let Err(e) = validate_body(&form.body) { let message = user_facing_message(&e).unwrap_or_else(|| e.to_string()); return edit_comment_page( cx, ctx, kind, number, comment.id.clone(), form.body, Some(message), ) .await; } state .store .edit_comment(&comment.id, &user.id, &form.body) .await?; let edited = Comment { body: form.body.clone(), edited: true, ..comment.clone() }; let pull = matches!(kind, ItemKind::Pull); let sender = Sender::of(&user); emit_comment(state, &ctx.repo, &sender, "edited", pull, &item, &edited).await; notify::dispatch_edit(state, &ctx.repo, &item, &user.id, &comment.body, &form.body).await; Err(see_other(format!( "/{}/{}/{}/{number}#comment-{}", ctx.repo.owner_name, ctx.repo.name, kind.segment(), comment.id )) .into()) } #[derive(Deserialize)] struct CsrfForm { #[serde(default)] csrf: String, } /// Deletes one comment: its author or Triage. The item's comment count goes down. async fn item_comment_delete(cx: &Cx, kind: ItemKind, form: CsrfForm) -> Result { let number = parse_number(path_param::(cx))?; let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let (user, session) = require_user(cx).await?; check_write(&ctx, &session, &form.csrf)?; let state = app_context::(cx); let item = item_for(state, &ctx, kind, number).await?; let comment = comment_for(state, &item, path_param::(cx)).await?; if !may_change(&ctx, &comment.author_id) { return Err(forbidden().into()); } state.store.delete_comment(&comment.id).await?; let pull = matches!(kind, ItemKind::Pull); emit_comment( state, &ctx.repo, &Sender::of(&user), "deleted", pull, &item, &comment, ) .await; Ok(see_other(format!( "/{}/{}/{}/{number}", ctx.repo.owner_name, ctx.repo.name, kind.segment() ))) } // Issue routes. A pull request number under these paths is 404: `issue_by_number` skips pulls. #[page("/{owner}/{repo}/issues/{number}/edit")] async fn issue_edit_form(cx: &Cx) -> Result { item_edit_form(cx, ItemKind::Issue).await } #[page(POST "/{owner}/{repo}/issues/{number}/edit")] async fn issue_edit_save(cx: &Cx, Form(form): Form) -> Result { item_edit_save(cx, ItemKind::Issue, form).await } #[page("/{owner}/{repo}/issues/{number}/comments/{comment_id}/edit")] async fn comment_edit_form(cx: &Cx) -> Result { item_comment_edit_form(cx, ItemKind::Issue).await } #[page(POST "/{owner}/{repo}/issues/{number}/comments/{comment_id}/edit")] async fn comment_edit_save(cx: &Cx, Form(form): Form) -> Result { item_comment_edit_save(cx, ItemKind::Issue, form).await } #[route(POST "/{owner}/{repo}/issues/{number}/comments/{comment_id}/delete")] async fn comment_delete(cx: &Cx, Form(form): Form) -> Result { item_comment_delete(cx, ItemKind::Issue, form).await } // Pull request routes: the same handlers, resolved through `pull_by_number`. #[page("/{owner}/{repo}/pulls/{number}/edit")] async fn pull_edit_form(cx: &Cx) -> Result { item_edit_form(cx, ItemKind::Pull).await } #[page(POST "/{owner}/{repo}/pulls/{number}/edit")] async fn pull_edit_save(cx: &Cx, Form(form): Form) -> Result { item_edit_save(cx, ItemKind::Pull, form).await } #[page("/{owner}/{repo}/pulls/{number}/comments/{comment_id}/edit")] async fn pull_comment_edit_form(cx: &Cx) -> Result { item_comment_edit_form(cx, ItemKind::Pull).await } #[page(POST "/{owner}/{repo}/pulls/{number}/comments/{comment_id}/edit")] async fn pull_comment_edit_save(cx: &Cx, Form(form): Form) -> Result { item_comment_edit_save(cx, ItemKind::Pull, form).await } #[route(POST "/{owner}/{repo}/pulls/{number}/comments/{comment_id}/delete")] async fn pull_comment_delete(cx: &Cx, Form(form): Form) -> Result { item_comment_delete(cx, ItemKind::Pull, form).await } #[cfg(test)] mod tests { use forge_core::token::hash_token; use forge_core::{Role, User, Visibility}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::{SESSION_COOKIE, percent_encode}; use crate::pages::commits::test_support::{Fixture, OWNER, REPO, get}; fn cookie(token: &str) -> String { format!("__Host-{SESSION_COOKIE}={token}") } /// Sends one request through the router and returns `(status, location, body)`. async fn send( router: &Router, method: &str, uri: &str, token: Option<&str>, form: Option, ) -> (u16, Option, String) { let mut req = Request::builder().method(method).uri(uri); if let Some(token) = token { req = req.header("cookie", cookie(token)); } let body = match form { Some(form) => { req = req.header("content-type", "application/x-www-form-urlencoded"); Body::from(form) } None => Body::empty(), }; let res = router.handle(req.body(body).unwrap()).await; let status = res.status().as_u16(); let location = res .headers() .get("location") .map(|v| v.to_str().unwrap().to_owned()); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); ( status, location, String::from_utf8_lossy(&bytes).into_owned(), ) } /// Signed-in user with a session whose CSRF token is `{token}-csrf`. async fn signed_in(fx: &Fixture, name: &str, token: &str) -> User { let user = fx .store .create_user(name, None, "not-a-real-hash", false) .await .unwrap(); crate::test_support::create_session(&fx.store, &user.id, token, &format!("{token}-csrf")) .await; user } fn csrf_form(token: &str, pairs: &[(&str, &str)]) -> String { let mut form = format!("csrf={}", percent_encode(&format!("{token}-csrf"))); for (key, value) in pairs { form.push_str(&format!("&{key}={}", percent_encode(value))); } form } async fn repo_id(fx: &Fixture) -> String { fx.store .repo_by_owner_and_name(OWNER, REPO) .await .unwrap() .unwrap() .id } /// The repo owner, signed in with token `alice` (CSRF `alice-csrf`). async fn alice(fx: &Fixture) -> User { let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap(); if fx .store .session_by_token_hash(&hash_token("alice")) .await .unwrap() .is_none() { crate::test_support::create_session(&fx.store, &owner.id, "alice", "alice-csrf").await; } owner } async fn issue_state(fx: &Fixture, number: i64) -> (bool, Option) { let issue = fx .store .issue_by_number(&repo_id(fx).await, number) .await .unwrap() .unwrap(); ( matches!(issue.state, forge_core::IssueState::Open), issue.state_reason.map(|r| r.as_str().to_owned()), ) } const ISSUE: &str = "/alice/demo/issues/1"; #[tokio::test] async fn renders_body_markdown_safely_and_links_refs() { let fx = Fixture::new(Visibility::Public).await; let owner = alice(&fx).await; let id = repo_id(&fx).await; fx.store .create_issue( &id, &owner.id, "First", "# Heading\n\n\n\nsee #1", ) .await .unwrap(); let (status, _, body) = get(&fx.router, ISSUE).await; assert_eq!(status, 200); let html = String::from_utf8(body).unwrap(); assert!(html.contains(">Heading")); assert!(html.contains("class=\"issue-ref\"")); assert!(html.contains("href=\"/alice/demo/issues/1\"")); } #[tokio::test] async fn bad_numbers_and_missing_issues_are_404() { let fx = Fixture::new(Visibility::Public).await; for uri in [ "/alice/demo/issues/0", "/alice/demo/issues/abc", "/alice/demo/issues/9", ] { let (status, _, _) = get(&fx.router, uri).await; assert_eq!(status, 404, "{uri}"); } } #[tokio::test] async fn anonymous_sees_no_forms_and_private_is_404() { let fx = Fixture::new(Visibility::Public).await; let owner = alice(&fx).await; let id = repo_id(&fx).await; fx.store .create_issue(&id, &owner.id, "First", "body") .await .unwrap(); let (status, _, body) = get(&fx.router, ISSUE).await; assert_eq!(status, 200); let html = String::from_utf8(body).unwrap(); assert!(!html.contains("name=\"csrf\"")); assert!(!html.contains("/issues/1/labels")); let private = Fixture::new(Visibility::Private).await; let owner = alice(&private).await; let id = repo_id(&private).await; private .store .create_issue(&id, &owner.id, "Secret", "body") .await .unwrap(); let (status, _, _) = get(&private.router, ISSUE).await; assert_eq!(status, 404); } #[tokio::test] async fn owner_gets_triage_forms_in_sidebar() { let fx = Fixture::new(Visibility::Public).await; let owner = alice(&fx).await; let id = repo_id(&fx).await; fx.store .create_issue(&id, &owner.id, "First", "body") .await .unwrap(); let (status, _, body) = send(&fx.router, "GET", ISSUE, Some("alice"), None).await; assert_eq!(status, 200); assert!(body.contains("/issues/1/labels")); assert!(body.contains("name=\"assignees\"")); assert!(body.contains("name=\"milestone_id\"")); let (_, _, body) = send(&fx.router, "GET", ISSUE, None, None).await; assert!(!body.contains("/issues/1/labels")); assert!(!body.contains("name=\"assignees\"")); assert!(!body.contains("name=\"milestone_id\"")); } #[tokio::test] async fn comment_as_another_user_redirects_to_the_comment() { let fx = Fixture::new(Visibility::Public).await; let owner = alice(&fx).await; let id = repo_id(&fx).await; fx.store .create_issue(&id, &owner.id, "First", "body") .await .unwrap(); signed_in(&fx, "bob", "bob-token").await; let form = csrf_form( "bob-token", &[("body", "Looks good"), ("action", "comment")], ); let (status, location, _) = send( &fx.router, "POST", "/alice/demo/issues/1/comment", Some("bob-token"), Some(form), ) .await; assert_eq!(status, 303); let issue = fx.store.issue_by_number(&id, 1).await.unwrap().unwrap(); let comments = fx.store.list_comments(&issue.id).await.unwrap(); assert_eq!(comments.len(), 1); assert_eq!( location.unwrap(), format!("/alice/demo/issues/1#comment-{}", comments[0].id) ); let (_, _, body) = get(&fx.router, ISSUE).await; assert!(String::from_utf8(body).unwrap().contains("Looks good")); } #[tokio::test] async fn empty_comment_is_a_validation_error() { let fx = Fixture::new(Visibility::Public).await; let owner = alice(&fx).await; let id = repo_id(&fx).await; let issue = fx .store .create_issue(&id, &owner.id, "First", "body") .await .unwrap(); let form = csrf_form("alice", &[("body", " "), ("action", "comment")]); let (status, _, body) = send( &fx.router, "POST", "/alice/demo/issues/1/comment", Some("alice"), Some(form), ) .await; assert_eq!(status, 200); assert!(body.contains("Comment cannot be empty.")); assert!(fx.store.list_comments(&issue.id).await.unwrap().is_empty()); } #[tokio::test] async fn author_closes_as_not_planned() { let fx = Fixture::new(Visibility::Public).await; let owner = alice(&fx).await; let id = repo_id(&fx).await; let issue = fx .store .create_issue(&id, &owner.id, "First", "body") .await .unwrap(); let form = csrf_form("alice", &[("action", "close_not_planned")]); let (status, location, _) = send( &fx.router, "POST", "/alice/demo/issues/1/comment", Some("alice"), Some(form), ) .await; assert_eq!(status, 303); assert_eq!(location.unwrap(), "/alice/demo/issues/1#issue-1"); assert_eq!( issue_state(&fx, 1).await, (false, Some("not_planned".to_owned())) ); let events = fx.store.list_events(&issue.id).await.unwrap(); assert!(events.iter().any(|e| e.kind == "closed")); let (_, _, body) = get(&fx.router, ISSUE).await; assert!( String::from_utf8(body) .unwrap() .contains("Closed as not planned") ); } #[tokio::test] async fn non_author_cannot_close_but_can_comment() { let fx = Fixture::new(Visibility::Public).await; let owner = alice(&fx).await; let id = repo_id(&fx).await; fx.store .create_issue(&id, &owner.id, "First", "body") .await .unwrap(); signed_in(&fx, "bob", "bob-token").await; let form = csrf_form("bob-token", &[("action", "close_completed")]); let (status, _, _) = send( &fx.router, "POST", "/alice/demo/issues/1/comment", Some("bob-token"), Some(form), ) .await; assert_eq!(status, 403); assert_eq!(issue_state(&fx, 1).await, (true, None)); let form = csrf_form("bob-token", &[("body", "Me too"), ("action", "comment")]); let (status, _, _) = send( &fx.router, "POST", "/alice/demo/issues/1/comment", Some("bob-token"), Some(form), ) .await; assert_eq!(status, 303); } #[tokio::test] async fn triage_collaborator_closes_and_edits_others_comments() { let fx = Fixture::new(Visibility::Public).await; let owner = alice(&fx).await; let id = repo_id(&fx).await; let issue = fx .store .create_issue(&id, &owner.id, "First", "body") .await .unwrap(); let carol = signed_in(&fx, "carol", "carol-token").await; fx.store .set_collaborator(&id, &carol.id, Role::Triage) .await .unwrap(); let bob = signed_in(&fx, "bob", "bob-token").await; let comment = fx .store .add_comment(&issue.id, &bob.id, "original") .await .unwrap(); let form = csrf_form("carol-token", &[("action", "close_completed")]); let (status, _, _) = send( &fx.router, "POST", "/alice/demo/issues/1/comment", Some("carol-token"), Some(form), ) .await; assert_eq!(status, 303); assert_eq!( issue_state(&fx, 1).await, (false, Some("completed".to_owned())) ); let uri = format!("/alice/demo/issues/1/comments/{}/edit", comment.id); let (status, _, _) = get(&fx.router, &uri).await; assert_eq!(status, 303, "anonymous goes to login"); let (status, _, _) = send(&fx.router, "GET", &uri, Some("carol-token"), None).await; assert_eq!(status, 200); let form = csrf_form("carol-token", &[("body", "edited by triage")]); let (status, _, _) = send(&fx.router, "POST", &uri, Some("carol-token"), Some(form)).await; assert_eq!(status, 303); let saved = fx .store .comment_by_id(&issue.id, &comment.id) .await .unwrap() .unwrap(); assert_eq!(saved.body, "edited by triage"); assert!(saved.edited); } #[tokio::test] async fn edit_issue_updates_title_and_records_rename() { let fx = Fixture::new(Visibility::Public).await; let owner = alice(&fx).await; let id = repo_id(&fx).await; let issue = fx .store .create_issue(&id, &owner.id, "old", "old body") .await .unwrap(); let form = csrf_form("alice", &[("title", "new"), ("body", "new body")]); let (status, location, _) = send( &fx.router, "POST", "/alice/demo/issues/1/edit", Some("alice"), Some(form), ) .await; assert_eq!(status, 303); assert_eq!(location.unwrap(), "/alice/demo/issues/1"); let saved = fx.store.issue_by_number(&id, 1).await.unwrap().unwrap(); assert_eq!( (saved.title.as_str(), saved.body.as_str()), ("new", "new body") ); // The store writes the previous version to edit_history on every edit; the rename event // is the visible part of that write. let events = fx.store.list_events(&issue.id).await.unwrap(); assert!(events.iter().any(|e| e.kind == "renamed")); } #[tokio::test] async fn deleting_a_comment_decrements_the_count() { let fx = Fixture::new(Visibility::Public).await; let owner = alice(&fx).await; let id = repo_id(&fx).await; let issue = fx .store .create_issue(&id, &owner.id, "First", "body") .await .unwrap(); let first = fx .store .add_comment(&issue.id, &owner.id, "one") .await .unwrap(); fx.store .add_comment(&issue.id, &owner.id, "two") .await .unwrap(); assert_eq!( fx.store .issue_by_number(&id, 1) .await .unwrap() .unwrap() .comment_count, 2 ); let uri = format!("/alice/demo/issues/1/comments/{}/delete", first.id); let form = csrf_form("alice", &[]); let (status, _, _) = send(&fx.router, "POST", &uri, Some("alice"), Some(form)).await; assert_eq!(status, 303); assert_eq!( fx.store .issue_by_number(&id, 1) .await .unwrap() .unwrap() .comment_count, 1 ); } #[tokio::test] async fn comment_id_from_another_issue_is_404() { let fx = Fixture::new(Visibility::Public).await; let owner = alice(&fx).await; let id = repo_id(&fx).await; fx.store .create_issue(&id, &owner.id, "First", "body") .await .unwrap(); let other = fx .store .create_issue(&id, &owner.id, "Second", "body") .await .unwrap(); let foreign = fx .store .add_comment(&other.id, &owner.id, "elsewhere") .await .unwrap(); let edit = format!("/alice/demo/issues/1/comments/{}/edit", foreign.id); let (status, _, _) = send(&fx.router, "GET", &edit, Some("alice"), None).await; assert_eq!(status, 404); let delete = format!("/alice/demo/issues/1/comments/{}/delete", foreign.id); let form = csrf_form("alice", &[]); let (status, _, _) = send(&fx.router, "POST", &delete, Some("alice"), Some(form)).await; assert_eq!(status, 404); } #[tokio::test] async fn archived_repo_rejects_writes() { let fx = Fixture::new(Visibility::Public).await; let owner = alice(&fx).await; let id = repo_id(&fx).await; fx.store .create_issue(&id, &owner.id, "First", "body") .await .unwrap(); fx.store.set_repo_archived(&id, true).await.unwrap(); let form = csrf_form("alice", &[("body", "nope"), ("action", "comment")]); let (status, _, _) = send( &fx.router, "POST", "/alice/demo/issues/1/comment", Some("alice"), Some(form), ) .await; assert_eq!(status, 403); let (_, _, body) = send(&fx.router, "GET", ISSUE, Some("alice"), None).await; assert!(!body.contains("name=\"body\"")); } }