use crate::harness::TestHarness; #[tokio::test] async fn pin_toggle() { let mut h = TestHarness::new().await; let mod_id = h.login_as("pintoggler").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(mod_id, comm_id, "moderator").await; let thread_id = h .create_thread_with_post(cat_id, mod_id, "Toggle Pin", "Body") .await; let thread_url = format!("/p/test/general/{thread_id}"); // Pin the thread h.client.get(&thread_url).await; let pin_url = format!("/p/test/general/{thread_id}/pin"); h.client.post_form(&pin_url, "").await; // Verify pinned badge let resp = h.client.get(&thread_url).await; assert!(resp.text.contains("[pinned]"), "Expected [pinned] badge"); // Unpin h.client.post_form(&pin_url, "").await; let resp = h.client.get(&thread_url).await; assert!( !resp.text.contains("[pinned]"), "Expected [pinned] badge to be gone after unpin" ); } #[tokio::test] async fn lock_prevents_replies() { let mut h = TestHarness::new().await; let mod_id = h.login_as("locker").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(mod_id, comm_id, "moderator").await; let thread_id = h .create_thread_with_post(cat_id, mod_id, "Lock Test", "Body") .await; let thread_url = format!("/p/test/general/{thread_id}"); // Lock h.client.get(&thread_url).await; let lock_url = format!("/p/test/general/{thread_id}/lock"); h.client.post_form(&lock_url, "").await; // Try to reply h.client.get(&thread_url).await; let reply_url = format!("/p/test/general/{thread_id}/reply"); let resp = h.client.post_form(&reply_url, "body=Nope").await; assert_eq!(resp.status.as_u16(), 403); } #[tokio::test] async fn pinned_threads_first_in_listing() { let mut h = TestHarness::new().await; let mod_id = h.login_as("orderer").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(mod_id, comm_id, "moderator").await; // Create two threads let _thread1 = h .create_thread_with_post(cat_id, mod_id, "First Thread", "First body") .await; // Small delay so last_activity differs tokio::time::sleep(std::time::Duration::from_millis(50)).await; let thread2 = h .create_thread_with_post(cat_id, mod_id, "Second Thread", "Second body") .await; // Pin the second thread mt_db::mutations::set_thread_pinned(&h.db, thread2, true) .await .unwrap(); let resp = h.client.get("/p/test/general").await; // The pinned "Second Thread" should appear before "First Thread" let pos_second = resp .text .find("Second Thread") .expect("Second Thread not found"); let pos_first = resp .text .find("First Thread") .expect("First Thread not found"); assert!( pos_second < pos_first, "Pinned thread should appear before unpinned thread" ); } #[tokio::test] async fn update_community_settings() { let mut h = TestHarness::new().await; let owner_id = h.login_as("settingswriter").await; let comm_id = h.create_community("Old Name", "test").await; h.add_membership(owner_id, comm_id, "owner").await; let _cat_id = h.create_category(comm_id, "General", "general").await; // GET settings to get CSRF h.client.get("/p/test/settings").await; let resp = h .client .post_form("/p/test/settings", "name=New+Name&description=Updated+desc") .await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // Verify the name was saved let community = mt_db::queries::get_community_by_slug(&h.db, "test") .await .unwrap() .unwrap(); assert_eq!(community.name, "New Name"); } #[tokio::test] async fn create_category_via_settings() { let mut h = TestHarness::new().await; let owner_id = h.login_as("catcreator").await; let comm_id = h.create_community("Test", "test").await; h.add_membership(owner_id, comm_id, "owner").await; let _cat_id = h.create_category(comm_id, "General", "general").await; // GET settings for CSRF h.client.get("/p/test/settings").await; let resp = h .client .post_form( "/p/test/settings/categories/new", "name=New+Category&slug=new-cat&description=A+new+category", ) .await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // Verify category appears in settings let resp = h.client.get("/p/test/settings").await; assert!( resp.text.contains("New Category"), "New category should appear in settings page" ); } #[tokio::test] async fn edit_category_via_settings() { let mut h = TestHarness::new().await; let owner_id = h.login_as("cateditor").await; let comm_id = h.create_community("Test", "test").await; h.add_membership(owner_id, comm_id, "owner").await; let cat_id = h.create_category(comm_id, "Old Name", "general").await; // GET edit form for CSRF h.client .get(&format!("/p/test/settings/categories/{cat_id}/edit")) .await; let resp = h .client .post_form( &format!("/p/test/settings/categories/{cat_id}/edit"), "name=New+Name&description=Updated", ) .await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // Verify the name was saved let cat = mt_db::queries::get_category_in_community(&h.db, cat_id, comm_id) .await .unwrap() .unwrap(); assert_eq!(cat.name, "New Name"); } #[tokio::test] async fn edit_category_form_via_wrong_community_404s() { // C1: an owner of community B must not render community A's category edit form // by borrowing B's slug with A's category id. let mut h = TestHarness::new().await; let owner_id = h.login_as("owner").await; let comm_a = h.create_community("Alpha", "alpha").await; h.add_membership(owner_id, comm_a, "owner").await; let cat_a = h.create_category(comm_a, "Secret", "secret").await; let comm_b = h.create_community("Beta", "beta").await; h.add_membership(owner_id, comm_b, "owner").await; // Owner of beta requests alpha's category through beta's slug → 404. let resp = h .client .get(&format!("/p/beta/settings/categories/{cat_a}/edit")) .await; assert_eq!( resp.status, axum::http::StatusCode::NOT_FOUND, "got {}", resp.status ); // Sanity: through the correct slug it still renders. let resp = h .client .get(&format!("/p/alpha/settings/categories/{cat_a}/edit")) .await; assert_eq!( resp.status, axum::http::StatusCode::OK, "got {}", resp.status ); } #[tokio::test] async fn reorder_categories_via_settings() { let mut h = TestHarness::new().await; let owner_id = h.login_as("catmover").await; let comm_id = h.create_community("Test", "test").await; h.add_membership(owner_id, comm_id, "owner").await; // Create two categories with explicit sort order let cat_a = h.create_category(comm_id, "Alpha", "alpha").await; // Update sort_order so cat_a=0 (default from create_category) sqlx::query("UPDATE categories SET sort_order = 1 WHERE id = $1") .bind(cat_a) .execute(&h.db) .await .unwrap(); let cat_b = h.create_category(comm_id, "Beta", "beta").await; sqlx::query("UPDATE categories SET sort_order = 2 WHERE id = $1") .bind(cat_b) .execute(&h.db) .await .unwrap(); // Verify initial order: Alpha(1), Beta(2) let cats = mt_db::queries::list_categories_for_settings(&h.db, comm_id) .await .unwrap(); assert_eq!(cats[0].name, "Alpha"); assert_eq!(cats[1].name, "Beta"); // Move Alpha down (swap with Beta) h.client.get("/p/test/settings").await; let resp = h .client .post_form( &format!("/p/test/settings/categories/{cat_a}/move"), "direction=down", ) .await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // Verify new order: Beta(1), Alpha(2) let cats = mt_db::queries::list_categories_for_settings(&h.db, comm_id) .await .unwrap(); assert_eq!(cats[0].name, "Beta", "Beta should be first after move"); assert_eq!(cats[1].name, "Alpha", "Alpha should be second after move"); } // Post removal via direct handler (not via flag) #[tokio::test] async fn mod_remove_post_directly() { let mut h = TestHarness::new().await; let author_id = h.login_as("postauthor").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, "Remove Direct", "Content to remove") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // Log in as moderator let mod_id = h.login_as("directmod").await; h.add_membership(mod_id, comm_id, "moderator").await; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let remove_url = format!("/p/test/general/{thread_id}/posts/{post_id}/remove"); let resp = h.client.post_form(&remove_url, "").await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // Verify post is removed in DB let removed: bool = sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1") .bind(post_id) .fetch_one(&h.db) .await .unwrap(); assert!(removed, "Post should be marked as removed"); // Verify removed_by is the mod let removed_by: uuid::Uuid = sqlx::query_scalar("SELECT removed_by FROM posts WHERE id = $1") .bind(post_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(removed_by, mod_id, "removed_by should be the moderator"); } #[tokio::test] async fn member_cannot_remove_post() { let mut h = TestHarness::new().await; let author_id = h.login_as("noremoveauthor").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, "No Remove", "Content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // Log in as a different member (not mod) let member_id = h.login_as("regularmember").await; h.add_membership(member_id, comm_id, "member").await; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let remove_url = format!("/p/test/general/{thread_id}/posts/{post_id}/remove"); let resp = h.client.post_form(&remove_url, "").await; assert_eq!( resp.status.as_u16(), 403, "Non-mod should get 403 when trying to remove a post" ); } #[tokio::test] async fn removed_post_shows_removed_in_thread() { let mut h = TestHarness::new().await; let author_id = h.login_as("removedviewauthor").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, "View Removed", "Visible content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // Mod removes the post mt_db::mutations::mod_remove_post(&h.db, post_id, author_id) .await .unwrap(); // View the thread, should contain the "removed" CSS class let thread_url = format!("/p/test/general/{thread_id}"); let resp = h.client.get(&thread_url).await; assert!( resp.text.contains("post-removed"), "Removed post should have 'post-removed' class in thread view" ); } // Mod log page #[tokio::test] async fn mod_log_shows_actions() { let mut h = TestHarness::new().await; let mod_id = h.login_as("logmod").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(mod_id, comm_id, "moderator").await; // Perform a mod action (pin a thread) to generate a log entry let thread_id = h .create_thread_with_post(cat_id, mod_id, "Log Test Thread", "Body") .await; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let pin_url = format!("/p/test/general/{thread_id}/pin"); h.client.post_form(&pin_url, "").await; // View mod log let resp = h.client.get("/p/test/moderation/log").await; assert_eq!(resp.status.as_u16(), 200); assert!( resp.text.contains("pin_thread"), "Mod log should contain the pin_thread action" ); assert!( resp.text.contains("logmod"), "Mod log should show the acting moderator's username" ); } #[tokio::test] async fn mod_log_forbidden_for_members() { let mut h = TestHarness::new().await; let member_id = h.login_as("logmember").await; let comm_id = h.create_community("Test", "test").await; h.add_membership(member_id, comm_id, "member").await; let resp = h.client.get("/p/test/moderation/log").await; assert_eq!( resp.status.as_u16(), 403, "Non-mod should get 403 for mod log" ); } #[tokio::test] async fn moderation_page_shows_bans_and_flags() { let mut h = TestHarness::new().await; let owner_id = h.login_as("modpageowner").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(owner_id, comm_id, "owner").await; // Create a member and ban them let member_id = uuid::Uuid::new_v4(); sqlx::query( "INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, 'banneduser', 'Banned User')", ) .bind(member_id) .execute(&h.db) .await .unwrap(); h.add_membership(member_id, comm_id, "member").await; h.ban_user(comm_id, member_id, owner_id, "ban").await; // Create a post and flag it let thread_id = h .create_thread_with_post(cat_id, owner_id, "Flagged Thread", "Some content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let flagger_id = uuid::Uuid::new_v4(); sqlx::query( "INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, 'flaggeruser', 'Flagger')", ) .bind(flagger_id) .execute(&h.db) .await .unwrap(); mt_db::mutations::insert_flag(&h.db, post_id, flagger_id, "spam", None) .await .unwrap(); // View moderation page let resp = h.client.get("/p/test/moderation").await; assert_eq!(resp.status.as_u16(), 200); assert!( resp.text.contains("banneduser"), "Moderation page should show banned user" ); assert!( resp.text.contains("spam"), "Moderation page should show pending flag reason" ); } // OP-removal cascade, removing the opening post soft-deletes the whole thread #[tokio::test] async fn removing_op_cascades_to_thread_delete() { let mut h = TestHarness::new().await; let author_id = h.login_as("cascadeauthor").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, "Cascade Thread", "Opening content") .await; // Add a reply so the thread is more than just its OP. mt_db::mutations::create_post(&h.db, thread_id, author_id, "A reply", "
A reply
") .await .unwrap(); let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let op_id = posts[0].id; // Mod removes the opening post via the HTTP endpoint. let mod_id = h.login_as("cascademod").await; h.add_membership(mod_id, comm_id, "moderator").await; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; // prime CSRF token let remove_url = format!("/p/test/general/{thread_id}/posts/{op_id}/remove"); let resp = h.client.post_form(&remove_url, "").await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // The whole thread is now soft-deleted. let deleted: (bool,) = sqlx::query_as("SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1") .bind(thread_id) .fetch_one(&h.db) .await .unwrap(); assert!(deleted.0, "Removing the OP should soft-delete the thread"); // The thread page now 404s instead of leaving a headless thread. let resp = h.client.get(&thread_url).await; assert_eq!(resp.status.as_u16(), 404, "Cascaded thread should 404"); // Replying to the cascaded thread is rejected (no live headless thread). let reply_resp = h .client .post_form(&format!("{thread_url}/reply"), "body=ghost+reply") .await; assert_eq!( reply_resp.status.as_u16(), 404, "Replying to a cascaded thread should 404" ); } #[tokio::test] async fn removing_reply_does_not_cascade() { let mut h = TestHarness::new().await; let author_id = h.login_as("noncascadeauthor").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, "Survivor Thread", "Opening content") .await; mt_db::mutations::create_post(&h.db, thread_id, author_id, "A reply", "A reply
") .await .unwrap(); let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let reply_id = posts[1].id; // Mod removes the reply (not the OP). let mod_id = h.login_as("noncascademod").await; h.add_membership(mod_id, comm_id, "moderator").await; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let remove_url = format!("/p/test/general/{thread_id}/posts/{reply_id}/remove"); let resp = h.client.post_form(&remove_url, "").await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // The thread survives: not deleted, still viewable. let deleted: (bool,) = sqlx::query_as("SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1") .bind(thread_id) .fetch_one(&h.db) .await .unwrap(); assert!(!deleted.0, "Removing a reply must not delete the thread"); let resp = h.client.get(&thread_url).await; assert_eq!(resp.status.as_u16(), 200, "Thread should still be viewable"); } /// Regression (ultra-fuzz CHRONIC C1): a thread can only be acted on through its /// own community's slug. CommunityScope::resolve 404s when the slug names a /// different community than the resource lives in, even for a user who is a /// moderator/owner in the slug's community, so role can't paper over the /// mismatch. This is the structural seal the per-site guard kept failing to hold. #[tokio::test] async fn delete_thread_via_wrong_community_slug_404s() { let mut h = TestHarness::new().await; let actor = h.login_as("c1actor").await; let alpha = h.create_community("Alpha", "alpha").await; let alpha_cat = h.create_category(alpha, "General", "general").await; h.add_membership(actor, alpha, "moderator").await; // The actor is an OWNER in Beta, so a role check against Beta would pass, // only the resource-in-community check can stop this. let beta = h.create_community("Beta", "beta").await; let _beta_cat = h.create_category(beta, "General", "general").await; h.add_membership(actor, beta, "owner").await; let thread = h .create_thread_with_post(alpha_cat, actor, "Lives in Alpha", "Body") .await; // Attempt to delete Alpha's thread through Beta's slug. h.client.get("/p/beta/general").await; let url = format!("/p/beta/general/{thread}/delete"); let resp = h.client.post_form(&url, "").await; assert_eq!( resp.status.as_u16(), 404, "a thread acted on through the wrong community's slug must 404" ); // Alpha's thread is untouched. let deleted: bool = sqlx::query_scalar("SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1") .bind(thread) .fetch_one(&h.db) .await .unwrap(); assert!( !deleted, "thread must not be deleted via a mismatched community slug" ); } // Deleted-threads surface /// The gap this closes: removing an OP cascades the thread to `deleted_at`, and /// every thread loader filters on that, so the thread page 404s and the /// post-level restore control (which lives on that page) is unreachable. Without /// this surface the delete is permanent even though every row survives. #[tokio::test] async fn deleted_thread_can_be_restored_after_op_removal_cascade() { let mut h = TestHarness::new().await; let author_id = h.login_as("cascadeauthor").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, "Cascade Restore", "Opening content") .await; let op_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap()[0] .id; let mod_id = h.login_as("cascaderestoremod").await; h.add_membership(mod_id, comm_id, "moderator").await; h.client.get(&format!("/p/test/general/{thread_id}")).await; h.client .post_form( &format!("/p/test/general/{thread_id}/posts/{op_id}/remove"), "", ) .await; // The thread page is gone, so the post-level restore control cannot be reached. let resp = h.client.get(&format!("/p/test/general/{thread_id}")).await; assert_eq!( resp.status, axum::http::StatusCode::NOT_FOUND, "cascade-deleted thread must 404" ); // The deleted-threads page lists it. let resp = h.client.get("/p/test/moderation/deleted").await; assert!(resp.status.is_success()); assert!( resp.text.contains("Cascade Restore"), "deleted thread must be listed for restore" ); let resp = h .client .post_form( &format!("/p/test/moderation/threads/{thread_id}/restore"), "", ) .await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); let (thread_deleted, op_removed): (bool, bool) = sqlx::query_as( "SELECT (SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1), (SELECT removed_at IS NOT NULL FROM posts WHERE id = $2)", ) .bind(thread_id) .bind(op_id) .fetch_one(&h.db) .await .unwrap(); assert!(!thread_deleted, "thread must be restored"); assert!( !op_removed, "the opening post must come back with the thread, not leave it headless" ); let resp = h.client.get(&format!("/p/test/general/{thread_id}")).await; assert!(resp.status.is_success(), "restored thread must load again"); } /// The other delete path: a mod deletes the thread directly and the opening post /// is never removed. Restore must not "restore" a post that was fine all along. #[tokio::test] async fn directly_deleted_thread_restores_without_touching_the_op() { let mut h = TestHarness::new().await; let author_id = h.login_as("directdelauthor").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, "Direct Delete", "Opening content") .await; let op_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap()[0] .id; let mod_id = h.login_as("directdelmod").await; h.add_membership(mod_id, comm_id, "moderator").await; h.client.get(&format!("/p/test/general/{thread_id}")).await; h.client .post_form(&format!("/p/test/general/{thread_id}/delete"), "") .await; let deleted: bool = sqlx::query_scalar("SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1") .bind(thread_id) .fetch_one(&h.db) .await .unwrap(); assert!(deleted, "thread should be soft-deleted"); h.client .post_form( &format!("/p/test/moderation/threads/{thread_id}/restore"), "", ) .await; let (thread_deleted, op_removed): (bool, bool) = sqlx::query_as( "SELECT (SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1), (SELECT removed_at IS NOT NULL FROM posts WHERE id = $2)", ) .bind(thread_id) .bind(op_id) .fetch_one(&h.db) .await .unwrap(); assert!(!thread_deleted, "thread must be restored"); assert!(!op_removed, "the OP was never removed and must stay live"); let rows: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM mod_log WHERE target_id = $1 AND action = 'restore_post'", ) .bind(thread_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( rows, 0, "no post was restored, so no restore_post row should be logged" ); } #[tokio::test] async fn member_cannot_see_or_restore_deleted_threads() { let mut h = TestHarness::new().await; let author_id = h.login_as("nodelauthor").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", "Opening content") .await; sqlx::query("UPDATE threads SET deleted_at = now() WHERE id = $1") .bind(thread_id) .execute(&h.db) .await .unwrap(); let member_id = h.login_as("nodelmember").await; h.add_membership(member_id, comm_id, "member").await; let resp = h.client.get("/p/test/moderation/deleted").await; assert_eq!( resp.status, axum::http::StatusCode::FORBIDDEN, "a member must not see the deleted-threads list" ); let resp = h .client .post_form( &format!("/p/test/moderation/threads/{thread_id}/restore"), "", ) .await; assert_eq!( resp.status, axum::http::StatusCode::FORBIDDEN, "a member must not restore a thread" ); let deleted: bool = sqlx::query_scalar("SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1") .bind(thread_id) .fetch_one(&h.db) .await .unwrap(); assert!(deleted, "thread must stay deleted"); } /// The community-id check in `get_deleted_thread_in_community` is what stops a /// mod of one community restoring another's thread; `CommunityScope` cannot do /// it here because every scoped thread loader filters deleted rows out. #[tokio::test] async fn mod_cannot_restore_a_thread_in_another_community() { let mut h = TestHarness::new().await; let author_id = h.login_as("xcommauthor").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 thread_id = h .create_thread_with_post(cat_a, author_id, "Alpha Thread", "Opening content") .await; sqlx::query("UPDATE threads SET deleted_at = now() WHERE id = $1") .bind(thread_id) .execute(&h.db) .await .unwrap(); let comm_b = h.create_community("Beta", "beta").await; h.create_category(comm_b, "General", "general").await; let mod_b = h.login_as("betamod").await; h.add_membership(mod_b, comm_b, "moderator").await; let resp = h .client .post_form( &format!("/p/beta/moderation/threads/{thread_id}/restore"), "", ) .await; assert_eq!( resp.status, axum::http::StatusCode::NOT_FOUND, "beta's mod must not reach alpha's thread" ); let deleted: bool = sqlx::query_scalar("SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1") .bind(thread_id) .fetch_one(&h.db) .await .unwrap(); assert!(deleted, "alpha's thread must stay deleted"); }