forge
ExploreLog in

Tahinli/forge

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