forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
37.6 KiB
1//! Page: one issue at `/{owner}/{repo}/issues/{n}`, plus its comment, edit and delete routes.
2//! The body is the first timeline entry, then comments and events merged by time. Triage holders
3//! get label, assignee and milestone forms in the sidebar (posted to `issue_meta`); others get
4//! read-only lists. Archived repos are read-only: no write forms, and every POST gets 403.
5//! The timeline, sidebar and comment POST logic are shared with the pull request pages
6//! (`issue_parts`). A pull request number under `/issues/{n}` redirects to `/pulls/{n}`.
7
8use forge_core::{Comment, Issue, IssueState, StateReason};
9use forge_core::{validate_body, validate_title};
10use serde::Deserialize;
11use topcoat::Result;
12use topcoat::context::{Cx, app_context};
13use topcoat::router::content::Form;
14use topcoat::router::error::{SeeOther, forbidden, not_found, see_other};
15use topcoat::router::{page, path_param, route};
16use topcoat::view::{View, view};
17
18use crate::auth::require_user;
19use crate::i18n;
20use crate::i18n::issues::{self as g, K};
21use crate::notify;
22use crate::pages::issue_parts::{
23 CommentForm, ItemKind, Posted, check_write, comment_for, comment_form, csrf_of,
24 discussion_timeline, issue_for, item_for, item_sidebar, load_discussion, load_sidebar,
25 lock_message, may_change, may_converse, parse_number, post_comment, triage,
26};
27use crate::pages::issue_pin::pin_box;
28use crate::pages::notifications::notify_box_for;
29use crate::pages::repo::repo_head;
30use crate::pages::repo_ctx::{RepoCtx, load_repo};
31use crate::state::AppState;
32use crate::views::{alert, repo_href, shell, user_facing_message, user_link};
33use crate::webhooks::emit::{Sender, emit_comment, emit_item_action};
34
35path_param!(owner);
36path_param!(repo);
37path_param!(number);
38path_param!(comment_id);
39
40/// The issue page: header, timeline, comment form and sidebar. `draft` and `error` belong to
41/// the comment form, so a failed comment keeps its text.
42async fn issue_page(
43 cx: &Cx,
44 ctx: RepoCtx,
45 issue: Issue,
46 draft: String,
47 error: Option<String>,
48) -> Result<impl View> {
49 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u));
50 let state = app_context::<AppState>(cx);
51 let owner = ctx.repo.owner_name.clone();
52 let name = ctx.repo.name.clone();
53 let number = issue.number;
54 let issue_href = repo_href(&owner, &name, "issues", &number.to_string());
55
56 let discussion = load_discussion(lang, state, &ctx, &issue, ItemKind::Issue, "").await?;
57 let sidebar = load_sidebar(state, &ctx.repo.id, &issue).await?;
58 let pinned = state.store.is_pinned(&ctx.repo.id, &issue.id).await?;
59 let pin_action = format!("{issue_href}/pin");
60 let notify = notify_box_for(state, &ctx, lang, &issue.id, &issue_href).await?;
61 let author = discussion.author.clone();
62 let author_login = discussion.author_login.clone();
63 let opened = discussion.opened.clone();
64
65 let csrf = csrf_of(&ctx);
66 let archived = ctx.repo.archived;
67 let signed_in = ctx.viewer.is_some();
68 let editable_issue = may_change(&ctx, &issue.author_id);
69 let write_meta = !archived && triage(&ctx);
70 let can_comment = signed_in && !archived && may_converse(&ctx, &issue);
71 let locked_for_viewer = !may_converse(&ctx, &issue);
72 let lock_notice = lock_message(lang, &issue);
73 let open = matches!(issue.state, IssueState::Open);
74
75 let (state_class, state_text) = match (&issue.state, &issue.state_reason) {
76 (IssueState::Open, _) => ("issue-badge issue-badge-open", g::t(lang, K::Open)),
77 (IssueState::Closed, Some(StateReason::NotPlanned)) => (
78 "issue-badge issue-badge-not-planned",
79 g::t(lang, K::StateClosedNotPlanned),
80 ),
81 (IssueState::Closed, _) => ("issue-badge issue-badge-completed", g::t(lang, K::Closed)),
82 };
83 let comments_label = g::comment_count(lang, issue.comment_count);
84 let page_title = format!("{} #{number} \u{b7} {owner}/{name}", issue.title);
85 let number_label = format!("#{number}");
86 let login_href = "/login".to_owned();
87 let comment_action = format!("{issue_href}/comment");
88 let labels_href = repo_href(&owner, &name, "labels.css", "");
89
90 let header = ctx.head(state, "issues").await?;
91 Ok(view! { cx =>
92 shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), extra_stylesheet: Some(labels_href.as_str()),
93 repo_head(head: header)
94 <section class="issue-head">
95 <h1 class="issue-heading">(issue.title.as_str()) " " <span class="issue-number">(number_label.as_str())</span></h1>
96 <p class="issue-byline">
97 <span class=(state_class)>(state_text)</span>
98 <strong>user_link(shown: author.as_str(), login: author_login.as_str())</strong>
99 (g::t(lang, K::OpenedThis))
100 <time datetime=(opened.iso.as_str()) title=(opened.absolute.as_str())>(opened.relative.as_str())</time>
101 (g::opened_after(lang))
102 " · "
103 (comments_label.as_str())
104 </p>
105 </section>
106 <div class="issue-layout">
107 <div class="issue-column">
108 discussion_timeline(lang: lang, discussion: discussion)
109 if can_comment {
110 comment_form(lang: lang, action: comment_action.clone(), state_action: comment_action.clone(),
111 csrf: csrf.clone(), draft: draft, error: error, can_close: editable_issue, open: open)
112 }
113 if archived {
114 <p class="muted">(g::t(lang, K::ArchivedIssuesReadOnly))</p>
115 }
116 if locked_for_viewer {
117 <p class="muted">(lock_notice.as_str())</p>
118 } else if !signed_in {
119 <p class="muted">(g::comment_login_lead(lang))<a href=(login_href.as_str())>(g::t(lang, K::LogInLink))</a>(g::t(lang, K::ToComment))</p>
120 }
121 </div>
122 <aside class="issue-sidebar">
123 pin_box(lang: lang, write: write_meta, pinned: pinned, action: pin_action.clone(), csrf: csrf.clone())
124 item_sidebar(lang: lang, write: write_meta, base: issue_href.clone(), csrf: csrf.clone(), sidebar: sidebar, notify: notify)
125 </aside>
126 </div>
127 )
128 })
129}
130
131#[page("/{owner}/{repo}/issues/{number}")]
132async fn issue_show(cx: &Cx) -> Result<impl View> {
133 let number = parse_number(path_param::<Number>(cx))?;
134 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
135 let state = app_context::<AppState>(cx);
136 let Some(issue) = state.store.issue_by_number(&ctx.repo.id, number).await? else {
137 if state
138 .store
139 .pull_by_number(&ctx.repo.id, number)
140 .await?
141 .is_some()
142 {
143 let location = repo_href(
144 &ctx.repo.owner_name,
145 &ctx.repo.name,
146 "pulls",
147 &number.to_string(),
148 );
149 return Err(see_other(location).into());
150 }
151 return Err(not_found().into());
152 };
153 issue_page(cx, ctx, issue, String::new(), None).await
154}
155
156/// Adds a comment and/or changes the state. Only the author or a Triage holder may close or
157/// reopen; without that the request is 403 and nothing is written.
158#[page(POST "/{owner}/{repo}/issues/{number}/comment")]
159async fn issue_comment(cx: &Cx, Form(form): Form<CommentForm>) -> Result<impl View> {
160 let number = parse_number(path_param::<Number>(cx))?;
161 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
162 let (user, session) = require_user(cx).await?;
163 check_write(&ctx, &session, &form.csrf)?;
164 let state = app_context::<AppState>(cx);
165 let issue = issue_for(state, &ctx, number).await?;
166 let lang = i18n::lang(cx, Some(&user));
167 match post_comment(lang, state, &ctx, &user, &issue, false, &form).await? {
168 Posted::Saved { comment_id } => {
169 let anchor = match comment_id {
170 Some(id) => format!("comment-{id}"),
171 None => format!("issue-{number}"),
172 };
173 Err(see_other(format!(
174 "/{}/{}/issues/{number}#{anchor}",
175 ctx.repo.owner_name, ctx.repo.name
176 ))
177 .into())
178 }
179 Posted::Refused { message } => issue_page(cx, ctx, issue, form.body, Some(message)).await,
180 }
181}
182
183#[derive(Deserialize)]
184struct IssueForm {
185 #[serde(default)]
186 csrf: String,
187 #[serde(default)]
188 title: String,
189 #[serde(default)]
190 body: String,
191}
192
193/// Edit form for the title and body of an issue or pull request: author or Triage.
194async fn edit_item_page(
195 cx: &Cx,
196 ctx: RepoCtx,
197 kind: ItemKind,
198 number: i64,
199 title: String,
200 body: String,
201 error: Option<String>,
202) -> Result<impl View> {
203 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u));
204 let state = app_context::<AppState>(cx);
205 let owner = ctx.repo.owner_name.clone();
206 let name = ctx.repo.name.clone();
207 let csrf = csrf_of(&ctx);
208 let segment = kind.segment();
209 let action = repo_href(&owner, &name, segment, &format!("{number}/edit"));
210 let back = repo_href(&owner, &name, segment, &number.to_string());
211 let page_title = g::edit_title(lang, number, &format!("{owner}/{name}"));
212 let heading = g::edit_heading(lang, kind == ItemKind::Pull, number);
213 let header = ctx.head(state, kind_tab(kind)).await?;
214 Ok(view! { cx =>
215 shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(),
216 repo_head(head: header)
217 <section class="card issue-edit">
218 <h2>(heading.as_str())</h2>
219 if let Some(message) = error {
220 alert(message: message.as_str())
221 }
222 <form method="post" action=(action.as_str()) class="stack">
223 <input type="hidden" name="csrf" value=(csrf.as_str())>
224 <label>
225 <span>(g::t(lang, K::Title))</span>
226 <input type="text" name="title" value=(title.as_str()) required=(true) autocomplete="off">
227 </label>
228 <label>
229 <span>(g::t(lang, K::Description))</span>
230 <textarea name="body" rows="12">(body.as_str())</textarea>
231 </label>
232 <div class="issue-actions">
233 <button type="submit">(g::t(lang, K::SaveChanges))</button>
234 <a href=(back.as_str())>(g::t(lang, K::Cancel))</a>
235 </div>
236 </form>
237 </section>
238 )
239 })
240}
241
242/// The nav tab an item's pages highlight.
243fn kind_tab(kind: ItemKind) -> &'static str {
244 match kind {
245 ItemKind::Issue => "issues",
246 ItemKind::Pull => "pulls",
247 }
248}
249
250/// Shows the edit form. Only the author or Triage; anyone else is 403.
251async fn item_edit_form(cx: &Cx, kind: ItemKind) -> Result<impl View> {
252 let number = parse_number(path_param::<Number>(cx))?;
253 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
254 require_user(cx).await?;
255 let item = item_for(app_context::<AppState>(cx), &ctx, kind, number).await?;
256 if !may_change(&ctx, &item.author_id) {
257 return Err(forbidden().into());
258 }
259 edit_item_page(cx, ctx, kind, number, item.title, item.body, None).await
260}
261
262/// Saves the title and body. Only the author or Triage; the store records the edit history.
263async fn item_edit_save(cx: &Cx, kind: ItemKind, form: IssueForm) -> Result<impl View> {
264 let number = parse_number(path_param::<Number>(cx))?;
265 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
266 let (user, session) = require_user(cx).await?;
267 check_write(&ctx, &session, &form.csrf)?;
268 let state = app_context::<AppState>(cx);
269 let item = item_for(state, &ctx, kind, number).await?;
270 if !may_change(&ctx, &item.author_id) {
271 return Err(forbidden().into());
272 }
273 let title = match validate_title(&form.title) {
274 Ok(title) => title,
275 Err(e) => {
276 let message = user_facing_message(&e).unwrap_or_else(|| e.to_string());
277 return edit_item_page(cx, ctx, kind, number, form.title, form.body, Some(message))
278 .await;
279 }
280 };
281 if let Err(e) = validate_body(&form.body) {
282 let message = user_facing_message(&e).unwrap_or_else(|| e.to_string());
283 return edit_item_page(cx, ctx, kind, number, title, form.body, Some(message)).await;
284 }
285 state
286 .store
287 .edit_issue(&item.id, &user.id, &title, &form.body)
288 .await?;
289 let pull = matches!(kind, ItemKind::Pull);
290 emit_item_action(state, &ctx.repo, &Sender::of(&user), pull, number, "edited").await;
291 notify::dispatch_edit(state, &ctx.repo, &item, &user.id, &item.body, &form.body).await;
292 Err(see_other(format!(
293 "/{}/{}/{}/{number}",
294 ctx.repo.owner_name,
295 ctx.repo.name,
296 kind.segment()
297 ))
298 .into())
299}
300
301/// Edit form for one comment: its author or Triage.
302async fn edit_comment_page(
303 cx: &Cx,
304 ctx: RepoCtx,
305 kind: ItemKind,
306 number: i64,
307 comment_id: String,
308 body: String,
309 error: Option<String>,
310) -> Result<impl View> {
311 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u));
312 let state = app_context::<AppState>(cx);
313 let owner = ctx.repo.owner_name.clone();
314 let name = ctx.repo.name.clone();
315 let csrf = csrf_of(&ctx);
316 let segment = kind.segment();
317 let action = repo_href(
318 &owner,
319 &name,
320 segment,
321 &format!("{number}/comments/{comment_id}/edit"),
322 );
323 let back = format!(
324 "{}#comment-{comment_id}",
325 repo_href(&owner, &name, segment, &number.to_string())
326 );
327 let page_title = g::edit_comment_title(lang, number, &format!("{owner}/{name}"));
328 let header = ctx.head(state, kind_tab(kind)).await?;
329 Ok(view! { cx =>
330 shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(),
331 repo_head(head: header)
332 <section class="card issue-edit">
333 <h2>(g::t(lang, K::EditComment))</h2>
334 if let Some(message) = error {
335 alert(message: message.as_str())
336 }
337 <form method="post" action=(action.as_str()) class="stack">
338 <input type="hidden" name="csrf" value=(csrf.as_str())>
339 <label>
340 <span>(g::t(lang, K::Comment))</span>
341 <textarea name="body" rows="10">(body.as_str())</textarea>
342 </label>
343 <div class="issue-actions">
344 <button type="submit" class="primary">(g::t(lang, K::SaveComment))</button>
345 <a href=(back.as_str())>(g::t(lang, K::Cancel))</a>
346 </div>
347 </form>
348 </section>
349 )
350 })
351}
352
353/// Shows the edit form for a comment. Its author or Triage; anyone else is 403.
354async fn item_comment_edit_form(cx: &Cx, kind: ItemKind) -> Result<impl View> {
355 let number = parse_number(path_param::<Number>(cx))?;
356 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
357 require_user(cx).await?;
358 let state = app_context::<AppState>(cx);
359 let item = item_for(state, &ctx, kind, number).await?;
360 let comment = comment_for(state, &item, path_param::<CommentId>(cx)).await?;
361 if !may_change(&ctx, &comment.author_id) {
362 return Err(forbidden().into());
363 }
364 edit_comment_page(
365 cx,
366 ctx,
367 kind,
368 number,
369 comment.id.clone(),
370 comment.body,
371 None,
372 )
373 .await
374}
375
376/// Saves an edited comment. Its author or Triage; the store records the previous body.
377async fn item_comment_edit_save(cx: &Cx, kind: ItemKind, form: CommentForm) -> Result<impl View> {
378 let number = parse_number(path_param::<Number>(cx))?;
379 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
380 let (user, session) = require_user(cx).await?;
381 check_write(&ctx, &session, &form.csrf)?;
382 let state = app_context::<AppState>(cx);
383 let item = item_for(state, &ctx, kind, number).await?;
384 let comment = comment_for(state, &item, path_param::<CommentId>(cx)).await?;
385 if !may_change(&ctx, &comment.author_id) {
386 return Err(forbidden().into());
387 }
388 if form.body.trim().is_empty() {
389 let lang = i18n::lang(cx, Some(&user));
390 return edit_comment_page(
391 cx,
392 ctx,
393 kind,
394 number,
395 comment.id.clone(),
396 form.body,
397 Some(g::t(lang, K::CommentEmpty).to_owned()),
398 )
399 .await;
400 }
401 if let Err(e) = validate_body(&form.body) {
402 let message = user_facing_message(&e).unwrap_or_else(|| e.to_string());
403 return edit_comment_page(
404 cx,
405 ctx,
406 kind,
407 number,
408 comment.id.clone(),
409 form.body,
410 Some(message),
411 )
412 .await;
413 }
414 state
415 .store
416 .edit_comment(&comment.id, &user.id, &form.body)
417 .await?;
418 let edited = Comment {
419 body: form.body.clone(),
420 edited: true,
421 ..comment.clone()
422 };
423 let pull = matches!(kind, ItemKind::Pull);
424 let sender = Sender::of(&user);
425 emit_comment(state, &ctx.repo, &sender, "edited", pull, &item, &edited).await;
426 notify::dispatch_edit(state, &ctx.repo, &item, &user.id, &comment.body, &form.body).await;
427 Err(see_other(format!(
428 "/{}/{}/{}/{number}#comment-{}",
429 ctx.repo.owner_name,
430 ctx.repo.name,
431 kind.segment(),
432 comment.id
433 ))
434 .into())
435}
436
437#[derive(Deserialize)]
438struct CsrfForm {
439 #[serde(default)]
440 csrf: String,
441}
442
443/// Deletes one comment: its author or Triage. The item's comment count goes down.
444async fn item_comment_delete(cx: &Cx, kind: ItemKind, form: CsrfForm) -> Result<SeeOther> {
445 let number = parse_number(path_param::<Number>(cx))?;
446 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
447 let (user, session) = require_user(cx).await?;
448 check_write(&ctx, &session, &form.csrf)?;
449 let state = app_context::<AppState>(cx);
450 let item = item_for(state, &ctx, kind, number).await?;
451 let comment = comment_for(state, &item, path_param::<CommentId>(cx)).await?;
452 if !may_change(&ctx, &comment.author_id) {
453 return Err(forbidden().into());
454 }
455 state.store.delete_comment(&comment.id).await?;
456 let pull = matches!(kind, ItemKind::Pull);
457 emit_comment(
458 state,
459 &ctx.repo,
460 &Sender::of(&user),
461 "deleted",
462 pull,
463 &item,
464 &comment,
465 )
466 .await;
467 Ok(see_other(format!(
468 "/{}/{}/{}/{number}",
469 ctx.repo.owner_name,
470 ctx.repo.name,
471 kind.segment()
472 )))
473}
474
475// Issue routes. A pull request number under these paths is 404: `issue_by_number` skips pulls.
476
477#[page("/{owner}/{repo}/issues/{number}/edit")]
478async fn issue_edit_form(cx: &Cx) -> Result<impl View> {
479 item_edit_form(cx, ItemKind::Issue).await
480}
481
482#[page(POST "/{owner}/{repo}/issues/{number}/edit")]
483async fn issue_edit_save(cx: &Cx, Form(form): Form<IssueForm>) -> Result<impl View> {
484 item_edit_save(cx, ItemKind::Issue, form).await
485}
486
487#[page("/{owner}/{repo}/issues/{number}/comments/{comment_id}/edit")]
488async fn comment_edit_form(cx: &Cx) -> Result<impl View> {
489 item_comment_edit_form(cx, ItemKind::Issue).await
490}
491
492#[page(POST "/{owner}/{repo}/issues/{number}/comments/{comment_id}/edit")]
493async fn comment_edit_save(cx: &Cx, Form(form): Form<CommentForm>) -> Result<impl View> {
494 item_comment_edit_save(cx, ItemKind::Issue, form).await
495}
496
497#[route(POST "/{owner}/{repo}/issues/{number}/comments/{comment_id}/delete")]
498async fn comment_delete(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
499 item_comment_delete(cx, ItemKind::Issue, form).await
500}
501
502// Pull request routes: the same handlers, resolved through `pull_by_number`.
503
504#[page("/{owner}/{repo}/pulls/{number}/edit")]
505async fn pull_edit_form(cx: &Cx) -> Result<impl View> {
506 item_edit_form(cx, ItemKind::Pull).await
507}
508
509#[page(POST "/{owner}/{repo}/pulls/{number}/edit")]
510async fn pull_edit_save(cx: &Cx, Form(form): Form<IssueForm>) -> Result<impl View> {
511 item_edit_save(cx, ItemKind::Pull, form).await
512}
513
514#[page("/{owner}/{repo}/pulls/{number}/comments/{comment_id}/edit")]
515async fn pull_comment_edit_form(cx: &Cx) -> Result<impl View> {
516 item_comment_edit_form(cx, ItemKind::Pull).await
517}
518
519#[page(POST "/{owner}/{repo}/pulls/{number}/comments/{comment_id}/edit")]
520async fn pull_comment_edit_save(cx: &Cx, Form(form): Form<CommentForm>) -> Result<impl View> {
521 item_comment_edit_save(cx, ItemKind::Pull, form).await
522}
523
524#[route(POST "/{owner}/{repo}/pulls/{number}/comments/{comment_id}/delete")]
525async fn pull_comment_delete(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
526 item_comment_delete(cx, ItemKind::Pull, form).await
527}
528
529#[cfg(test)]
530mod tests {
531 use forge_core::token::hash_token;
532 use forge_core::{Role, User, Visibility};
533 use topcoat::router::request::Request;
534 use topcoat::router::{Body, Router, to_bytes};
535
536 use crate::auth::{SESSION_COOKIE, percent_encode};
537 use crate::pages::commits::test_support::{Fixture, OWNER, REPO, get};
538
539 fn cookie(token: &str) -> String {
540 format!("__Host-{SESSION_COOKIE}={token}")
541 }
542
543 /// Sends one request through the router and returns `(status, location, body)`.
544 async fn send(
545 router: &Router,
546 method: &str,
547 uri: &str,
548 token: Option<&str>,
549 form: Option<String>,
550 ) -> (u16, Option<String>, String) {
551 let mut req = Request::builder().method(method).uri(uri);
552 if let Some(token) = token {
553 req = req.header("cookie", cookie(token));
554 }
555 let body = match form {
556 Some(form) => {
557 req = req.header("content-type", "application/x-www-form-urlencoded");
558 Body::from(form)
559 }
560 None => Body::empty(),
561 };
562 let res = router.handle(req.body(body).unwrap()).await;
563 let status = res.status().as_u16();
564 let location = res
565 .headers()
566 .get("location")
567 .map(|v| v.to_str().unwrap().to_owned());
568 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
569 (
570 status,
571 location,
572 String::from_utf8_lossy(&bytes).into_owned(),
573 )
574 }
575
576 /// Signed-in user with a session whose CSRF token is `{token}-csrf`.
577 async fn signed_in(fx: &Fixture, name: &str, token: &str) -> User {
578 let user = fx
579 .store
580 .create_user(name, None, "not-a-real-hash", false)
581 .await
582 .unwrap();
583 crate::test_support::create_session(&fx.store, &user.id, token, &format!("{token}-csrf"))
584 .await;
585 user
586 }
587
588 fn csrf_form(token: &str, pairs: &[(&str, &str)]) -> String {
589 let mut form = format!("csrf={}", percent_encode(&format!("{token}-csrf")));
590 for (key, value) in pairs {
591 form.push_str(&format!("&{key}={}", percent_encode(value)));
592 }
593 form
594 }
595
596 async fn repo_id(fx: &Fixture) -> String {
597 fx.store
598 .repo_by_owner_and_name(OWNER, REPO)
599 .await
600 .unwrap()
601 .unwrap()
602 .id
603 }
604
605 /// The repo owner, signed in with token `alice` (CSRF `alice-csrf`).
606 async fn alice(fx: &Fixture) -> User {
607 let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap();
608 if fx
609 .store
610 .session_by_token_hash(&hash_token("alice"))
611 .await
612 .unwrap()
613 .is_none()
614 {
615 crate::test_support::create_session(&fx.store, &owner.id, "alice", "alice-csrf").await;
616 }
617 owner
618 }
619
620 async fn issue_state(fx: &Fixture, number: i64) -> (bool, Option<String>) {
621 let issue = fx
622 .store
623 .issue_by_number(&repo_id(fx).await, number)
624 .await
625 .unwrap()
626 .unwrap();
627 (
628 matches!(issue.state, forge_core::IssueState::Open),
629 issue.state_reason.map(|r| r.as_str().to_owned()),
630 )
631 }
632
633 const ISSUE: &str = "/alice/demo/issues/1";
634
635 #[tokio::test]
636 async fn renders_body_markdown_safely_and_links_refs() {
637 let fx = Fixture::new(Visibility::Public).await;
638 let owner = alice(&fx).await;
639 let id = repo_id(&fx).await;
640 fx.store
641 .create_issue(
642 &id,
643 &owner.id,
644 "First",
645 "# Heading\n\n<script>alert(1)</script>\n\nsee #1",
646 )
647 .await
648 .unwrap();
649 let (status, _, body) = get(&fx.router, ISSUE).await;
650 assert_eq!(status, 200);
651 let html = String::from_utf8(body).unwrap();
652 assert!(html.contains(">Heading<a"));
653 assert!(!html.contains("<script>"));
654 assert!(html.contains("class=\"issue-ref\""));
655 assert!(html.contains("href=\"/alice/demo/issues/1\""));
656 }
657
658 #[tokio::test]
659 async fn bad_numbers_and_missing_issues_are_404() {
660 let fx = Fixture::new(Visibility::Public).await;
661 for uri in [
662 "/alice/demo/issues/0",
663 "/alice/demo/issues/abc",
664 "/alice/demo/issues/9",
665 ] {
666 let (status, _, _) = get(&fx.router, uri).await;
667 assert_eq!(status, 404, "{uri}");
668 }
669 }
670
671 #[tokio::test]
672 async fn anonymous_sees_no_forms_and_private_is_404() {
673 let fx = Fixture::new(Visibility::Public).await;
674 let owner = alice(&fx).await;
675 let id = repo_id(&fx).await;
676 fx.store
677 .create_issue(&id, &owner.id, "First", "body")
678 .await
679 .unwrap();
680 let (status, _, body) = get(&fx.router, ISSUE).await;
681 assert_eq!(status, 200);
682 let html = String::from_utf8(body).unwrap();
683 assert!(!html.contains("name=\"csrf\""));
684 assert!(!html.contains("/issues/1/labels"));
685
686 let private = Fixture::new(Visibility::Private).await;
687 let owner = alice(&private).await;
688 let id = repo_id(&private).await;
689 private
690 .store
691 .create_issue(&id, &owner.id, "Secret", "body")
692 .await
693 .unwrap();
694 let (status, _, _) = get(&private.router, ISSUE).await;
695 assert_eq!(status, 404);
696 }
697
698 #[tokio::test]
699 async fn owner_gets_triage_forms_in_sidebar() {
700 let fx = Fixture::new(Visibility::Public).await;
701 let owner = alice(&fx).await;
702 let id = repo_id(&fx).await;
703 fx.store
704 .create_issue(&id, &owner.id, "First", "body")
705 .await
706 .unwrap();
707 let (status, _, body) = send(&fx.router, "GET", ISSUE, Some("alice"), None).await;
708 assert_eq!(status, 200);
709 assert!(body.contains("/issues/1/labels"));
710 assert!(body.contains("name=\"assignees\""));
711 assert!(body.contains("name=\"milestone_id\""));
712 let (_, _, body) = send(&fx.router, "GET", ISSUE, None, None).await;
713 assert!(!body.contains("/issues/1/labels"));
714 assert!(!body.contains("name=\"assignees\""));
715 assert!(!body.contains("name=\"milestone_id\""));
716 }
717
718 #[tokio::test]
719 async fn comment_as_another_user_redirects_to_the_comment() {
720 let fx = Fixture::new(Visibility::Public).await;
721 let owner = alice(&fx).await;
722 let id = repo_id(&fx).await;
723 fx.store
724 .create_issue(&id, &owner.id, "First", "body")
725 .await
726 .unwrap();
727 signed_in(&fx, "bob", "bob-token").await;
728 let form = csrf_form(
729 "bob-token",
730 &[("body", "Looks good"), ("action", "comment")],
731 );
732 let (status, location, _) = send(
733 &fx.router,
734 "POST",
735 "/alice/demo/issues/1/comment",
736 Some("bob-token"),
737 Some(form),
738 )
739 .await;
740 assert_eq!(status, 303);
741 let issue = fx.store.issue_by_number(&id, 1).await.unwrap().unwrap();
742 let comments = fx.store.list_comments(&issue.id).await.unwrap();
743 assert_eq!(comments.len(), 1);
744 assert_eq!(
745 location.unwrap(),
746 format!("/alice/demo/issues/1#comment-{}", comments[0].id)
747 );
748 let (_, _, body) = get(&fx.router, ISSUE).await;
749 assert!(String::from_utf8(body).unwrap().contains("Looks good"));
750 }
751
752 #[tokio::test]
753 async fn empty_comment_is_a_validation_error() {
754 let fx = Fixture::new(Visibility::Public).await;
755 let owner = alice(&fx).await;
756 let id = repo_id(&fx).await;
757 let issue = fx
758 .store
759 .create_issue(&id, &owner.id, "First", "body")
760 .await
761 .unwrap();
762 let form = csrf_form("alice", &[("body", " "), ("action", "comment")]);
763 let (status, _, body) = send(
764 &fx.router,
765 "POST",
766 "/alice/demo/issues/1/comment",
767 Some("alice"),
768 Some(form),
769 )
770 .await;
771 assert_eq!(status, 200);
772 assert!(body.contains("Comment cannot be empty."));
773 assert!(fx.store.list_comments(&issue.id).await.unwrap().is_empty());
774 }
775
776 #[tokio::test]
777 async fn author_closes_as_not_planned() {
778 let fx = Fixture::new(Visibility::Public).await;
779 let owner = alice(&fx).await;
780 let id = repo_id(&fx).await;
781 let issue = fx
782 .store
783 .create_issue(&id, &owner.id, "First", "body")
784 .await
785 .unwrap();
786 let form = csrf_form("alice", &[("action", "close_not_planned")]);
787 let (status, location, _) = send(
788 &fx.router,
789 "POST",
790 "/alice/demo/issues/1/comment",
791 Some("alice"),
792 Some(form),
793 )
794 .await;
795 assert_eq!(status, 303);
796 assert_eq!(location.unwrap(), "/alice/demo/issues/1#issue-1");
797 assert_eq!(
798 issue_state(&fx, 1).await,
799 (false, Some("not_planned".to_owned()))
800 );
801 let events = fx.store.list_events(&issue.id).await.unwrap();
802 assert!(events.iter().any(|e| e.kind == "closed"));
803 let (_, _, body) = get(&fx.router, ISSUE).await;
804 assert!(
805 String::from_utf8(body)
806 .unwrap()
807 .contains("Closed as not planned")
808 );
809 }
810
811 #[tokio::test]
812 async fn non_author_cannot_close_but_can_comment() {
813 let fx = Fixture::new(Visibility::Public).await;
814 let owner = alice(&fx).await;
815 let id = repo_id(&fx).await;
816 fx.store
817 .create_issue(&id, &owner.id, "First", "body")
818 .await
819 .unwrap();
820 signed_in(&fx, "bob", "bob-token").await;
821 let form = csrf_form("bob-token", &[("action", "close_completed")]);
822 let (status, _, _) = send(
823 &fx.router,
824 "POST",
825 "/alice/demo/issues/1/comment",
826 Some("bob-token"),
827 Some(form),
828 )
829 .await;
830 assert_eq!(status, 403);
831 assert_eq!(issue_state(&fx, 1).await, (true, None));
832
833 let form = csrf_form("bob-token", &[("body", "Me too"), ("action", "comment")]);
834 let (status, _, _) = send(
835 &fx.router,
836 "POST",
837 "/alice/demo/issues/1/comment",
838 Some("bob-token"),
839 Some(form),
840 )
841 .await;
842 assert_eq!(status, 303);
843 }
844
845 #[tokio::test]
846 async fn triage_collaborator_closes_and_edits_others_comments() {
847 let fx = Fixture::new(Visibility::Public).await;
848 let owner = alice(&fx).await;
849 let id = repo_id(&fx).await;
850 let issue = fx
851 .store
852 .create_issue(&id, &owner.id, "First", "body")
853 .await
854 .unwrap();
855 let carol = signed_in(&fx, "carol", "carol-token").await;
856 fx.store
857 .set_collaborator(&id, &carol.id, Role::Triage)
858 .await
859 .unwrap();
860 let bob = signed_in(&fx, "bob", "bob-token").await;
861 let comment = fx
862 .store
863 .add_comment(&issue.id, &bob.id, "original")
864 .await
865 .unwrap();
866
867 let form = csrf_form("carol-token", &[("action", "close_completed")]);
868 let (status, _, _) = send(
869 &fx.router,
870 "POST",
871 "/alice/demo/issues/1/comment",
872 Some("carol-token"),
873 Some(form),
874 )
875 .await;
876 assert_eq!(status, 303);
877 assert_eq!(
878 issue_state(&fx, 1).await,
879 (false, Some("completed".to_owned()))
880 );
881
882 let uri = format!("/alice/demo/issues/1/comments/{}/edit", comment.id);
883 let (status, _, _) = get(&fx.router, &uri).await;
884 assert_eq!(status, 303, "anonymous goes to login");
885 let (status, _, _) = send(&fx.router, "GET", &uri, Some("carol-token"), None).await;
886 assert_eq!(status, 200);
887 let form = csrf_form("carol-token", &[("body", "edited by triage")]);
888 let (status, _, _) = send(&fx.router, "POST", &uri, Some("carol-token"), Some(form)).await;
889 assert_eq!(status, 303);
890 let saved = fx
891 .store
892 .comment_by_id(&issue.id, &comment.id)
893 .await
894 .unwrap()
895 .unwrap();
896 assert_eq!(saved.body, "edited by triage");
897 assert!(saved.edited);
898 }
899
900 #[tokio::test]
901 async fn edit_issue_updates_title_and_records_rename() {
902 let fx = Fixture::new(Visibility::Public).await;
903 let owner = alice(&fx).await;
904 let id = repo_id(&fx).await;
905 let issue = fx
906 .store
907 .create_issue(&id, &owner.id, "old", "old body")
908 .await
909 .unwrap();
910 let form = csrf_form("alice", &[("title", "new"), ("body", "new body")]);
911 let (status, location, _) = send(
912 &fx.router,
913 "POST",
914 "/alice/demo/issues/1/edit",
915 Some("alice"),
916 Some(form),
917 )
918 .await;
919 assert_eq!(status, 303);
920 assert_eq!(location.unwrap(), "/alice/demo/issues/1");
921 let saved = fx.store.issue_by_number(&id, 1).await.unwrap().unwrap();
922 assert_eq!(
923 (saved.title.as_str(), saved.body.as_str()),
924 ("new", "new body")
925 );
926 // The store writes the previous version to edit_history on every edit; the rename event
927 // is the visible part of that write.
928 let events = fx.store.list_events(&issue.id).await.unwrap();
929 assert!(events.iter().any(|e| e.kind == "renamed"));
930 }
931
932 #[tokio::test]
933 async fn deleting_a_comment_decrements_the_count() {
934 let fx = Fixture::new(Visibility::Public).await;
935 let owner = alice(&fx).await;
936 let id = repo_id(&fx).await;
937 let issue = fx
938 .store
939 .create_issue(&id, &owner.id, "First", "body")
940 .await
941 .unwrap();
942 let first = fx
943 .store
944 .add_comment(&issue.id, &owner.id, "one")
945 .await
946 .unwrap();
947 fx.store
948 .add_comment(&issue.id, &owner.id, "two")
949 .await
950 .unwrap();
951 assert_eq!(
952 fx.store
953 .issue_by_number(&id, 1)
954 .await
955 .unwrap()
956 .unwrap()
957 .comment_count,
958 2
959 );
960
961 let uri = format!("/alice/demo/issues/1/comments/{}/delete", first.id);
962 let form = csrf_form("alice", &[]);
963 let (status, _, _) = send(&fx.router, "POST", &uri, Some("alice"), Some(form)).await;
964 assert_eq!(status, 303);
965 assert_eq!(
966 fx.store
967 .issue_by_number(&id, 1)
968 .await
969 .unwrap()
970 .unwrap()
971 .comment_count,
972 1
973 );
974 }
975
976 #[tokio::test]
977 async fn comment_id_from_another_issue_is_404() {
978 let fx = Fixture::new(Visibility::Public).await;
979 let owner = alice(&fx).await;
980 let id = repo_id(&fx).await;
981 fx.store
982 .create_issue(&id, &owner.id, "First", "body")
983 .await
984 .unwrap();
985 let other = fx
986 .store
987 .create_issue(&id, &owner.id, "Second", "body")
988 .await
989 .unwrap();
990 let foreign = fx
991 .store
992 .add_comment(&other.id, &owner.id, "elsewhere")
993 .await
994 .unwrap();
995
996 let edit = format!("/alice/demo/issues/1/comments/{}/edit", foreign.id);
997 let (status, _, _) = send(&fx.router, "GET", &edit, Some("alice"), None).await;
998 assert_eq!(status, 404);
999 let delete = format!("/alice/demo/issues/1/comments/{}/delete", foreign.id);
1000 let form = csrf_form("alice", &[]);
1001 let (status, _, _) = send(&fx.router, "POST", &delete, Some("alice"), Some(form)).await;
1002 assert_eq!(status, 404);
1003 }
1004
1005 #[tokio::test]
1006 async fn archived_repo_rejects_writes() {
1007 let fx = Fixture::new(Visibility::Public).await;
1008 let owner = alice(&fx).await;
1009 let id = repo_id(&fx).await;
1010 fx.store
1011 .create_issue(&id, &owner.id, "First", "body")
1012 .await
1013 .unwrap();
1014 fx.store.set_repo_archived(&id, true).await.unwrap();
1015 let form = csrf_form("alice", &[("body", "nope"), ("action", "comment")]);
1016 let (status, _, _) = send(
1017 &fx.router,
1018 "POST",
1019 "/alice/demo/issues/1/comment",
1020 Some("alice"),
1021 Some(form),
1022 )
1023 .await;
1024 assert_eq!(status, 403);
1025 let (_, _, body) = send(&fx.router, "GET", ISSUE, Some("alice"), None).await;
1026 assert!(!body.contains("name=\"body\""));
1027 }
1028}