//! Router-level tests for the label, assignee, milestone and issue-metadata pages. They drive //! `Router::handle` against a temporary store with signed-in sessions, one per user. use forge_core::{Role, Store, Visibility}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; const CSRF: &str = "labels-test-csrf"; struct Site { router: Router, store: Store, _dir: tempfile::TempDir, } struct Reply { status: u16, location: Option, body: String, } async fn site() -> Site { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().to_path_buf(); let state = crate::test_support::test_state(&path).await; let store = state.store.clone(); let router = crate::test_support::app_router(state); Site { router, store, _dir: dir, } } /// Creates a session for `user_id` and returns its cookie header value. async fn sign_in(store: &Store, user_id: &str, token: &str) -> String { crate::test_support::create_session(store, user_id, token, CSRF).await; format!("__Host-{SESSION_COOKIE}={token}") } async fn call( site: &Site, method: &str, uri: &str, cookie: Option<&str>, form: Option, ) -> Reply { let mut builder = Request::builder().method(method).uri(uri); if let Some(cookie) = cookie { builder = builder.header("cookie", cookie); } let body = match form { Some(form) => { builder = builder.header("content-type", "application/x-www-form-urlencoded"); Body::from(form) } None => Body::empty(), }; let res = site .router .handle(builder.body(body).expect("request")) .await; let status = res.status().as_u16(); let location = res .headers() .get("location") .and_then(|v| v.to_str().ok()) .map(str::to_owned); let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); Reply { status, location, body: String::from_utf8_lossy(&bytes).into_owned(), } } /// An urlencoded body with the test CSRF token and the given fields. fn form(fields: &[(&str, &str)]) -> Option { let mut pairs = vec![("csrf", CSRF)]; pairs.extend_from_slice(fields); Some( form_urlencoded::Serializer::new(String::new()) .extend_pairs(pairs) .finish(), ) } struct Seed { site: Site, alice: String, bob_cookie: String, carol_cookie: String, alice_cookie: String, repo_id: String, other_repo_id: String, } /// Alice owns a public repo `alice/forge` and a private repo `alice/secret`. Bob is a Read /// collaborator on forge, Carol is a Triage collaborator. async fn seeded() -> Seed { let site = site().await; let store = site.store.clone(); let alice = store .create_user("alice", None, "unused", false) .await .expect("alice"); let bob = store .create_user("bob", None, "unused", false) .await .expect("bob"); let carol = store .create_user("carol", None, "unused", false) .await .expect("carol"); let repo = store .create_repo(&alice.id, "forge", "", Visibility::Public, "main") .await .expect("repo"); let other = store .create_repo(&alice.id, "other", "", Visibility::Public, "main") .await .expect("other repo"); store .create_repo(&alice.id, "secret", "", Visibility::Private, "main") .await .expect("secret repo"); store .set_collaborator(&repo.id, &bob.id, Role::Read) .await .expect("bob role"); store .set_collaborator(&repo.id, &carol.id, Role::Triage) .await .expect("carol role"); let alice_cookie = sign_in(&store, &alice.id, "alice-token").await; let bob_cookie = sign_in(&store, &bob.id, "bob-token").await; let carol_cookie = sign_in(&store, &carol.id, "carol-token").await; Seed { site, alice: alice.id, bob_cookie, carol_cookie, alice_cookie, repo_id: repo.id, other_repo_id: other.id, } } const FORGE_LABELS: &str = "/alice/forge/labels"; #[tokio::test] async fn owner_creates_edits_and_deletes_a_label() { let s = seeded().await; let cookie = Some(s.alice_cookie.as_str()); let created = call( &s.site, "POST", FORGE_LABELS, cookie, form(&[ ("name", "bug"), ("color", "#FF0000"), ("description", "Broken"), ]), ) .await; assert_eq!(created.status, 303); assert_eq!(created.location.as_deref(), Some(FORGE_LABELS)); let page = call(&s.site, "GET", FORGE_LABELS, None, None).await; assert_eq!(page.status, 200); assert!(page.body.contains("label-chip label-")); assert!(page.body.contains("bug")); assert!(page.body.contains("/alice/forge/issues?label=bug")); let labels = s.site.store.list_labels(&s.repo_id).await.expect("labels"); assert_eq!(labels.len(), 1); assert_eq!(labels[0].color, "#ff0000"); let id = labels[0].id.clone(); let edited = call( &s.site, "POST", &format!("{FORGE_LABELS}/{id}"), cookie, form(&[ ("name", "defect"), ("color", "#00ff00"), ("description", ""), ]), ) .await; assert_eq!(edited.status, 303); let labels = s.site.store.list_labels(&s.repo_id).await.expect("labels"); assert_eq!(labels[0].name, "defect"); assert_eq!(labels[0].color, "#00ff00"); let deleted = call( &s.site, "POST", &format!("{FORGE_LABELS}/{id}/delete"), cookie, form(&[]), ) .await; assert_eq!(deleted.status, 303); assert!( s.site .store .list_labels(&s.repo_id) .await .expect("labels") .is_empty() ); } #[tokio::test] async fn duplicate_label_name_shows_an_error() { let s = seeded().await; let cookie = Some(s.alice_cookie.as_str()); call( &s.site, "POST", FORGE_LABELS, cookie, form(&[("name", "bug"), ("color", "#ff0000")]), ) .await; let dup = call( &s.site, "POST", FORGE_LABELS, cookie, form(&[("name", "BUG"), ("color", "#00ff00")]), ) .await; assert_eq!(dup.status, 200); assert!(dup.body.contains("already exists")); assert_eq!( s.site .store .list_labels(&s.repo_id) .await .expect("labels") .len(), 1 ); } #[tokio::test] async fn read_only_collaborator_cannot_write_labels() { let s = seeded().await; let res = call( &s.site, "POST", FORGE_LABELS, Some(s.bob_cookie.as_str()), form(&[("name", "bug"), ("color", "#ff0000")]), ) .await; assert_eq!(res.status, 403); assert!( s.site .store .list_labels(&s.repo_id) .await .expect("labels") .is_empty() ); } #[tokio::test] async fn archived_repo_refuses_label_writes() { let s = seeded().await; s.site .store .set_repo_archived(&s.repo_id, true) .await .expect("archive"); let res = call( &s.site, "POST", FORGE_LABELS, Some(s.alice_cookie.as_str()), form(&[("name", "bug"), ("color", "#ff0000")]), ) .await; assert_eq!(res.status, 403); } #[tokio::test] async fn labels_on_an_issue_are_recorded_and_cross_repo_ids_are_rejected() { let s = seeded().await; let store = &s.site.store; let issue = store .create_issue(&s.repo_id, &s.alice, "Crash", "") .await .expect("issue"); let mine = store .create_label(&s.repo_id, "bug", "#ff0000", "") .await .expect("label"); let theirs = store .create_label(&s.other_repo_id, "elsewhere", "#00ff00", "") .await .expect("other label"); let uri = "/alice/forge/issues/1/labels"; let ok = call( &s.site, "POST", uri, Some(s.carol_cookie.as_str()), form(&[("label", mine.id.as_str())]), ) .await; assert_eq!(ok.status, 303); assert_eq!(ok.location.as_deref(), Some("/alice/forge/issues/1")); let events = store.list_events(&issue.id).await.expect("events"); assert!(events.iter().any(|e| e.kind == "labeled")); let rejected = call( &s.site, "POST", uri, Some(s.carol_cookie.as_str()), form(&[("label", mine.id.as_str()), ("label", theirs.id.as_str())]), ) .await; assert_eq!(rejected.status, 200); let current = store.issue_labels(&issue.id).await.expect("labels"); assert_eq!(current.len(), 1); assert_eq!(current[0].id, mine.id); } #[tokio::test] async fn assignees_accept_valid_users_and_reject_unknown_ones() { let s = seeded().await; let store = &s.site.store; let issue = store .create_issue(&s.repo_id, &s.alice, "Crash", "") .await .expect("issue"); let uri = "/alice/forge/issues/1/assignees"; let ok = call( &s.site, "POST", uri, Some(s.carol_cookie.as_str()), form(&[("assignees", "carol, bob")]), ) .await; assert_eq!(ok.status, 303); let names: Vec = store .issue_assignees(&issue.id) .await .expect("assignees") .into_iter() .map(|(_, name)| name) .collect(); assert_eq!(names.len(), 2); assert!(names.iter().any(|n| n == "carol")); assert!(names.iter().any(|n| n == "bob")); let unknown = call( &s.site, "POST", uri, Some(s.carol_cookie.as_str()), form(&[("assignees", "ghost")]), ) .await; assert_eq!(unknown.status, 200); assert_eq!( store .issue_assignees(&issue.id) .await .expect("assignees") .len(), 2 ); } #[tokio::test] async fn milestone_is_set_and_cleared_on_an_issue() { let s = seeded().await; let store = &s.site.store; store .create_issue(&s.repo_id, &s.alice, "Crash", "") .await .expect("issue"); let created = call( &s.site, "POST", "/alice/forge/milestones", Some(s.alice_cookie.as_str()), form(&[ ("title", "v1"), ("description", "**Ship**"), ("due_on", "2030-01-02"), ]), ) .await; assert_eq!(created.status, 303); let milestone = store .list_milestones(&s.repo_id, None) .await .expect("milestones") .remove(0); assert_eq!(milestone.due_on.as_deref(), Some("2030-01-02")); let set = call( &s.site, "POST", "/alice/forge/issues/1/milestone", Some(s.carol_cookie.as_str()), form(&[("milestone_id", milestone.id.as_str())]), ) .await; assert_eq!(set.status, 303); let issue_now = store .issue_by_number(&s.repo_id, 1) .await .expect("issue") .expect("exists"); assert_eq!( issue_now.milestone_id.as_deref(), Some(milestone.id.as_str()) ); let cleared = call( &s.site, "POST", "/alice/forge/issues/1/milestone", Some(s.carol_cookie.as_str()), form(&[("milestone_id", "")]), ) .await; assert_eq!(cleared.status, 303); let issue_now = store .issue_by_number(&s.repo_id, 1) .await .expect("issue") .expect("exists"); assert!(issue_now.milestone_id.is_none()); } #[tokio::test] async fn milestone_progress_close_and_delete_keeps_issues() { let s = seeded().await; let store = &s.site.store; let open_issue = store .create_issue(&s.repo_id, &s.alice, "Open one", "") .await .expect("issue"); let done_issue = store .create_issue(&s.repo_id, &s.alice, "Done one", "") .await .expect("issue"); call( &s.site, "POST", "/alice/forge/milestones", Some(s.alice_cookie.as_str()), form(&[("title", "v1"), ("due_on", "2030-01-02")]), ) .await; let milestone = store .list_milestones(&s.repo_id, None) .await .expect("milestones") .remove(0); for number in [open_issue.number, done_issue.number] { store .set_issue_milestone( &store .issue_by_number(&s.repo_id, number) .await .expect("issue") .expect("exists") .id, &s.alice, Some(milestone.id.as_str()), ) .await .expect("set milestone"); } store .set_issue_state( &done_issue.id, Some(&s.alice), forge_core::IssueState::Closed, None, serde_json::json!({}), ) .await .expect("close"); let list = call(&s.site, "GET", "/alice/forge/milestones", None, None).await; assert_eq!(list.status, 200); assert!(list.body.contains(r#""#)); assert!(list.body.contains("1 open")); assert!(list.body.contains("1 closed")); let closed_list = call( &s.site, "GET", "/alice/forge/milestones?state=closed", None, None, ) .await; assert!(closed_list.body.contains("No closed milestones.")); let edit_uri = format!("/alice/forge/milestones/{}/edit", milestone.id); let edit = call( &s.site, "POST", &edit_uri, Some(s.alice_cookie.as_str()), form(&[ ("title", "v1"), ("description", ""), ("due_on", "2030-01-02"), ("state", "closed"), ]), ) .await; assert_eq!(edit.status, 303); assert_eq!( store .milestone_by_id(&s.repo_id, &milestone.id) .await .expect("milestone") .expect("exists") .state, forge_core::IssueState::Closed ); let deleted = call( &s.site, "POST", &format!("/alice/forge/milestones/{}/delete", milestone.id), Some(s.alice_cookie.as_str()), form(&[]), ) .await; assert_eq!(deleted.status, 303); assert!( store .list_milestones(&s.repo_id, None) .await .expect("milestones") .is_empty() ); let survivor = store .issue_by_number(&s.repo_id, open_issue.number) .await .expect("issue") .expect("issues keep existing"); assert!(survivor.milestone_id.is_none()); } #[tokio::test] async fn write_without_csrf_is_rejected() { let s = seeded().await; let no_token = Some( form_urlencoded::Serializer::new(String::new()) .extend_pairs([("name", "bug"), ("color", "#ff0000")]) .finish(), ); let res = call( &s.site, "POST", FORGE_LABELS, Some(s.alice_cookie.as_str()), no_token, ) .await; assert_eq!(res.status, 403); let res = call( &s.site, "POST", "/alice/forge/milestones", Some(s.alice_cookie.as_str()), Some( form_urlencoded::Serializer::new(String::new()) .extend_pairs([("title", "v1")]) .finish(), ), ) .await; assert_eq!(res.status, 403); } #[tokio::test] async fn anonymous_viewer_of_a_private_repo_gets_404() { let s = seeded().await; for uri in ["/alice/secret/labels", "/alice/secret/milestones"] { let res = call(&s.site, "GET", uri, None, None).await; assert_eq!(res.status, 404, "{uri}"); } let res = call(&s.site, "GET", "/alice/forge/milestones", None, None).await; assert_eq!(res.status, 200); } const FORGE_ISSUE: &str = "/alice/forge/issues/1"; /// Alice opens issue #1 in the seeded repo and returns its id. async fn open_issue(s: &Seed) -> String { s.site .store .create_issue(&s.repo_id, &s.alice, "Lock me", "body") .await .expect("issue") .id } #[tokio::test] async fn lock_requires_triage_and_csrf() { let s = seeded().await; open_issue(&s).await; let uri = "/alice/forge/issues/1/lock"; let res = call( &s.site, "POST", uri, Some(s.bob_cookie.as_str()), form(&[("locked", "1"), ("reason", "spam")]), ) .await; assert_eq!(res.status, 403); let no_token = Some( form_urlencoded::Serializer::new(String::new()) .extend_pairs([("locked", "1"), ("reason", "spam")]) .finish(), ); let res = call( &s.site, "POST", uri, Some(s.carol_cookie.as_str()), no_token, ) .await; assert_eq!(res.status, 403); let issue = s .site .store .issue_by_number(&s.repo_id, 1) .await .expect("lookup") .expect("issue"); assert!(!issue.locked); } #[tokio::test] async fn triage_locks_and_unlocks_with_reason() { let s = seeded().await; open_issue(&s).await; let uri = "/alice/forge/issues/1/lock"; let res = call( &s.site, "POST", uri, Some(s.carol_cookie.as_str()), form(&[("locked", "1"), ("reason", "too_heated")]), ) .await; assert_eq!(res.status, 303); let issue = s .site .store .issue_by_number(&s.repo_id, 1) .await .expect("lookup") .expect("issue"); assert!(issue.locked); assert_eq!(issue.lock_reason.as_deref(), Some("too_heated")); let res = call( &s.site, "POST", uri, Some(s.carol_cookie.as_str()), form(&[("locked", "0")]), ) .await; assert_eq!(res.status, 303); let issue = s .site .store .issue_by_number(&s.repo_id, 1) .await .expect("lookup") .expect("issue"); assert!(!issue.locked); assert_eq!(issue.lock_reason, None); } #[tokio::test] async fn locked_issue_refuses_comments_and_reactions_from_read_users() { let s = seeded().await; let issue_id = open_issue(&s).await; s.site .store .set_issue_locked(&issue_id, Some(&s.alice), true, Some("spam")) .await .expect("lock"); let res = call( &s.site, "POST", &format!("{FORGE_ISSUE}/comment"), Some(s.bob_cookie.as_str()), form(&[("body", "hello"), ("action", "comment")]), ) .await; assert_eq!(res.status, 403); let res = call( &s.site, "POST", &format!("{FORGE_ISSUE}/reactions"), Some(s.bob_cookie.as_str()), form(&[ ("target_kind", "issue"), ("target_id", issue_id.as_str()), ("reaction", "heart"), ]), ) .await; assert_eq!(res.status, 403); let issue = s .site .store .issue_by_number(&s.repo_id, 1) .await .expect("lookup") .expect("issue"); assert_eq!(issue.comment_count, 0); // Triage holders still comment, and the page tells Bob why he cannot. let res = call( &s.site, "POST", &format!("{FORGE_ISSUE}/comment"), Some(s.carol_cookie.as_str()), form(&[("body", "moderating"), ("action", "comment")]), ) .await; assert_eq!(res.status, 303); let res = call( &s.site, "GET", FORGE_ISSUE, Some(s.bob_cookie.as_str()), None, ) .await; assert_eq!(res.status, 200); assert!( res.body .contains("This conversation has been locked (spam) and limited to collaborators.") ); assert!(!res.body.contains("Add a comment")); }