//! Router-level tests for reactions on issues, pull requests and comments: CSRF, permissions, //! toggling, target validation and the archived read-only rule. use forge_core::{Reaction, ReactionTarget, Store, Visibility}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; const CSRF: &str = "reactions-test-csrf"; struct Site { router: Router, store: Store, _dir: tempfile::TempDir, } struct Reply { status: u16, location: Option, } async fn site() -> Site { let dir = tempfile::tempdir().expect("tempdir"); let state = crate::test_support::test_state(dir.path()).await; let store = state.store.clone(); let router = crate::test_support::app_router(state); Site { router, store, _dir: dir, } } 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 post(site: &Site, uri: &str, cookie: Option<&str>, fields: &[(&str, &str)]) -> Reply { let mut builder = Request::builder().method("POST").uri(uri); if let Some(cookie) = cookie { builder = builder.header("cookie", cookie); } let body = form_urlencoded::Serializer::new(String::new()) .extend_pairs(fields) .finish(); builder = builder.header("content-type", "application/x-www-form-urlencoded"); let res = site .router .handle(builder.body(Body::from(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 _ = to_bytes(res.into_body(), usize::MAX).await.expect("body"); Reply { status, location } } /// The form fields for a reaction on `target_kind`/`target_id`, with the test CSRF token. fn fields<'a>( target_kind: &'a str, target_id: &'a str, reaction: &'a str, ) -> Vec<(&'a str, &'a str)> { vec![ ("csrf", CSRF), ("target_kind", target_kind), ("target_id", target_id), ("reaction", reaction), ] } struct Seed { site: Site, alice: String, bob_cookie: String, alice_cookie: String, repo_id: String, issue_id: String, issue_number: i64, comment_id: String, other_issue_id: String, } /// Alice owns the public repo `alice/forge` with an issue and a comment, and a second issue in /// the same repo. Bob is a signed-in user with no role. Alice owns `alice/secret` (private). 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 repo = store .create_repo(&alice.id, "forge", "", Visibility::Public, "main") .await .expect("repo"); store .create_repo(&alice.id, "secret", "", Visibility::Private, "main") .await .expect("secret repo"); let issue = store .create_issue(&repo.id, &alice.id, "first", "body") .await .expect("issue"); let other = store .create_issue(&repo.id, &alice.id, "second", "") .await .expect("other issue"); let comment = store .add_comment(&issue.id, &bob.id, "hello") .await .expect("comment"); let alice_cookie = sign_in(&store, &alice.id, "alice-token").await; let bob_cookie = sign_in(&store, &bob.id, "bob-token").await; Seed { site, alice: alice.id.clone(), bob_cookie, alice_cookie, repo_id: repo.id, issue_id: issue.id, issue_number: issue.number, comment_id: comment.id, other_issue_id: other.id, } } fn issue_uri(number: i64) -> String { format!("/alice/forge/issues/{number}/reactions") } async fn on_issue(s: &Seed) -> Vec { s.site .store .reactions_for( ReactionTarget::Issue, std::slice::from_ref(&s.issue_id), None, ) .await .expect("reactions") .remove(&s.issue_id) .unwrap_or_default() } #[tokio::test] async fn signed_in_reaction_toggles_on_and_off_and_redirects_to_anchor() { let s = seeded().await; let uri = issue_uri(s.issue_number); let body = fields("issue", &s.issue_id, "heart"); let on = post(&s.site, &uri, Some(&s.bob_cookie), &body).await; assert_eq!(on.status, 303); assert_eq!( on.location.as_deref(), Some( format!( "/alice/forge/issues/{}#issue-{}", s.issue_number, s.issue_number ) .as_str() ) ); let summaries = on_issue(&s).await; assert_eq!(summaries.len(), 1); assert_eq!(summaries[0].reaction, Reaction::Heart); assert_eq!(summaries[0].count, 1); assert_eq!(summaries[0].users, vec!["bob"]); let off = post(&s.site, &uri, Some(&s.bob_cookie), &body).await; assert_eq!(off.status, 303); assert!(on_issue(&s).await.is_empty()); } #[tokio::test] async fn comment_reaction_anchors_on_the_comment() { let s = seeded().await; let uri = issue_uri(s.issue_number); let reply = post( &s.site, &uri, Some(&s.alice_cookie), &fields("comment", &s.comment_id, "eyes"), ) .await; assert_eq!(reply.status, 303); assert_eq!( reply.location.as_deref(), Some( format!( "/alice/forge/issues/{}#comment-{}", s.issue_number, s.comment_id ) .as_str() ) ); let map = s .site .store .reactions_for( ReactionTarget::Comment, std::slice::from_ref(&s.comment_id), Some(&s.alice), ) .await .expect("reactions"); assert!(map[&s.comment_id][0].reacted_by_viewer); } #[tokio::test] async fn missing_or_wrong_csrf_is_forbidden_and_changes_nothing() { let s = seeded().await; let uri = issue_uri(s.issue_number); let no_token = vec![ ("target_kind", "issue"), ("target_id", s.issue_id.as_str()), ("reaction", "rocket"), ]; assert_eq!( post(&s.site, &uri, Some(&s.bob_cookie), &no_token) .await .status, 403 ); let wrong = vec![ ("csrf", "not-the-token"), ("target_kind", "issue"), ("target_id", s.issue_id.as_str()), ("reaction", "rocket"), ]; assert_eq!( post(&s.site, &uri, Some(&s.bob_cookie), &wrong) .await .status, 403 ); assert!(on_issue(&s).await.is_empty()); } #[tokio::test] async fn signed_out_viewer_is_sent_to_login() { let s = seeded().await; let uri = issue_uri(s.issue_number); let reply = post(&s.site, &uri, None, &fields("issue", &s.issue_id, "+1")).await; assert_eq!(reply.status, 303); assert!(reply.location.unwrap_or_default().starts_with("/login")); assert!(on_issue(&s).await.is_empty()); } #[tokio::test] async fn hidden_repo_answers_not_found_for_a_viewer_without_access() { let s = seeded().await; let reply = post( &s.site, "/alice/secret/issues/1/reactions", Some(&s.bob_cookie), &fields("issue", "anything", "+1"), ) .await; assert_eq!(reply.status, 404); } #[tokio::test] async fn target_must_belong_to_this_issue() { let s = seeded().await; let uri = issue_uri(s.issue_number); // An issue id from another item in the same repo is not this item's body. let wrong_issue = post( &s.site, &uri, Some(&s.bob_cookie), &fields("issue", &s.other_issue_id, "+1"), ) .await; assert_eq!(wrong_issue.status, 404); // A comment of the other issue (the comment belongs to the first issue) is rejected on the // second issue's page. let other_uri = issue_uri(s.issue_number + 1); let wrong_comment = post( &s.site, &other_uri, Some(&s.bob_cookie), &fields("comment", &s.comment_id, "+1"), ) .await; assert_eq!(wrong_comment.status, 404); assert!(on_issue(&s).await.is_empty()); } #[tokio::test] async fn unknown_reaction_and_target_kind_are_bad_requests() { let s = seeded().await; let uri = issue_uri(s.issue_number); assert_eq!( post( &s.site, &uri, Some(&s.bob_cookie), &fields("issue", &s.issue_id, "party") ) .await .status, 400 ); assert_eq!( post( &s.site, &uri, Some(&s.bob_cookie), &fields("pull", &s.issue_id, "+1") ) .await .status, 400 ); } #[tokio::test] async fn archived_repo_refuses_reactions() { let s = seeded().await; s.site .store .set_repo_archived(&s.repo_id, true) .await .expect("archive"); let reply = post( &s.site, &issue_uri(s.issue_number), Some(&s.bob_cookie), &fields("issue", &s.issue_id, "+1"), ) .await; assert_eq!(reply.status, 403); assert!(on_issue(&s).await.is_empty()); }