forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
19.8 KiB
1//! Router-level tests for the label, assignee, milestone and issue-metadata pages. They drive
2//! `Router::handle` against a temporary store with signed-in sessions, one per user.
3
4use forge_core::{Role, Store, Visibility};
5use topcoat::router::request::Request;
6use topcoat::router::{Body, Router, to_bytes};
7
8use crate::auth::SESSION_COOKIE;
9
10const CSRF: &str = "labels-test-csrf";
11
12struct Site {
13 router: Router,
14 store: Store,
15 _dir: tempfile::TempDir,
16}
17
18struct Reply {
19 status: u16,
20 location: Option<String>,
21 body: String,
22}
23
24async fn site() -> Site {
25 let dir = tempfile::tempdir().expect("tempdir");
26 let path = dir.path().to_path_buf();
27 let state = crate::test_support::test_state(&path).await;
28 let store = state.store.clone();
29 let router = crate::test_support::app_router(state);
30 Site {
31 router,
32 store,
33 _dir: dir,
34 }
35}
36
37/// Creates a session for `user_id` and returns its cookie header value.
38async fn sign_in(store: &Store, user_id: &str, token: &str) -> String {
39 crate::test_support::create_session(store, user_id, token, CSRF).await;
40 format!("__Host-{SESSION_COOKIE}={token}")
41}
42
43async fn call(
44 site: &Site,
45 method: &str,
46 uri: &str,
47 cookie: Option<&str>,
48 form: Option<String>,
49) -> Reply {
50 let mut builder = Request::builder().method(method).uri(uri);
51 if let Some(cookie) = cookie {
52 builder = builder.header("cookie", cookie);
53 }
54 let body = match form {
55 Some(form) => {
56 builder = builder.header("content-type", "application/x-www-form-urlencoded");
57 Body::from(form)
58 }
59 None => Body::empty(),
60 };
61 let res = site
62 .router
63 .handle(builder.body(body).expect("request"))
64 .await;
65 let status = res.status().as_u16();
66 let location = res
67 .headers()
68 .get("location")
69 .and_then(|v| v.to_str().ok())
70 .map(str::to_owned);
71 let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body");
72 Reply {
73 status,
74 location,
75 body: String::from_utf8_lossy(&bytes).into_owned(),
76 }
77}
78
79/// An urlencoded body with the test CSRF token and the given fields.
80fn form(fields: &[(&str, &str)]) -> Option<String> {
81 let mut pairs = vec![("csrf", CSRF)];
82 pairs.extend_from_slice(fields);
83 Some(
84 form_urlencoded::Serializer::new(String::new())
85 .extend_pairs(pairs)
86 .finish(),
87 )
88}
89
90struct Seed {
91 site: Site,
92 alice: String,
93 bob_cookie: String,
94 carol_cookie: String,
95 alice_cookie: String,
96 repo_id: String,
97 other_repo_id: String,
98}
99
100/// Alice owns a public repo `alice/forge` and a private repo `alice/secret`. Bob is a Read
101/// collaborator on forge, Carol is a Triage collaborator.
102async fn seeded() -> Seed {
103 let site = site().await;
104 let store = site.store.clone();
105 let alice = store
106 .create_user("alice", None, "unused", false)
107 .await
108 .expect("alice");
109 let bob = store
110 .create_user("bob", None, "unused", false)
111 .await
112 .expect("bob");
113 let carol = store
114 .create_user("carol", None, "unused", false)
115 .await
116 .expect("carol");
117 let repo = store
118 .create_repo(&alice.id, "forge", "", Visibility::Public, "main")
119 .await
120 .expect("repo");
121 let other = store
122 .create_repo(&alice.id, "other", "", Visibility::Public, "main")
123 .await
124 .expect("other repo");
125 store
126 .create_repo(&alice.id, "secret", "", Visibility::Private, "main")
127 .await
128 .expect("secret repo");
129 store
130 .set_collaborator(&repo.id, &bob.id, Role::Read)
131 .await
132 .expect("bob role");
133 store
134 .set_collaborator(&repo.id, &carol.id, Role::Triage)
135 .await
136 .expect("carol role");
137 let alice_cookie = sign_in(&store, &alice.id, "alice-token").await;
138 let bob_cookie = sign_in(&store, &bob.id, "bob-token").await;
139 let carol_cookie = sign_in(&store, &carol.id, "carol-token").await;
140 Seed {
141 site,
142 alice: alice.id,
143 bob_cookie,
144 carol_cookie,
145 alice_cookie,
146 repo_id: repo.id,
147 other_repo_id: other.id,
148 }
149}
150
151const FORGE_LABELS: &str = "/alice/forge/labels";
152
153#[tokio::test]
154async fn owner_creates_edits_and_deletes_a_label() {
155 let s = seeded().await;
156 let cookie = Some(s.alice_cookie.as_str());
157 let created = call(
158 &s.site,
159 "POST",
160 FORGE_LABELS,
161 cookie,
162 form(&[
163 ("name", "bug"),
164 ("color", "#FF0000"),
165 ("description", "Broken"),
166 ]),
167 )
168 .await;
169 assert_eq!(created.status, 303);
170 assert_eq!(created.location.as_deref(), Some(FORGE_LABELS));
171
172 let page = call(&s.site, "GET", FORGE_LABELS, None, None).await;
173 assert_eq!(page.status, 200);
174 assert!(page.body.contains("label-chip label-"));
175 assert!(page.body.contains("bug"));
176 assert!(page.body.contains("/alice/forge/issues?label=bug"));
177
178 let labels = s.site.store.list_labels(&s.repo_id).await.expect("labels");
179 assert_eq!(labels.len(), 1);
180 assert_eq!(labels[0].color, "#ff0000");
181 let id = labels[0].id.clone();
182
183 let edited = call(
184 &s.site,
185 "POST",
186 &format!("{FORGE_LABELS}/{id}"),
187 cookie,
188 form(&[
189 ("name", "defect"),
190 ("color", "#00ff00"),
191 ("description", ""),
192 ]),
193 )
194 .await;
195 assert_eq!(edited.status, 303);
196 let labels = s.site.store.list_labels(&s.repo_id).await.expect("labels");
197 assert_eq!(labels[0].name, "defect");
198 assert_eq!(labels[0].color, "#00ff00");
199
200 let deleted = call(
201 &s.site,
202 "POST",
203 &format!("{FORGE_LABELS}/{id}/delete"),
204 cookie,
205 form(&[]),
206 )
207 .await;
208 assert_eq!(deleted.status, 303);
209 assert!(
210 s.site
211 .store
212 .list_labels(&s.repo_id)
213 .await
214 .expect("labels")
215 .is_empty()
216 );
217}
218
219#[tokio::test]
220async fn duplicate_label_name_shows_an_error() {
221 let s = seeded().await;
222 let cookie = Some(s.alice_cookie.as_str());
223 call(
224 &s.site,
225 "POST",
226 FORGE_LABELS,
227 cookie,
228 form(&[("name", "bug"), ("color", "#ff0000")]),
229 )
230 .await;
231 let dup = call(
232 &s.site,
233 "POST",
234 FORGE_LABELS,
235 cookie,
236 form(&[("name", "BUG"), ("color", "#00ff00")]),
237 )
238 .await;
239 assert_eq!(dup.status, 200);
240 assert!(dup.body.contains("already exists"));
241 assert_eq!(
242 s.site
243 .store
244 .list_labels(&s.repo_id)
245 .await
246 .expect("labels")
247 .len(),
248 1
249 );
250}
251
252#[tokio::test]
253async fn read_only_collaborator_cannot_write_labels() {
254 let s = seeded().await;
255 let res = call(
256 &s.site,
257 "POST",
258 FORGE_LABELS,
259 Some(s.bob_cookie.as_str()),
260 form(&[("name", "bug"), ("color", "#ff0000")]),
261 )
262 .await;
263 assert_eq!(res.status, 403);
264 assert!(
265 s.site
266 .store
267 .list_labels(&s.repo_id)
268 .await
269 .expect("labels")
270 .is_empty()
271 );
272}
273
274#[tokio::test]
275async fn archived_repo_refuses_label_writes() {
276 let s = seeded().await;
277 s.site
278 .store
279 .set_repo_archived(&s.repo_id, true)
280 .await
281 .expect("archive");
282 let res = call(
283 &s.site,
284 "POST",
285 FORGE_LABELS,
286 Some(s.alice_cookie.as_str()),
287 form(&[("name", "bug"), ("color", "#ff0000")]),
288 )
289 .await;
290 assert_eq!(res.status, 403);
291}
292
293#[tokio::test]
294async fn labels_on_an_issue_are_recorded_and_cross_repo_ids_are_rejected() {
295 let s = seeded().await;
296 let store = &s.site.store;
297 let issue = store
298 .create_issue(&s.repo_id, &s.alice, "Crash", "")
299 .await
300 .expect("issue");
301 let mine = store
302 .create_label(&s.repo_id, "bug", "#ff0000", "")
303 .await
304 .expect("label");
305 let theirs = store
306 .create_label(&s.other_repo_id, "elsewhere", "#00ff00", "")
307 .await
308 .expect("other label");
309 let uri = "/alice/forge/issues/1/labels";
310
311 let ok = call(
312 &s.site,
313 "POST",
314 uri,
315 Some(s.carol_cookie.as_str()),
316 form(&[("label", mine.id.as_str())]),
317 )
318 .await;
319 assert_eq!(ok.status, 303);
320 assert_eq!(ok.location.as_deref(), Some("/alice/forge/issues/1"));
321 let events = store.list_events(&issue.id).await.expect("events");
322 assert!(events.iter().any(|e| e.kind == "labeled"));
323
324 let rejected = call(
325 &s.site,
326 "POST",
327 uri,
328 Some(s.carol_cookie.as_str()),
329 form(&[("label", mine.id.as_str()), ("label", theirs.id.as_str())]),
330 )
331 .await;
332 assert_eq!(rejected.status, 200);
333 let current = store.issue_labels(&issue.id).await.expect("labels");
334 assert_eq!(current.len(), 1);
335 assert_eq!(current[0].id, mine.id);
336}
337
338#[tokio::test]
339async fn assignees_accept_valid_users_and_reject_unknown_ones() {
340 let s = seeded().await;
341 let store = &s.site.store;
342 let issue = store
343 .create_issue(&s.repo_id, &s.alice, "Crash", "")
344 .await
345 .expect("issue");
346 let uri = "/alice/forge/issues/1/assignees";
347
348 let ok = call(
349 &s.site,
350 "POST",
351 uri,
352 Some(s.carol_cookie.as_str()),
353 form(&[("assignees", "carol, bob")]),
354 )
355 .await;
356 assert_eq!(ok.status, 303);
357 let names: Vec<String> = store
358 .issue_assignees(&issue.id)
359 .await
360 .expect("assignees")
361 .into_iter()
362 .map(|(_, name)| name)
363 .collect();
364 assert_eq!(names.len(), 2);
365 assert!(names.iter().any(|n| n == "carol"));
366 assert!(names.iter().any(|n| n == "bob"));
367
368 let unknown = call(
369 &s.site,
370 "POST",
371 uri,
372 Some(s.carol_cookie.as_str()),
373 form(&[("assignees", "ghost")]),
374 )
375 .await;
376 assert_eq!(unknown.status, 200);
377 assert_eq!(
378 store
379 .issue_assignees(&issue.id)
380 .await
381 .expect("assignees")
382 .len(),
383 2
384 );
385}
386
387#[tokio::test]
388async fn milestone_is_set_and_cleared_on_an_issue() {
389 let s = seeded().await;
390 let store = &s.site.store;
391 store
392 .create_issue(&s.repo_id, &s.alice, "Crash", "")
393 .await
394 .expect("issue");
395 let created = call(
396 &s.site,
397 "POST",
398 "/alice/forge/milestones",
399 Some(s.alice_cookie.as_str()),
400 form(&[
401 ("title", "v1"),
402 ("description", "**Ship**"),
403 ("due_on", "2030-01-02"),
404 ]),
405 )
406 .await;
407 assert_eq!(created.status, 303);
408 let milestone = store
409 .list_milestones(&s.repo_id, None)
410 .await
411 .expect("milestones")
412 .remove(0);
413 assert_eq!(milestone.due_on.as_deref(), Some("2030-01-02"));
414
415 let set = call(
416 &s.site,
417 "POST",
418 "/alice/forge/issues/1/milestone",
419 Some(s.carol_cookie.as_str()),
420 form(&[("milestone_id", milestone.id.as_str())]),
421 )
422 .await;
423 assert_eq!(set.status, 303);
424 let issue_now = store
425 .issue_by_number(&s.repo_id, 1)
426 .await
427 .expect("issue")
428 .expect("exists");
429 assert_eq!(
430 issue_now.milestone_id.as_deref(),
431 Some(milestone.id.as_str())
432 );
433
434 let cleared = call(
435 &s.site,
436 "POST",
437 "/alice/forge/issues/1/milestone",
438 Some(s.carol_cookie.as_str()),
439 form(&[("milestone_id", "")]),
440 )
441 .await;
442 assert_eq!(cleared.status, 303);
443 let issue_now = store
444 .issue_by_number(&s.repo_id, 1)
445 .await
446 .expect("issue")
447 .expect("exists");
448 assert!(issue_now.milestone_id.is_none());
449}
450
451#[tokio::test]
452async fn milestone_progress_close_and_delete_keeps_issues() {
453 let s = seeded().await;
454 let store = &s.site.store;
455 let open_issue = store
456 .create_issue(&s.repo_id, &s.alice, "Open one", "")
457 .await
458 .expect("issue");
459 let done_issue = store
460 .create_issue(&s.repo_id, &s.alice, "Done one", "")
461 .await
462 .expect("issue");
463 call(
464 &s.site,
465 "POST",
466 "/alice/forge/milestones",
467 Some(s.alice_cookie.as_str()),
468 form(&[("title", "v1"), ("due_on", "2030-01-02")]),
469 )
470 .await;
471 let milestone = store
472 .list_milestones(&s.repo_id, None)
473 .await
474 .expect("milestones")
475 .remove(0);
476 for number in [open_issue.number, done_issue.number] {
477 store
478 .set_issue_milestone(
479 &store
480 .issue_by_number(&s.repo_id, number)
481 .await
482 .expect("issue")
483 .expect("exists")
484 .id,
485 &s.alice,
486 Some(milestone.id.as_str()),
487 )
488 .await
489 .expect("set milestone");
490 }
491 store
492 .set_issue_state(
493 &done_issue.id,
494 Some(&s.alice),
495 forge_core::IssueState::Closed,
496 None,
497 serde_json::json!({}),
498 )
499 .await
500 .expect("close");
501
502 let list = call(&s.site, "GET", "/alice/forge/milestones", None, None).await;
503 assert_eq!(list.status, 200);
504 assert!(list.body.contains(r#"<progress value="1" max="2">"#));
505 assert!(list.body.contains("1 open"));
506 assert!(list.body.contains("1 closed"));
507
508 let closed_list = call(
509 &s.site,
510 "GET",
511 "/alice/forge/milestones?state=closed",
512 None,
513 None,
514 )
515 .await;
516 assert!(closed_list.body.contains("No closed milestones."));
517
518 let edit_uri = format!("/alice/forge/milestones/{}/edit", milestone.id);
519 let edit = call(
520 &s.site,
521 "POST",
522 &edit_uri,
523 Some(s.alice_cookie.as_str()),
524 form(&[
525 ("title", "v1"),
526 ("description", ""),
527 ("due_on", "2030-01-02"),
528 ("state", "closed"),
529 ]),
530 )
531 .await;
532 assert_eq!(edit.status, 303);
533 assert_eq!(
534 store
535 .milestone_by_id(&s.repo_id, &milestone.id)
536 .await
537 .expect("milestone")
538 .expect("exists")
539 .state,
540 forge_core::IssueState::Closed
541 );
542
543 let deleted = call(
544 &s.site,
545 "POST",
546 &format!("/alice/forge/milestones/{}/delete", milestone.id),
547 Some(s.alice_cookie.as_str()),
548 form(&[]),
549 )
550 .await;
551 assert_eq!(deleted.status, 303);
552 assert!(
553 store
554 .list_milestones(&s.repo_id, None)
555 .await
556 .expect("milestones")
557 .is_empty()
558 );
559 let survivor = store
560 .issue_by_number(&s.repo_id, open_issue.number)
561 .await
562 .expect("issue")
563 .expect("issues keep existing");
564 assert!(survivor.milestone_id.is_none());
565}
566
567#[tokio::test]
568async fn write_without_csrf_is_rejected() {
569 let s = seeded().await;
570 let no_token = Some(
571 form_urlencoded::Serializer::new(String::new())
572 .extend_pairs([("name", "bug"), ("color", "#ff0000")])
573 .finish(),
574 );
575 let res = call(
576 &s.site,
577 "POST",
578 FORGE_LABELS,
579 Some(s.alice_cookie.as_str()),
580 no_token,
581 )
582 .await;
583 assert_eq!(res.status, 403);
584 let res = call(
585 &s.site,
586 "POST",
587 "/alice/forge/milestones",
588 Some(s.alice_cookie.as_str()),
589 Some(
590 form_urlencoded::Serializer::new(String::new())
591 .extend_pairs([("title", "v1")])
592 .finish(),
593 ),
594 )
595 .await;
596 assert_eq!(res.status, 403);
597}
598
599#[tokio::test]
600async fn anonymous_viewer_of_a_private_repo_gets_404() {
601 let s = seeded().await;
602 for uri in ["/alice/secret/labels", "/alice/secret/milestones"] {
603 let res = call(&s.site, "GET", uri, None, None).await;
604 assert_eq!(res.status, 404, "{uri}");
605 }
606 let res = call(&s.site, "GET", "/alice/forge/milestones", None, None).await;
607 assert_eq!(res.status, 200);
608}
609
610const FORGE_ISSUE: &str = "/alice/forge/issues/1";
611
612/// Alice opens issue #1 in the seeded repo and returns its id.
613async fn open_issue(s: &Seed) -> String {
614 s.site
615 .store
616 .create_issue(&s.repo_id, &s.alice, "Lock me", "body")
617 .await
618 .expect("issue")
619 .id
620}
621
622#[tokio::test]
623async fn lock_requires_triage_and_csrf() {
624 let s = seeded().await;
625 open_issue(&s).await;
626 let uri = "/alice/forge/issues/1/lock";
627 let res = call(
628 &s.site,
629 "POST",
630 uri,
631 Some(s.bob_cookie.as_str()),
632 form(&[("locked", "1"), ("reason", "spam")]),
633 )
634 .await;
635 assert_eq!(res.status, 403);
636 let no_token = Some(
637 form_urlencoded::Serializer::new(String::new())
638 .extend_pairs([("locked", "1"), ("reason", "spam")])
639 .finish(),
640 );
641 let res = call(
642 &s.site,
643 "POST",
644 uri,
645 Some(s.carol_cookie.as_str()),
646 no_token,
647 )
648 .await;
649 assert_eq!(res.status, 403);
650 let issue = s
651 .site
652 .store
653 .issue_by_number(&s.repo_id, 1)
654 .await
655 .expect("lookup")
656 .expect("issue");
657 assert!(!issue.locked);
658}
659
660#[tokio::test]
661async fn triage_locks_and_unlocks_with_reason() {
662 let s = seeded().await;
663 open_issue(&s).await;
664 let uri = "/alice/forge/issues/1/lock";
665 let res = call(
666 &s.site,
667 "POST",
668 uri,
669 Some(s.carol_cookie.as_str()),
670 form(&[("locked", "1"), ("reason", "too_heated")]),
671 )
672 .await;
673 assert_eq!(res.status, 303);
674 let issue = s
675 .site
676 .store
677 .issue_by_number(&s.repo_id, 1)
678 .await
679 .expect("lookup")
680 .expect("issue");
681 assert!(issue.locked);
682 assert_eq!(issue.lock_reason.as_deref(), Some("too_heated"));
683
684 let res = call(
685 &s.site,
686 "POST",
687 uri,
688 Some(s.carol_cookie.as_str()),
689 form(&[("locked", "0")]),
690 )
691 .await;
692 assert_eq!(res.status, 303);
693 let issue = s
694 .site
695 .store
696 .issue_by_number(&s.repo_id, 1)
697 .await
698 .expect("lookup")
699 .expect("issue");
700 assert!(!issue.locked);
701 assert_eq!(issue.lock_reason, None);
702}
703
704#[tokio::test]
705async fn locked_issue_refuses_comments_and_reactions_from_read_users() {
706 let s = seeded().await;
707 let issue_id = open_issue(&s).await;
708 s.site
709 .store
710 .set_issue_locked(&issue_id, Some(&s.alice), true, Some("spam"))
711 .await
712 .expect("lock");
713
714 let res = call(
715 &s.site,
716 "POST",
717 &format!("{FORGE_ISSUE}/comment"),
718 Some(s.bob_cookie.as_str()),
719 form(&[("body", "hello"), ("action", "comment")]),
720 )
721 .await;
722 assert_eq!(res.status, 403);
723 let res = call(
724 &s.site,
725 "POST",
726 &format!("{FORGE_ISSUE}/reactions"),
727 Some(s.bob_cookie.as_str()),
728 form(&[
729 ("target_kind", "issue"),
730 ("target_id", issue_id.as_str()),
731 ("reaction", "heart"),
732 ]),
733 )
734 .await;
735 assert_eq!(res.status, 403);
736 let issue = s
737 .site
738 .store
739 .issue_by_number(&s.repo_id, 1)
740 .await
741 .expect("lookup")
742 .expect("issue");
743 assert_eq!(issue.comment_count, 0);
744
745 // Triage holders still comment, and the page tells Bob why he cannot.
746 let res = call(
747 &s.site,
748 "POST",
749 &format!("{FORGE_ISSUE}/comment"),
750 Some(s.carol_cookie.as_str()),
751 form(&[("body", "moderating"), ("action", "comment")]),
752 )
753 .await;
754 assert_eq!(res.status, 303);
755 let res = call(
756 &s.site,
757 "GET",
758 FORGE_ISSUE,
759 Some(s.bob_cookie.as_str()),
760 None,
761 )
762 .await;
763 assert_eq!(res.status, 200);
764 assert!(
765 res.body
766 .contains("This conversation has been locked (spam) and limited to collaborators.")
767 );
768 assert!(!res.body.contains("Add a comment"));
769}