//! Router-level tests for pinning issues: triage permission, CSRF, the three-pin limit, the //! timeline event, the sidebar button and the pinned cards above the issue list. 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 = "pin-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 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 call( site: &Site, method: &str, uri: &str, cookie: Option<&str>, fields: Option<&[(&str, &str)]>, ) -> Reply { let mut builder = Request::builder().method(method).uri(uri); if let Some(cookie) = cookie { builder = builder.header("cookie", cookie); } let body = match fields { Some(fields) => { builder = builder.header("content-type", "application/x-www-form-urlencoded"); Body::from( form_urlencoded::Serializer::new(String::new()) .extend_pairs(fields) .finish(), ) } 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(), } } /// The form fields for a pin or unpin, with the test CSRF token. fn pin_fields(pinned: &'static str) -> Vec<(&'static str, &'static str)> { vec![("csrf", CSRF), ("pinned", pinned)] } const PIN_PATH: &str = "/alice/forge/issues/"; struct Seed { site: Site, alice_cookie: String, bob_cookie: String, carol_cookie: String, repo_id: String, /// Issues one to four, in number order. issues: Vec<(String, i64)>, } /// Alice owns the public repo `alice/forge` with four issues. Bob is a Read collaborator and /// Carol 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"); 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 mut issues = Vec::new(); for i in 1..=4 { let issue = store .create_issue(&repo.id, &alice.id, &format!("issue {i}"), "") .await .expect("issue"); issues.push((issue.id, issue.number)); } 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_cookie, bob_cookie, carol_cookie, repo_id: repo.id, issues, } } fn pin_uri(number: i64) -> String { format!("{PIN_PATH}{number}/pin") } #[tokio::test] async fn triage_pins_and_unpins_and_records_the_timeline() { let s = seeded().await; let (issue_id, number) = s.issues[0].clone(); let uri = pin_uri(number); let reply = call( &s.site, "POST", &uri, Some(s.carol_cookie.as_str()), Some(pin_fields("1").as_slice()), ) .await; assert_eq!(reply.status, 303); assert_eq!( reply.location.as_deref(), Some(format!("{PIN_PATH}{number}").as_str()) ); assert!( s.site .store .is_pinned(&s.repo_id, &issue_id) .await .expect("pinned") ); let events = s.site.store.list_events(&issue_id).await.expect("events"); assert!(events.iter().any(|e| e.kind == "pinned")); let reply = call( &s.site, "POST", &uri, Some(s.carol_cookie.as_str()), Some(pin_fields("0").as_slice()), ) .await; assert_eq!(reply.status, 303); assert!( !s.site .store .is_pinned(&s.repo_id, &issue_id) .await .expect("pinned") ); let events = s.site.store.list_events(&issue_id).await.expect("events"); assert!(events.iter().any(|e| e.kind == "unpinned")); } #[tokio::test] async fn read_only_collaborator_and_anonymous_are_forbidden() { let s = seeded().await; let (issue_id, number) = s.issues[0].clone(); let uri = pin_uri(number); let reply = call( &s.site, "POST", &uri, Some(s.bob_cookie.as_str()), Some(pin_fields("1").as_slice()), ) .await; assert_eq!(reply.status, 403); let reply = call( &s.site, "POST", &uri, None, Some(pin_fields("1").as_slice()), ) .await; assert!(reply.status == 403 || reply.status == 303); assert!( !s.site .store .is_pinned(&s.repo_id, &issue_id) .await .expect("pinned") ); } #[tokio::test] async fn missing_csrf_is_forbidden_and_changes_nothing() { let s = seeded().await; let (issue_id, number) = s.issues[0].clone(); let no_token: &[(&str, &str)] = &[("pinned", "1")]; let reply = call( &s.site, "POST", &pin_uri(number), Some(s.alice_cookie.as_str()), Some(no_token), ) .await; assert_eq!(reply.status, 403); assert!( !s.site .store .is_pinned(&s.repo_id, &issue_id) .await .expect("pinned") ); } #[tokio::test] async fn bad_pinned_value_is_rejected_and_unknown_issue_is_404() { let s = seeded().await; let (_, number) = s.issues[0].clone(); let reply = call( &s.site, "POST", &pin_uri(number), Some(s.carol_cookie.as_str()), Some(&[("csrf", CSRF), ("pinned", "yes")][..]), ) .await; assert_eq!(reply.status, 400); let reply = call( &s.site, "POST", &pin_uri(999), Some(s.carol_cookie.as_str()), Some(pin_fields("1").as_slice()), ) .await; assert_eq!(reply.status, 404); } #[tokio::test] async fn fourth_pin_shows_the_limit_and_pins_nothing() { let s = seeded().await; for (id, _) in &s.issues[..3] { s.site.store.pin_issue(&s.repo_id, id).await.expect("pin"); } let (fourth_id, fourth_number) = s.issues[3].clone(); let reply = call( &s.site, "POST", &pin_uri(fourth_number), Some(s.carol_cookie.as_str()), Some(pin_fields("1").as_slice()), ) .await; assert_eq!(reply.status, 200); assert!(reply.body.contains("already has 3 pinned issues")); assert!( !s.site .store .is_pinned(&s.repo_id, &fourth_id) .await .expect("pinned") ); } #[tokio::test] async fn issue_list_shows_pinned_cards_above_the_list() { let s = seeded().await; let (_, number) = s.issues[1].clone(); s.site .store .pin_issue(&s.repo_id, &s.issues[1].0) .await .expect("pin"); let reply = call(&s.site, "GET", "/alice/forge/issues", None, None).await; assert_eq!(reply.status, 200); assert!(reply.body.contains("pinned-card")); assert!( reply .body .contains(&format!("/alice/forge/issues/{number}\"")) ); let card_at = reply.body.find("pinned-card").expect("card"); let list_at = reply.body.find("issue-list").expect("list"); assert!(card_at < list_at); } #[tokio::test] async fn issue_page_offers_pin_to_triage_only() { let s = seeded().await; let (_, number) = s.issues[0].clone(); let page = format!("{PIN_PATH}{number}"); let reply = call(&s.site, "GET", &page, Some(s.carol_cookie.as_str()), None).await; assert_eq!(reply.status, 200); assert!(reply.body.contains("Pin issue")); assert!(reply.body.contains(&format!("{page}/pin"))); let reply = call(&s.site, "GET", &page, Some(s.bob_cookie.as_str()), None).await; assert_eq!(reply.status, 200); assert!(!reply.body.contains("Pin issue")); }