use crate::harness::TestHarness; /// Regression (ultra-fuzz NOTE-1): footnoting your own post by routing the /// request through a *different* community's slug must 404, matching the /// flag/endorse/remove paths. Closes the lone remaining ban-evasion-via-slug gap. #[tokio::test] async fn footnote_via_mismatched_community_slug_is_404() { let mut h = TestHarness::new().await; let author_id = h.login_as("author").await; let comm_a = h.create_community("Alpha", "alpha").await; let cat_a = h.create_category(comm_a, "General", "general").await; h.add_membership(author_id, comm_a, "member").await; let comm_b = h.create_community("Beta", "beta").await; h.create_category(comm_b, "General", "general").await; h.add_membership(author_id, comm_b, "member").await; let thread_id = h .create_thread_with_post(cat_a, author_id, "Post in A", "Content") .await; let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap()[0] .id; h.client.get(&format!("/p/alpha/general/{thread_id}")).await; // CSRF // Author footnotes their own post but via community B's slug. let url = format!("/p/beta/general/{thread_id}/posts/{post_id}/footnote"); let resp = h.client.post_form(&url, "body=sneaky+footnote").await; assert_eq!( resp.status.as_u16(), 404, "mismatched-slug footnote must 404" ); let count = mt_db::queries::count_footnotes_for_post(&h.db, post_id) .await .unwrap(); assert_eq!(count, 0, "no footnote should be recorded"); } #[tokio::test] async fn endorse_post_happy_path() { let mut h = TestHarness::new().await; let author_id = h.login_as("author").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "Good Post", "Content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let endorser_id = h.login_as("endorser").await; h.add_membership(endorser_id, comm_id, "member").await; // GET thread page for CSRF let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let endorse_url = format!("/p/test/general/{thread_id}/posts/{post_id}/endorse"); let resp = h.client.post_form(&endorse_url, "").await; assert!( resp.status.is_redirection(), "Expected redirect after endorse, got {}", resp.status ); let endorsements = mt_db::queries::list_endorsements_for_posts(&h.db, &[post_id]) .await .unwrap(); assert_eq!(endorsements.len(), 1); assert_eq!(endorsements[0].endorser_id, endorser_id); } #[tokio::test] async fn endorse_via_mismatched_community_slug_is_404() { // A post lives in community A; routing the endorse through community B's // slug must 404 (post.community_id != slug-derived community), so a user // banned in A can't evade the ban check via another slug (audit S-2). let mut h = TestHarness::new().await; let author_id = h.login_as("author").await; let comm_a = h.create_community("Alpha", "alpha").await; let cat_a = h.create_category(comm_a, "General", "general").await; h.add_membership(author_id, comm_a, "member").await; // A second, unrelated community the attacker is a member of. let comm_b = h.create_community("Beta", "beta").await; h.create_category(comm_b, "General", "general").await; let thread_id = h .create_thread_with_post(cat_a, author_id, "Post in A", "Content") .await; let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap()[0] .id; let endorser_id = h.login_as("endorser").await; h.add_membership(endorser_id, comm_b, "member").await; h.client.get(&format!("/p/alpha/general/{thread_id}")).await; // CSRF // Use community B's slug with A's thread/post ids. let url = format!("/p/beta/general/{thread_id}/posts/{post_id}/endorse"); let resp = h.client.post_form(&url, "").await; assert_eq!( resp.status.as_u16(), 404, "mismatched-slug endorse must 404" ); let endorsements = mt_db::queries::list_endorsements_for_posts(&h.db, &[post_id]) .await .unwrap(); assert!(endorsements.is_empty(), "no endorsement should be recorded"); } #[tokio::test] async fn toggle_endorsement_removes() { let mut h = TestHarness::new().await; let author_id = h.login_as("author2").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "Toggle Post", "Content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let toggler_id = h.login_as("toggler").await; h.add_membership(toggler_id, comm_id, "member").await; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let endorse_url = format!("/p/test/general/{thread_id}/posts/{post_id}/endorse"); // First endorse h.client.post_form(&endorse_url, "").await; let endorsements = mt_db::queries::list_endorsements_for_posts(&h.db, &[post_id]) .await .unwrap(); assert_eq!( endorsements.len(), 1, "Should have 1 endorsement after first toggle" ); // GET again for fresh CSRF h.client.get(&thread_url).await; // Second endorse (un-endorse) h.client.post_form(&endorse_url, "").await; let endorsements = mt_db::queries::list_endorsements_for_posts(&h.db, &[post_id]) .await .unwrap(); assert_eq!( endorsements.len(), 0, "Should have 0 endorsements after second toggle" ); } #[tokio::test] async fn self_endorse_rejected() { let mut h = TestHarness::new().await; let author_id = h.login_as("selfendorser").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "My Post", "Content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // GET thread page for CSRF let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let endorse_url = format!("/p/test/general/{thread_id}/posts/{post_id}/endorse"); let resp = h.client.post_form(&endorse_url, "").await; assert_eq!(resp.status.as_u16(), 403, "Self-endorsement should be 403"); } #[tokio::test] async fn endorse_requires_login() { let mut h = TestHarness::new().await; let author_id = h.login_as("loginauthor").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "Login Test", "Content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // Don't login; create a fresh harness without login let mut h2 = TestHarness::new().await; // GET some page for CSRF h2.client.get("/").await; let endorse_url = format!("/p/test/general/{thread_id}/posts/{post_id}/endorse"); let resp = h2.client.post_form(&endorse_url, "").await; assert!( resp.status.is_redirection(), "Expected redirect to login, got {}", resp.status ); } #[tokio::test] async fn endorse_removed_post_rejected() { let mut h = TestHarness::new().await; let author_id = h.login_as("removedauthor").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "Removed Post", "Content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; mt_db::mutations::mod_remove_post(&h.db, post_id, author_id) .await .unwrap(); let endorser_id = h.login_as("removedendorser").await; h.add_membership(endorser_id, comm_id, "member").await; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let endorse_url = format!("/p/test/general/{thread_id}/posts/{post_id}/endorse"); let resp = h.client.post_form(&endorse_url, "").await; assert_eq!( resp.status.as_u16(), 403, "Endorsing removed post should be 403" ); } #[tokio::test] async fn endorsement_count_visible_to_author() { let mut h = TestHarness::new().await; let author_id = h.login_as("countauthor").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "Count Test", "Content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // Insert endorsement directly via DB (avoids needing to switch users) let endorser_id = uuid::Uuid::new_v4(); sqlx::query("INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $2)") .bind(endorser_id) .bind("countendorser") .execute(&h.db) .await .unwrap(); mt_db::mutations::toggle_endorsement(&h.db, post_id, endorser_id) .await .unwrap(); // Author is still logged in, view thread let thread_url = format!("/p/test/general/{thread_id}"); let resp = h.client.get(&thread_url).await; assert!( resp.text.contains("endorsement-count"), "Author should see endorsement count" ); } #[tokio::test] async fn endorsement_count_visible_to_mod() { let mut h = TestHarness::new().await; let author_id = h.login_as("modcountauthor").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "Mod Count Test", "Content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let endorser_id = h.login_as("modcountendorser").await; h.add_membership(endorser_id, comm_id, "member").await; mt_db::mutations::toggle_endorsement(&h.db, post_id, endorser_id) .await .unwrap(); let mod_id = h.login_as("modviewer").await; h.add_membership(mod_id, comm_id, "moderator").await; let thread_url = format!("/p/test/general/{thread_id}"); let resp = h.client.get(&thread_url).await; assert!( resp.text.contains("endorsement-count"), "Mod should see endorsement count" ); } #[tokio::test] async fn endorsement_count_hidden_from_others() { let mut h = TestHarness::new().await; let author_id = h.login_as("hiddenauthor").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "Hidden Count", "Content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let endorser_id = h.login_as("hiddenendorser").await; h.add_membership(endorser_id, comm_id, "member").await; mt_db::mutations::toggle_endorsement(&h.db, post_id, endorser_id) .await .unwrap(); // Login as a third user (not author, not endorser, not mod) let bystander_id = h.login_as("bystander").await; h.add_membership(bystander_id, comm_id, "member").await; let thread_url = format!("/p/test/general/{thread_id}"); let resp = h.client.get(&thread_url).await; assert!( !resp.text.contains("endorsement-count"), "Bystander should NOT see endorsement count" ); }