//! Flagging, auto-hide and the moderation actions that resolve them. //! //! Four properties, each security-relevant: //! //! - auto-hide threshold arithmetic: a post hides at the threshold, never below //! it, and never when the threshold is null //! - concurrency: `auto_hide_if_threshold_met` hides exactly once when two //! flaggers race, and endorsement toggles never duplicate under the same race //! - mod-log attribution: an auto-hide logs the System actor, not whichever //! flagger happened to cross the threshold //! - the write-level access gate on flagging: logged out, own post, platform //! suspended and muted are each rejected //! //! Restore is covered alongside removal because the two share the cascade: a //! restore must also resolve the flags, or the post rehides immediately. use crate::harness::TestHarness; use mt_core::types::BanType; // --- auto-hide threshold #[tokio::test] async fn auto_hide_threshold_removes_post_at_threshold() { let mut h = TestHarness::new().await; let author_id = h.login_as("hideauthor").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; // Set auto_hide_threshold to 2 sqlx::query("UPDATE communities SET auto_hide_threshold = 2 WHERE id = $1") .bind(comm_id) .execute(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, author_id, "Auto Hide Test", "Content to hide") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // First flag, should NOT auto-hide (threshold=2, only 1 flag) let flagger1 = h.login_as("flagger1").await; h.add_membership(flagger1, comm_id, "member").await; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag"); h.client.post_form(&flag_url, "reason=spam").await; // Verify NOT removed yet 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 NOT be removed after 1 flag (threshold=2)" ); // Second flag, should auto-hide (2 flags = threshold) let flagger2 = h.login_as("flagger2").await; h.add_membership(flagger2, comm_id, "member").await; h.client.get(&thread_url).await; h.client.post_form(&flag_url, "reason=off_topic").await; 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 auto-hidden after 2 flags (threshold=2)" ); } /// Concurrency: two flags racing the same threshold must auto-hide the post /// EXACTLY once. The `UPDATE ... WHERE removed_at IS NULL` guard plus row-locking /// means only one of two concurrent `auto_hide_if_threshold_met` calls can win; /// the loser sees the row already removed and affects zero rows. Without the /// guard both would set `removed_at` and (via the handler) write two AutoHidePost /// log rows. #[tokio::test] async fn concurrent_flags_auto_hide_exactly_once() { let mut h = TestHarness::new().await; let author_id = h.login_as("raceauthor").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, "Race", "Content") .await; let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap()[0] .id; // Two distinct flaggers, both flags pending → count = 2, threshold = 2. for name in ["racer1", "racer2"] { let uid = h.login_as(name).await; h.add_membership(uid, comm_id, "member").await; mt_db::mutations::insert_flag(&h.db, post_id, uid, "spam", None) .await .unwrap(); } // Fire the atomic auto-hide from two tasks at once, sharing the pool. let db1 = h.db.clone(); let db2 = h.db.clone(); let (r1, r2) = tokio::join!( async move { mt_db::mutations::auto_hide_if_threshold_met(&db1, post_id, 2) .await .unwrap() }, async move { mt_db::mutations::auto_hide_if_threshold_met(&db2, post_id, 2) .await .unwrap() }, ); // Exactly one call may report the removal. assert_ne!( r1, r2, "exactly one concurrent auto-hide must win (got {r1} and {r2})" ); 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 must be removed exactly once"); } /// Concurrency: two simultaneous toggles of the same (post, endorser) must /// converge to a single consistent state, never a duplicate row (the /// `post_endorsements` unique constraint + `ON CONFLICT DO NOTHING` enforce it). #[tokio::test] async fn concurrent_endorse_toggles_never_duplicate() { let mut h = TestHarness::new().await; let author_id = h.login_as("endauthor").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, "owner").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "Endorse", "Content") .await; let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap()[0] .id; let endorser = h.login_as("endorser").await; h.add_membership(endorser, comm_id, "member").await; let db1 = h.db.clone(); let db2 = h.db.clone(); let _ = tokio::join!( async move { mt_db::mutations::toggle_endorsement(&db1, post_id, endorser).await }, async move { mt_db::mutations::toggle_endorsement(&db2, post_id, endorser).await }, ); // Regardless of interleaving, the row count for this (post, endorser) is 0 or 1, // never 2. A duplicate would mean the unique constraint failed. let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM post_endorsements WHERE post_id = $1 AND endorser_id = $2", ) .bind(post_id) .bind(endorser) .fetch_one(&h.db) .await .unwrap(); assert!( count <= 1, "endorsement must never duplicate under concurrency (got {count})" ); } /// Regression (ultra-fuzz Run #5 S3): a flag-threshold auto-hide is a *system* /// action, its mod-log row must carry a NULL actor_id, never the member who /// happened to trip the threshold. And it must actually exist (the log is bound /// to the auto-hide's transaction, not fire-and-forget). #[tokio::test] async fn auto_hide_logs_system_actor_not_flagger() { let mut h = TestHarness::new().await; let author_id = h.login_as("sysauthor").await; let comm_id = h.create_community("Sys", "sys").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; sqlx::query("UPDATE communities SET auto_hide_threshold = 1 WHERE id = $1") .bind(comm_id) .execute(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, author_id, "Sys Hide", "Content") .await; let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap()[0] .id; // One flag from a member trips the threshold of 1. let flagger = h.login_as("tripper").await; h.add_membership(flagger, comm_id, "member").await; let thread_url = format!("/p/sys/general/{thread_id}"); h.client.get(&thread_url).await; let flag_url = format!("/p/sys/general/{thread_id}/posts/{post_id}/flag"); h.client.post_form(&flag_url, "reason=spam").await; // The audit row exists (atomic with the removal) and is attributed to System. let actor: Option = sqlx::query_scalar( "SELECT actor_id FROM mod_log WHERE target_id = $1 AND action = 'auto_hide_post'", ) .bind(post_id) .fetch_one(&h.db) .await .expect("auto-hide must write exactly one mod_log row"); assert_eq!( actor, None, "auto-hide must be logged as System (NULL actor_id), not the flagger" ); assert_ne!( actor, Some(flagger), "the flagger must never be recorded as the auto-hide actor" ); // Regression (fuzz-2026-07-06 top DB fix): the System row must be VISIBLE in // the mod-log listing. list_mod_log INNER JOINed the actor, silently dropping // every NULL-actor row, so the auto-moderation ledger migration 032 exists to // keep was invisible on the page, while count_mod_log counted it (pagination // overcount). The row must now appear, labeled "System", and the two agree. let entries = mt_db::queries::list_mod_log(&h.db, comm_id, 50, 0) .await .unwrap(); let count = mt_db::queries::count_mod_log(&h.db, comm_id).await.unwrap(); assert_eq!( count as usize, entries.len(), "count_mod_log must match the number of rows list_mod_log returns" ); let system_row = entries .iter() .find(|e| e.action == mt_core::types::ModAction::AutoHidePost) .expect("the auto-hide row must be present in the mod-log listing"); assert_eq!( system_row.actor_username, "System", "a NULL-actor auto-hide row must render as the System actor" ); } #[tokio::test] async fn auto_hide_disabled_when_threshold_null() { let mut h = TestHarness::new().await; let author_id = h.login_as("nohideauthor").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; // auto_hide_threshold is NULL by default, no auto-hide let thread_id = h .create_thread_with_post(cat_id, author_id, "No Hide Test", "Content stays") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // Flag 5 times from different users for i in 0..5 { let flagger = h.login_as(&format!("nohideflagger{i}")).await; h.add_membership(flagger, comm_id, "member").await; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag"); h.client.post_form(&flag_url, "reason=spam").await; } // Verify NOT removed (threshold is NULL = disabled) 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 NOT be removed when auto_hide_threshold is NULL" ); } #[tokio::test] async fn settings_saves_auto_hide_threshold() { let mut h = TestHarness::new().await; let owner_id = h.login_as("thresholdowner").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; // Save with threshold=3 let resp = h .client .post_form( "/p/test/settings", "name=Test&description=desc&auto_hide_threshold=3", ) .await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // Verify in DB let threshold: Option = sqlx::query_scalar("SELECT auto_hide_threshold FROM communities WHERE id = $1") .bind(comm_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(threshold, Some(3)); // Save with threshold=0 (disabled) h.client.get("/p/test/settings").await; h.client .post_form( "/p/test/settings", "name=Test&description=desc&auto_hide_threshold=0", ) .await; let threshold: Option = sqlx::query_scalar("SELECT auto_hide_threshold FROM communities WHERE id = $1") .bind(comm_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(threshold, None, "Threshold 0 should be stored as NULL"); } #[tokio::test] async fn flag_post_happy_path() { let mut h = TestHarness::new().await; let author_id = h.login_as("flagauthor").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, "Flag Test", "Content") .await; let flagger_id = h.login_as("flagger").await; h.add_membership(flagger_id, comm_id, "member").await; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag"); let resp = h.client.post_form(&flag_url, "reason=spam").await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // Verify flag in DB let has_flag = mt_db::queries::has_user_flagged_post(&h.db, post_id, flagger_id) .await .unwrap(); assert!(has_flag, "Flag should exist in DB"); } #[tokio::test] async fn duplicate_flag_silently_ignored() { let mut h = TestHarness::new().await; let author_id = h.login_as("dupflagauthor").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, "Dup Flag", "Content") .await; let flagger_id = h.login_as("dupflagger").await; h.add_membership(flagger_id, comm_id, "member").await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag"); // Flag once h.client.post_form(&flag_url, "reason=spam").await; // GET for CSRF refresh h.client.get(&thread_url).await; // Flag again, should not error let resp = h.client.post_form(&flag_url, "reason=off_topic").await; assert!( resp.status.is_redirection(), "Duplicate flag should redirect, got {}", resp.status ); } #[tokio::test] async fn flag_requires_login() { let mut h = TestHarness::new().await; let author_id = h.login_as("flagloginauthor").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 Flag", "Content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // New harness (no login) let mut h2 = TestHarness::new().await; h2.client.get("/").await; let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag"); let resp = h2.client.post_form(&flag_url, "reason=spam").await; assert!( resp.status.is_redirection(), "Expected redirect to login, got {}", resp.status ); } #[tokio::test] async fn flag_own_post_rejected() { let mut h = TestHarness::new().await; let author_id = h.login_as("selfflagauthor").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, "Self Flag", "Content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag"); let resp = h.client.post_form(&flag_url, "reason=spam").await; assert_eq!(resp.status.as_u16(), 403, "Flagging own post should be 403"); } // Flagging gates on write-level access, not read-level. A user who cannot post // must not be able to flag either, since enough flags trip the auto-hide // threshold on someone else's post. Both cases below passed under the old // `check_community_access` gate, which saw neither platform suspension nor mute. #[tokio::test] async fn platform_suspended_user_cannot_flag() { let mut h = TestHarness::new().await; let author_id = h.login_as("suspflagauthor").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, "Suspended Flag", "Content") .await; let flagger_id = h.login_as("suspflagger").await; h.add_membership(flagger_id, comm_id, "member").await; mt_db::mutations::suspend_user(&h.db, flagger_id, Some("test")) .await .unwrap(); let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag"); let resp = h.client.post_form(&flag_url, "reason=spam").await; assert_eq!( resp.status.as_u16(), 403, "A platform-suspended user must not be able to flag" ); let has_flag = mt_db::queries::has_user_flagged_post(&h.db, post_id, flagger_id) .await .unwrap(); assert!(!has_flag, "No flag row should have been written"); } #[tokio::test] async fn muted_user_cannot_flag() { let mut h = TestHarness::new().await; let author_id = h.login_as("muteflagauthor").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, "Muted Flag", "Content") .await; let flagger_id = h.login_as("muteflagger").await; h.add_membership(flagger_id, comm_id, "member").await; mt_db::mutations::create_community_ban( &h.db, comm_id, flagger_id, author_id, BanType::Mute, Some("test"), None, ) .await .unwrap(); let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag"); let resp = h.client.post_form(&flag_url, "reason=spam").await; assert_eq!( resp.status.as_u16(), 403, "A muted user must not be able to flag" ); let has_flag = mt_db::queries::has_user_flagged_post(&h.db, post_id, flagger_id) .await .unwrap(); assert!(!has_flag, "No flag row should have been written"); } #[tokio::test] async fn mod_dismiss_flag() { let mut h = TestHarness::new().await; let author_id = h.login_as("dismissauthor").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, "Dismiss 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 a flag directly let flagger_id = h.login_as("dismissflagger").await; h.add_membership(flagger_id, comm_id, "member").await; mt_db::mutations::insert_flag(&h.db, post_id, flagger_id, "spam", None) .await .unwrap(); let mod_id = h.login_as("dismissmod").await; h.add_membership(mod_id, comm_id, "moderator").await; // Get flag ID let flags = mt_db::queries::list_pending_flags(&h.db, comm_id, 100) .await .unwrap(); assert_eq!(flags.len(), 1); let flag_id = flags[0].flag_id; // GET moderation page for CSRF h.client.get("/p/test/moderation").await; let dismiss_url = format!("/p/test/moderation/flags/{flag_id}/dismiss"); let resp = h.client.post_form(&dismiss_url, "").await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // Verify flag resolved let flags = mt_db::queries::list_pending_flags(&h.db, comm_id, 100) .await .unwrap(); assert_eq!(flags.len(), 0, "Flag should be resolved after dismiss"); } #[tokio::test] async fn mod_remove_via_flag() { let mut h = TestHarness::new().await; let author_id = h.login_as("removeauthor").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 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 flags from two different users let flagger1 = h.login_as("removeflag1").await; h.add_membership(flagger1, comm_id, "member").await; mt_db::mutations::insert_flag(&h.db, post_id, flagger1, "spam", None) .await .unwrap(); let flagger2_id = uuid::Uuid::new_v4(); sqlx::query("INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $2)") .bind(flagger2_id) .bind("removeflag2") .execute(&h.db) .await .unwrap(); mt_db::mutations::insert_flag( &h.db, post_id, flagger2_id, "rule_breaking", Some("bad content"), ) .await .unwrap(); let mod_id = h.login_as("removemod").await; h.add_membership(mod_id, comm_id, "moderator").await; let flags = mt_db::queries::list_pending_flags(&h.db, comm_id, 100) .await .unwrap(); assert_eq!(flags.len(), 2, "Should have 2 pending flags"); let flag_id = flags[0].flag_id; h.client.get("/p/test/moderation").await; let remove_url = format!("/p/test/moderation/flags/{flag_id}/remove"); let resp = h.client.post_form(&remove_url, "").await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // All flags should be resolved let flags = mt_db::queries::list_pending_flags(&h.db, comm_id, 100) .await .unwrap(); assert_eq!(flags.len(), 0, "All flags should be resolved after remove"); // Post should be removed let post: Option<(bool,)> = sqlx::query_as("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1") .bind(post_id) .fetch_optional(&h.db) .await .unwrap(); assert!(post.unwrap().0, "Post should be mod-removed"); } #[tokio::test] async fn remove_flagged_op_cascades_to_thread_delete() { let mut h = TestHarness::new().await; let author_id = h.login_as("flagcascadeauthor").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, "Flag Cascade", "Opening content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let op_id = posts[0].id; // Flag the opening post. let flagger = h.login_as("flagcascadeflagger").await; h.add_membership(flagger, comm_id, "member").await; mt_db::mutations::insert_flag(&h.db, op_id, flagger, "spam", None) .await .unwrap(); // Mod removes the flagged OP via the flag-remove endpoint. let mod_id = h.login_as("flagcascademod").await; h.add_membership(mod_id, comm_id, "moderator").await; let flags = mt_db::queries::list_pending_flags(&h.db, comm_id, 100) .await .unwrap(); let flag_id = flags[0].flag_id; h.client.get("/p/test/moderation").await; let remove_url = format!("/p/test/moderation/flags/{flag_id}/remove"); let resp = h.client.post_form(&remove_url, "").await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // The thread is soft-deleted and now 404s. 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 flagged OP should soft-delete the thread" ); let resp = h.client.get(&format!("/p/test/general/{thread_id}")).await; assert_eq!(resp.status.as_u16(), 404, "Cascaded thread should 404"); // Regression (ultra-fuzz Run #5 M-DI1): the removal, the flag resolution, and // BOTH audit rows commit on one transaction. Assert both log rows landed and // are attributed to the acting moderator (User), not dropped fire-and-forget. let log_rows: Vec<(String, Option)> = sqlx::query_as( "SELECT action, actor_id FROM mod_log WHERE community_id = $1 AND action IN ('remove_post_via_flag', 'delete_thread') ORDER BY action", ) .bind(comm_id) .fetch_all(&h.db) .await .unwrap(); assert_eq!( log_rows.len(), 2, "OP removal via flag must log both remove_post_via_flag and delete_thread" ); for (action, actor) in &log_rows { assert_eq!( *actor, Some(mod_id), "{action} must be attributed to the acting moderator" ); } } // Restore, the reversal of both removal paths /// The gap this closes: before restore existed, a mod who read a bad-faith /// brigade and dismissed the flags left the post hidden forever, with an empty /// queue implying it had been dealt with. #[tokio::test] async fn restore_unhides_auto_hidden_post() { let mut h = TestHarness::new().await; let author_id = h.login_as("restoreauthor").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; sqlx::query("UPDATE communities SET auto_hide_threshold = 2 WHERE id = $1") .bind(comm_id) .execute(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, author_id, "Brigaded", "Perfectly fine post") .await; let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap()[0] .id; let thread_url = format!("/p/test/general/{thread_id}"); let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag"); for name in ["brigade1", "brigade2"] { let flagger = h.login_as(name).await; h.add_membership(flagger, comm_id, "member").await; h.client.get(&thread_url).await; h.client.post_form(&flag_url, "reason=spam").await; } 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, "threshold reached, post should be auto-hidden"); let mod_id = h.login_as("restoremod").await; h.add_membership(mod_id, comm_id, "moderator").await; h.client.get(&thread_url).await; let restore_url = format!("/p/test/general/{thread_id}/posts/{post_id}/restore"); let resp = h.client.post_form(&restore_url, "").await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); let still_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!(!still_removed, "restore must clear removed_at"); let actor: Option = sqlx::query_scalar( "SELECT actor_id FROM mod_log WHERE target_id = $1 AND action = 'restore_post'", ) .bind(post_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( actor, Some(mod_id), "restore must log against the acting moderator, not System" ); } /// Restoring while the post is still over the threshold would re-hide it on the /// very next flag, so restore resolves the outstanding flags. This is the test /// that pins that decision: one fresh flag after a restore must not re-hide. #[tokio::test] async fn restore_resolves_flags_so_the_post_does_not_immediately_rehide() { let mut h = TestHarness::new().await; let author_id = h.login_as("rehideauthor").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; sqlx::query("UPDATE communities SET auto_hide_threshold = 2 WHERE id = $1") .bind(comm_id) .execute(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, author_id, "Rehide", "Fine post") .await; let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap()[0] .id; let thread_url = format!("/p/test/general/{thread_id}"); let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag"); for name in ["rehide1", "rehide2"] { let flagger = h.login_as(name).await; h.add_membership(flagger, comm_id, "member").await; h.client.get(&thread_url).await; h.client.post_form(&flag_url, "reason=spam").await; } let mod_id = h.login_as("rehidemod").await; h.add_membership(mod_id, comm_id, "moderator").await; h.client.get(&thread_url).await; h.client .post_form( &format!("/p/test/general/{thread_id}/posts/{post_id}/restore"), "", ) .await; let pending: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM post_flags WHERE post_id = $1 AND resolved_at IS NULL", ) .bind(post_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(pending, 0, "restore must resolve the outstanding flags"); let flagger = h.login_as("rehide3").await; h.add_membership(flagger, comm_id, "member").await; h.client.get(&thread_url).await; h.client.post_form(&flag_url, "reason=spam").await; 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, "one flag after a restore is below threshold; the post must stay live" ); } #[tokio::test] async fn member_cannot_restore_post() { let mut h = TestHarness::new().await; let author_id = h.login_as("norestoreauthor").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, "NoRestore", "Content") .await; let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap()[0] .id; sqlx::query("UPDATE posts SET removed_at = now() WHERE id = $1") .bind(post_id) .execute(&h.db) .await .unwrap(); let member_id = h.login_as("plainmember").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 resp = h .client .post_form( &format!("/p/test/general/{thread_id}/posts/{post_id}/restore"), "", ) .await; assert_eq!( resp.status, axum::http::StatusCode::FORBIDDEN, "a member must not be able to restore" ); 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 must stay removed"); } /// Restoring a live post is a no-op, not a log entry. A mod-log row for an /// action that changed nothing is worse than no row: it reads as a reversal /// that happened. #[tokio::test] async fn restoring_a_live_post_writes_no_log_row() { let mut h = TestHarness::new().await; let author_id = h.login_as("liveauthor").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, "Live", "Never removed") .await; let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap()[0] .id; let mod_id = h.login_as("nooprestoremod").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; h.client .post_form( &format!("/p/test/general/{thread_id}/posts/{post_id}/restore"), "", ) .await; let rows: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM mod_log WHERE target_id = $1 AND action = 'restore_post'", ) .bind(post_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(rows, 0, "no-op restore must not write a mod-log row"); }