Skip to main content

max / makenotwork

34.9 KB · 1026 lines History Blame Raw
1 //! Flagging, auto-hide and the moderation actions that resolve them.
2 //!
3 //! Four properties, each security-relevant:
4 //!
5 //! - auto-hide threshold arithmetic: a post hides at the threshold, never below
6 //! it, and never when the threshold is null
7 //! - concurrency: `auto_hide_if_threshold_met` hides exactly once when two
8 //! flaggers race, and endorsement toggles never duplicate under the same race
9 //! - mod-log attribution: an auto-hide logs the System actor, not whichever
10 //! flagger happened to cross the threshold
11 //! - the write-level access gate on flagging: logged out, own post, platform
12 //! suspended and muted are each rejected
13 //!
14 //! Restore is covered alongside removal because the two share the cascade: a
15 //! restore must also resolve the flags, or the post rehides immediately.
16
17 use crate::harness::TestHarness;
18 use mt_core::types::BanType;
19
20 // --- auto-hide threshold
21
22 #[tokio::test]
23 async fn auto_hide_threshold_removes_post_at_threshold() {
24 let mut h = TestHarness::new().await;
25 let author_id = h.login_as("hideauthor").await;
26 let comm_id = h.create_community("Test", "test").await;
27 let cat_id = h.create_category(comm_id, "General", "general").await;
28 h.add_membership(author_id, comm_id, "member").await;
29
30 // Set auto_hide_threshold to 2
31 sqlx::query("UPDATE communities SET auto_hide_threshold = 2 WHERE id = $1")
32 .bind(comm_id)
33 .execute(&h.db)
34 .await
35 .unwrap();
36
37 let thread_id = h
38 .create_thread_with_post(cat_id, author_id, "Auto Hide Test", "Content to hide")
39 .await;
40
41 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
42 .await
43 .unwrap();
44 let post_id = posts[0].id;
45
46 // First flag, should NOT auto-hide (threshold=2, only 1 flag)
47 let flagger1 = h.login_as("flagger1").await;
48 h.add_membership(flagger1, comm_id, "member").await;
49 let thread_url = format!("/p/test/general/{thread_id}");
50 h.client.get(&thread_url).await;
51 let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag");
52 h.client.post_form(&flag_url, "reason=spam").await;
53
54 // Verify NOT removed yet
55 let removed: bool =
56 sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
57 .bind(post_id)
58 .fetch_one(&h.db)
59 .await
60 .unwrap();
61 assert!(
62 !removed,
63 "Post should NOT be removed after 1 flag (threshold=2)"
64 );
65
66 // Second flag, should auto-hide (2 flags = threshold)
67 let flagger2 = h.login_as("flagger2").await;
68 h.add_membership(flagger2, comm_id, "member").await;
69 h.client.get(&thread_url).await;
70 h.client.post_form(&flag_url, "reason=off_topic").await;
71
72 let removed: bool =
73 sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
74 .bind(post_id)
75 .fetch_one(&h.db)
76 .await
77 .unwrap();
78 assert!(
79 removed,
80 "Post should be auto-hidden after 2 flags (threshold=2)"
81 );
82 }
83
84 /// Concurrency: two flags racing the same threshold must auto-hide the post
85 /// EXACTLY once. The `UPDATE ... WHERE removed_at IS NULL` guard plus row-locking
86 /// means only one of two concurrent `auto_hide_if_threshold_met` calls can win;
87 /// the loser sees the row already removed and affects zero rows. Without the
88 /// guard both would set `removed_at` and (via the handler) write two AutoHidePost
89 /// log rows.
90 #[tokio::test]
91 async fn concurrent_flags_auto_hide_exactly_once() {
92 let mut h = TestHarness::new().await;
93 let author_id = h.login_as("raceauthor").await;
94 let comm_id = h.create_community("Test", "test").await;
95 let cat_id = h.create_category(comm_id, "General", "general").await;
96 h.add_membership(author_id, comm_id, "member").await;
97
98 let thread_id = h
99 .create_thread_with_post(cat_id, author_id, "Race", "Content")
100 .await;
101 let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
102 .await
103 .unwrap()[0]
104 .id;
105
106 // Two distinct flaggers, both flags pending → count = 2, threshold = 2.
107 for name in ["racer1", "racer2"] {
108 let uid = h.login_as(name).await;
109 h.add_membership(uid, comm_id, "member").await;
110 mt_db::mutations::insert_flag(&h.db, post_id, uid, "spam", None)
111 .await
112 .unwrap();
113 }
114
115 // Fire the atomic auto-hide from two tasks at once, sharing the pool.
116 let db1 = h.db.clone();
117 let db2 = h.db.clone();
118 let (r1, r2) = tokio::join!(
119 async move {
120 mt_db::mutations::auto_hide_if_threshold_met(&db1, post_id, 2)
121 .await
122 .unwrap()
123 },
124 async move {
125 mt_db::mutations::auto_hide_if_threshold_met(&db2, post_id, 2)
126 .await
127 .unwrap()
128 },
129 );
130
131 // Exactly one call may report the removal.
132 assert_ne!(
133 r1, r2,
134 "exactly one concurrent auto-hide must win (got {r1} and {r2})"
135 );
136
137 let removed: bool =
138 sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
139 .bind(post_id)
140 .fetch_one(&h.db)
141 .await
142 .unwrap();
143 assert!(removed, "post must be removed exactly once");
144 }
145
146 /// Concurrency: two simultaneous toggles of the same (post, endorser) must
147 /// converge to a single consistent state, never a duplicate row (the
148 /// `post_endorsements` unique constraint + `ON CONFLICT DO NOTHING` enforce it).
149 #[tokio::test]
150 async fn concurrent_endorse_toggles_never_duplicate() {
151 let mut h = TestHarness::new().await;
152 let author_id = h.login_as("endauthor").await;
153 let comm_id = h.create_community("Test", "test").await;
154 let cat_id = h.create_category(comm_id, "General", "general").await;
155 h.add_membership(author_id, comm_id, "owner").await;
156
157 let thread_id = h
158 .create_thread_with_post(cat_id, author_id, "Endorse", "Content")
159 .await;
160 let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
161 .await
162 .unwrap()[0]
163 .id;
164
165 let endorser = h.login_as("endorser").await;
166 h.add_membership(endorser, comm_id, "member").await;
167
168 let db1 = h.db.clone();
169 let db2 = h.db.clone();
170 let _ = tokio::join!(
171 async move { mt_db::mutations::toggle_endorsement(&db1, post_id, endorser).await },
172 async move { mt_db::mutations::toggle_endorsement(&db2, post_id, endorser).await },
173 );
174
175 // Regardless of interleaving, the row count for this (post, endorser) is 0 or 1,
176 // never 2. A duplicate would mean the unique constraint failed.
177 let count: i64 = sqlx::query_scalar(
178 "SELECT COUNT(*) FROM post_endorsements WHERE post_id = $1 AND endorser_id = $2",
179 )
180 .bind(post_id)
181 .bind(endorser)
182 .fetch_one(&h.db)
183 .await
184 .unwrap();
185 assert!(
186 count <= 1,
187 "endorsement must never duplicate under concurrency (got {count})"
188 );
189 }
190
191 /// Regression (ultra-fuzz Run #5 S3): a flag-threshold auto-hide is a *system*
192 /// action, its mod-log row must carry a NULL actor_id, never the member who
193 /// happened to trip the threshold. And it must actually exist (the log is bound
194 /// to the auto-hide's transaction, not fire-and-forget).
195 #[tokio::test]
196 async fn auto_hide_logs_system_actor_not_flagger() {
197 let mut h = TestHarness::new().await;
198 let author_id = h.login_as("sysauthor").await;
199 let comm_id = h.create_community("Sys", "sys").await;
200 let cat_id = h.create_category(comm_id, "General", "general").await;
201 h.add_membership(author_id, comm_id, "member").await;
202
203 sqlx::query("UPDATE communities SET auto_hide_threshold = 1 WHERE id = $1")
204 .bind(comm_id)
205 .execute(&h.db)
206 .await
207 .unwrap();
208
209 let thread_id = h
210 .create_thread_with_post(cat_id, author_id, "Sys Hide", "Content")
211 .await;
212 let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
213 .await
214 .unwrap()[0]
215 .id;
216
217 // One flag from a member trips the threshold of 1.
218 let flagger = h.login_as("tripper").await;
219 h.add_membership(flagger, comm_id, "member").await;
220 let thread_url = format!("/p/sys/general/{thread_id}");
221 h.client.get(&thread_url).await;
222 let flag_url = format!("/p/sys/general/{thread_id}/posts/{post_id}/flag");
223 h.client.post_form(&flag_url, "reason=spam").await;
224
225 // The audit row exists (atomic with the removal) and is attributed to System.
226 let actor: Option<uuid::Uuid> = sqlx::query_scalar(
227 "SELECT actor_id FROM mod_log WHERE target_id = $1 AND action = 'auto_hide_post'",
228 )
229 .bind(post_id)
230 .fetch_one(&h.db)
231 .await
232 .expect("auto-hide must write exactly one mod_log row");
233 assert_eq!(
234 actor, None,
235 "auto-hide must be logged as System (NULL actor_id), not the flagger"
236 );
237 assert_ne!(
238 actor,
239 Some(flagger),
240 "the flagger must never be recorded as the auto-hide actor"
241 );
242
243 // Regression (fuzz-2026-07-06 top DB fix): the System row must be VISIBLE in
244 // the mod-log listing. list_mod_log INNER JOINed the actor, silently dropping
245 // every NULL-actor row, so the auto-moderation ledger migration 032 exists to
246 // keep was invisible on the page, while count_mod_log counted it (pagination
247 // overcount). The row must now appear, labeled "System", and the two agree.
248 let entries = mt_db::queries::list_mod_log(&h.db, comm_id, 50, 0)
249 .await
250 .unwrap();
251 let count = mt_db::queries::count_mod_log(&h.db, comm_id).await.unwrap();
252 assert_eq!(
253 count as usize,
254 entries.len(),
255 "count_mod_log must match the number of rows list_mod_log returns"
256 );
257 let system_row = entries
258 .iter()
259 .find(|e| e.action == mt_core::types::ModAction::AutoHidePost)
260 .expect("the auto-hide row must be present in the mod-log listing");
261 assert_eq!(
262 system_row.actor_username, "System",
263 "a NULL-actor auto-hide row must render as the System actor"
264 );
265 }
266
267 #[tokio::test]
268 async fn auto_hide_disabled_when_threshold_null() {
269 let mut h = TestHarness::new().await;
270 let author_id = h.login_as("nohideauthor").await;
271 let comm_id = h.create_community("Test", "test").await;
272 let cat_id = h.create_category(comm_id, "General", "general").await;
273 h.add_membership(author_id, comm_id, "member").await;
274
275 // auto_hide_threshold is NULL by default, no auto-hide
276
277 let thread_id = h
278 .create_thread_with_post(cat_id, author_id, "No Hide Test", "Content stays")
279 .await;
280
281 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
282 .await
283 .unwrap();
284 let post_id = posts[0].id;
285
286 // Flag 5 times from different users
287 for i in 0..5 {
288 let flagger = h.login_as(&format!("nohideflagger{i}")).await;
289 h.add_membership(flagger, comm_id, "member").await;
290 let thread_url = format!("/p/test/general/{thread_id}");
291 h.client.get(&thread_url).await;
292 let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag");
293 h.client.post_form(&flag_url, "reason=spam").await;
294 }
295
296 // Verify NOT removed (threshold is NULL = disabled)
297 let removed: bool =
298 sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
299 .bind(post_id)
300 .fetch_one(&h.db)
301 .await
302 .unwrap();
303 assert!(
304 !removed,
305 "Post should NOT be removed when auto_hide_threshold is NULL"
306 );
307 }
308
309 #[tokio::test]
310 async fn settings_saves_auto_hide_threshold() {
311 let mut h = TestHarness::new().await;
312 let owner_id = h.login_as("thresholdowner").await;
313 let comm_id = h.create_community("Test", "test").await;
314 h.add_membership(owner_id, comm_id, "owner").await;
315 let _cat_id = h.create_category(comm_id, "General", "general").await;
316
317 // GET settings for CSRF
318 h.client.get("/p/test/settings").await;
319
320 // Save with threshold=3
321 let resp = h
322 .client
323 .post_form(
324 "/p/test/settings",
325 "name=Test&description=desc&auto_hide_threshold=3",
326 )
327 .await;
328 assert!(
329 resp.status.is_redirection(),
330 "Expected redirect, got {}",
331 resp.status
332 );
333
334 // Verify in DB
335 let threshold: Option<i32> =
336 sqlx::query_scalar("SELECT auto_hide_threshold FROM communities WHERE id = $1")
337 .bind(comm_id)
338 .fetch_one(&h.db)
339 .await
340 .unwrap();
341 assert_eq!(threshold, Some(3));
342
343 // Save with threshold=0 (disabled)
344 h.client.get("/p/test/settings").await;
345 h.client
346 .post_form(
347 "/p/test/settings",
348 "name=Test&description=desc&auto_hide_threshold=0",
349 )
350 .await;
351
352 let threshold: Option<i32> =
353 sqlx::query_scalar("SELECT auto_hide_threshold FROM communities WHERE id = $1")
354 .bind(comm_id)
355 .fetch_one(&h.db)
356 .await
357 .unwrap();
358 assert_eq!(threshold, None, "Threshold 0 should be stored as NULL");
359 }
360
361 #[tokio::test]
362 async fn flag_post_happy_path() {
363 let mut h = TestHarness::new().await;
364 let author_id = h.login_as("flagauthor").await;
365 let comm_id = h.create_community("Test", "test").await;
366 let cat_id = h.create_category(comm_id, "General", "general").await;
367 h.add_membership(author_id, comm_id, "member").await;
368
369 let thread_id = h
370 .create_thread_with_post(cat_id, author_id, "Flag Test", "Content")
371 .await;
372
373 let flagger_id = h.login_as("flagger").await;
374 h.add_membership(flagger_id, comm_id, "member").await;
375
376 let thread_url = format!("/p/test/general/{thread_id}");
377 h.client.get(&thread_url).await;
378
379 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
380 .await
381 .unwrap();
382 let post_id = posts[0].id;
383
384 let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag");
385 let resp = h.client.post_form(&flag_url, "reason=spam").await;
386 assert!(
387 resp.status.is_redirection(),
388 "Expected redirect, got {}",
389 resp.status
390 );
391
392 // Verify flag in DB
393 let has_flag = mt_db::queries::has_user_flagged_post(&h.db, post_id, flagger_id)
394 .await
395 .unwrap();
396 assert!(has_flag, "Flag should exist in DB");
397 }
398
399 #[tokio::test]
400 async fn duplicate_flag_silently_ignored() {
401 let mut h = TestHarness::new().await;
402 let author_id = h.login_as("dupflagauthor").await;
403 let comm_id = h.create_community("Test", "test").await;
404 let cat_id = h.create_category(comm_id, "General", "general").await;
405 h.add_membership(author_id, comm_id, "member").await;
406
407 let thread_id = h
408 .create_thread_with_post(cat_id, author_id, "Dup Flag", "Content")
409 .await;
410
411 let flagger_id = h.login_as("dupflagger").await;
412 h.add_membership(flagger_id, comm_id, "member").await;
413
414 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
415 .await
416 .unwrap();
417 let post_id = posts[0].id;
418
419 let thread_url = format!("/p/test/general/{thread_id}");
420 h.client.get(&thread_url).await;
421
422 let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag");
423
424 // Flag once
425 h.client.post_form(&flag_url, "reason=spam").await;
426
427 // GET for CSRF refresh
428 h.client.get(&thread_url).await;
429
430 // Flag again, should not error
431 let resp = h.client.post_form(&flag_url, "reason=off_topic").await;
432 assert!(
433 resp.status.is_redirection(),
434 "Duplicate flag should redirect, got {}",
435 resp.status
436 );
437 }
438
439 #[tokio::test]
440 async fn flag_requires_login() {
441 let mut h = TestHarness::new().await;
442 let author_id = h.login_as("flagloginauthor").await;
443 let comm_id = h.create_community("Test", "test").await;
444 let cat_id = h.create_category(comm_id, "General", "general").await;
445 h.add_membership(author_id, comm_id, "member").await;
446
447 let thread_id = h
448 .create_thread_with_post(cat_id, author_id, "Login Flag", "Content")
449 .await;
450
451 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
452 .await
453 .unwrap();
454 let post_id = posts[0].id;
455
456 // New harness (no login)
457 let mut h2 = TestHarness::new().await;
458 h2.client.get("/").await;
459
460 let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag");
461 let resp = h2.client.post_form(&flag_url, "reason=spam").await;
462 assert!(
463 resp.status.is_redirection(),
464 "Expected redirect to login, got {}",
465 resp.status
466 );
467 }
468
469 #[tokio::test]
470 async fn flag_own_post_rejected() {
471 let mut h = TestHarness::new().await;
472 let author_id = h.login_as("selfflagauthor").await;
473 let comm_id = h.create_community("Test", "test").await;
474 let cat_id = h.create_category(comm_id, "General", "general").await;
475 h.add_membership(author_id, comm_id, "member").await;
476
477 let thread_id = h
478 .create_thread_with_post(cat_id, author_id, "Self Flag", "Content")
479 .await;
480
481 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
482 .await
483 .unwrap();
484 let post_id = posts[0].id;
485
486 let thread_url = format!("/p/test/general/{thread_id}");
487 h.client.get(&thread_url).await;
488
489 let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag");
490 let resp = h.client.post_form(&flag_url, "reason=spam").await;
491 assert_eq!(resp.status.as_u16(), 403, "Flagging own post should be 403");
492 }
493
494 // Flagging gates on write-level access, not read-level. A user who cannot post
495 // must not be able to flag either, since enough flags trip the auto-hide
496 // threshold on someone else's post. Both cases below passed under the old
497 // `check_community_access` gate, which saw neither platform suspension nor mute.
498
499 #[tokio::test]
500 async fn platform_suspended_user_cannot_flag() {
501 let mut h = TestHarness::new().await;
502 let author_id = h.login_as("suspflagauthor").await;
503 let comm_id = h.create_community("Test", "test").await;
504 let cat_id = h.create_category(comm_id, "General", "general").await;
505 h.add_membership(author_id, comm_id, "member").await;
506
507 let thread_id = h
508 .create_thread_with_post(cat_id, author_id, "Suspended Flag", "Content")
509 .await;
510
511 let flagger_id = h.login_as("suspflagger").await;
512 h.add_membership(flagger_id, comm_id, "member").await;
513
514 mt_db::mutations::suspend_user(&h.db, flagger_id, Some("test"))
515 .await
516 .unwrap();
517
518 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
519 .await
520 .unwrap();
521 let post_id = posts[0].id;
522
523 let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag");
524 let resp = h.client.post_form(&flag_url, "reason=spam").await;
525 assert_eq!(
526 resp.status.as_u16(),
527 403,
528 "A platform-suspended user must not be able to flag"
529 );
530
531 let has_flag = mt_db::queries::has_user_flagged_post(&h.db, post_id, flagger_id)
532 .await
533 .unwrap();
534 assert!(!has_flag, "No flag row should have been written");
535 }
536
537 #[tokio::test]
538 async fn muted_user_cannot_flag() {
539 let mut h = TestHarness::new().await;
540 let author_id = h.login_as("muteflagauthor").await;
541 let comm_id = h.create_community("Test", "test").await;
542 let cat_id = h.create_category(comm_id, "General", "general").await;
543 h.add_membership(author_id, comm_id, "member").await;
544
545 let thread_id = h
546 .create_thread_with_post(cat_id, author_id, "Muted Flag", "Content")
547 .await;
548
549 let flagger_id = h.login_as("muteflagger").await;
550 h.add_membership(flagger_id, comm_id, "member").await;
551
552 mt_db::mutations::create_community_ban(
553 &h.db,
554 comm_id,
555 flagger_id,
556 author_id,
557 BanType::Mute,
558 Some("test"),
559 None,
560 )
561 .await
562 .unwrap();
563
564 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
565 .await
566 .unwrap();
567 let post_id = posts[0].id;
568
569 let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag");
570 let resp = h.client.post_form(&flag_url, "reason=spam").await;
571 assert_eq!(
572 resp.status.as_u16(),
573 403,
574 "A muted user must not be able to flag"
575 );
576
577 let has_flag = mt_db::queries::has_user_flagged_post(&h.db, post_id, flagger_id)
578 .await
579 .unwrap();
580 assert!(!has_flag, "No flag row should have been written");
581 }
582
583 #[tokio::test]
584 async fn mod_dismiss_flag() {
585 let mut h = TestHarness::new().await;
586 let author_id = h.login_as("dismissauthor").await;
587 let comm_id = h.create_community("Test", "test").await;
588 let cat_id = h.create_category(comm_id, "General", "general").await;
589 h.add_membership(author_id, comm_id, "member").await;
590
591 let thread_id = h
592 .create_thread_with_post(cat_id, author_id, "Dismiss Test", "Content")
593 .await;
594
595 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
596 .await
597 .unwrap();
598 let post_id = posts[0].id;
599
600 // Insert a flag directly
601 let flagger_id = h.login_as("dismissflagger").await;
602 h.add_membership(flagger_id, comm_id, "member").await;
603 mt_db::mutations::insert_flag(&h.db, post_id, flagger_id, "spam", None)
604 .await
605 .unwrap();
606
607 let mod_id = h.login_as("dismissmod").await;
608 h.add_membership(mod_id, comm_id, "moderator").await;
609
610 // Get flag ID
611 let flags = mt_db::queries::list_pending_flags(&h.db, comm_id, 100)
612 .await
613 .unwrap();
614 assert_eq!(flags.len(), 1);
615 let flag_id = flags[0].flag_id;
616
617 // GET moderation page for CSRF
618 h.client.get("/p/test/moderation").await;
619
620 let dismiss_url = format!("/p/test/moderation/flags/{flag_id}/dismiss");
621 let resp = h.client.post_form(&dismiss_url, "").await;
622 assert!(
623 resp.status.is_redirection(),
624 "Expected redirect, got {}",
625 resp.status
626 );
627
628 // Verify flag resolved
629 let flags = mt_db::queries::list_pending_flags(&h.db, comm_id, 100)
630 .await
631 .unwrap();
632 assert_eq!(flags.len(), 0, "Flag should be resolved after dismiss");
633 }
634
635 #[tokio::test]
636 async fn mod_remove_via_flag() {
637 let mut h = TestHarness::new().await;
638 let author_id = h.login_as("removeauthor").await;
639 let comm_id = h.create_community("Test", "test").await;
640 let cat_id = h.create_category(comm_id, "General", "general").await;
641 h.add_membership(author_id, comm_id, "member").await;
642
643 let thread_id = h
644 .create_thread_with_post(cat_id, author_id, "Remove Test", "Content")
645 .await;
646
647 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
648 .await
649 .unwrap();
650 let post_id = posts[0].id;
651
652 // Insert flags from two different users
653 let flagger1 = h.login_as("removeflag1").await;
654 h.add_membership(flagger1, comm_id, "member").await;
655 mt_db::mutations::insert_flag(&h.db, post_id, flagger1, "spam", None)
656 .await
657 .unwrap();
658
659 let flagger2_id = uuid::Uuid::new_v4();
660 sqlx::query("INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $2)")
661 .bind(flagger2_id)
662 .bind("removeflag2")
663 .execute(&h.db)
664 .await
665 .unwrap();
666 mt_db::mutations::insert_flag(
667 &h.db,
668 post_id,
669 flagger2_id,
670 "rule_breaking",
671 Some("bad content"),
672 )
673 .await
674 .unwrap();
675
676 let mod_id = h.login_as("removemod").await;
677 h.add_membership(mod_id, comm_id, "moderator").await;
678
679 let flags = mt_db::queries::list_pending_flags(&h.db, comm_id, 100)
680 .await
681 .unwrap();
682 assert_eq!(flags.len(), 2, "Should have 2 pending flags");
683 let flag_id = flags[0].flag_id;
684
685 h.client.get("/p/test/moderation").await;
686
687 let remove_url = format!("/p/test/moderation/flags/{flag_id}/remove");
688 let resp = h.client.post_form(&remove_url, "").await;
689 assert!(
690 resp.status.is_redirection(),
691 "Expected redirect, got {}",
692 resp.status
693 );
694
695 // All flags should be resolved
696 let flags = mt_db::queries::list_pending_flags(&h.db, comm_id, 100)
697 .await
698 .unwrap();
699 assert_eq!(flags.len(), 0, "All flags should be resolved after remove");
700
701 // Post should be removed
702 let post: Option<(bool,)> =
703 sqlx::query_as("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
704 .bind(post_id)
705 .fetch_optional(&h.db)
706 .await
707 .unwrap();
708 assert!(post.unwrap().0, "Post should be mod-removed");
709 }
710
711 #[tokio::test]
712 async fn remove_flagged_op_cascades_to_thread_delete() {
713 let mut h = TestHarness::new().await;
714 let author_id = h.login_as("flagcascadeauthor").await;
715 let comm_id = h.create_community("Test", "test").await;
716 let cat_id = h.create_category(comm_id, "General", "general").await;
717 h.add_membership(author_id, comm_id, "member").await;
718
719 let thread_id = h
720 .create_thread_with_post(cat_id, author_id, "Flag Cascade", "Opening content")
721 .await;
722 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
723 .await
724 .unwrap();
725 let op_id = posts[0].id;
726
727 // Flag the opening post.
728 let flagger = h.login_as("flagcascadeflagger").await;
729 h.add_membership(flagger, comm_id, "member").await;
730 mt_db::mutations::insert_flag(&h.db, op_id, flagger, "spam", None)
731 .await
732 .unwrap();
733
734 // Mod removes the flagged OP via the flag-remove endpoint.
735 let mod_id = h.login_as("flagcascademod").await;
736 h.add_membership(mod_id, comm_id, "moderator").await;
737 let flags = mt_db::queries::list_pending_flags(&h.db, comm_id, 100)
738 .await
739 .unwrap();
740 let flag_id = flags[0].flag_id;
741 h.client.get("/p/test/moderation").await;
742
743 let remove_url = format!("/p/test/moderation/flags/{flag_id}/remove");
744 let resp = h.client.post_form(&remove_url, "").await;
745 assert!(
746 resp.status.is_redirection(),
747 "Expected redirect, got {}",
748 resp.status
749 );
750
751 // The thread is soft-deleted and now 404s.
752 let deleted: (bool,) =
753 sqlx::query_as("SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1")
754 .bind(thread_id)
755 .fetch_one(&h.db)
756 .await
757 .unwrap();
758 assert!(
759 deleted.0,
760 "Removing the flagged OP should soft-delete the thread"
761 );
762
763 let resp = h.client.get(&format!("/p/test/general/{thread_id}")).await;
764 assert_eq!(resp.status.as_u16(), 404, "Cascaded thread should 404");
765
766 // Regression (ultra-fuzz Run #5 M-DI1): the removal, the flag resolution, and
767 // BOTH audit rows commit on one transaction. Assert both log rows landed and
768 // are attributed to the acting moderator (User), not dropped fire-and-forget.
769 let log_rows: Vec<(String, Option<uuid::Uuid>)> = sqlx::query_as(
770 "SELECT action, actor_id FROM mod_log
771 WHERE community_id = $1 AND action IN ('remove_post_via_flag', 'delete_thread')
772 ORDER BY action",
773 )
774 .bind(comm_id)
775 .fetch_all(&h.db)
776 .await
777 .unwrap();
778 assert_eq!(
779 log_rows.len(),
780 2,
781 "OP removal via flag must log both remove_post_via_flag and delete_thread"
782 );
783 for (action, actor) in &log_rows {
784 assert_eq!(
785 *actor,
786 Some(mod_id),
787 "{action} must be attributed to the acting moderator"
788 );
789 }
790 }
791
792 // Restore, the reversal of both removal paths
793
794 /// The gap this closes: before restore existed, a mod who read a bad-faith
795 /// brigade and dismissed the flags left the post hidden forever, with an empty
796 /// queue implying it had been dealt with.
797 #[tokio::test]
798 async fn restore_unhides_auto_hidden_post() {
799 let mut h = TestHarness::new().await;
800 let author_id = h.login_as("restoreauthor").await;
801 let comm_id = h.create_community("Test", "test").await;
802 let cat_id = h.create_category(comm_id, "General", "general").await;
803 h.add_membership(author_id, comm_id, "member").await;
804
805 sqlx::query("UPDATE communities SET auto_hide_threshold = 2 WHERE id = $1")
806 .bind(comm_id)
807 .execute(&h.db)
808 .await
809 .unwrap();
810
811 let thread_id = h
812 .create_thread_with_post(cat_id, author_id, "Brigaded", "Perfectly fine post")
813 .await;
814 let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
815 .await
816 .unwrap()[0]
817 .id;
818
819 let thread_url = format!("/p/test/general/{thread_id}");
820 let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag");
821 for name in ["brigade1", "brigade2"] {
822 let flagger = h.login_as(name).await;
823 h.add_membership(flagger, comm_id, "member").await;
824 h.client.get(&thread_url).await;
825 h.client.post_form(&flag_url, "reason=spam").await;
826 }
827
828 let removed: bool =
829 sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
830 .bind(post_id)
831 .fetch_one(&h.db)
832 .await
833 .unwrap();
834 assert!(removed, "threshold reached, post should be auto-hidden");
835
836 let mod_id = h.login_as("restoremod").await;
837 h.add_membership(mod_id, comm_id, "moderator").await;
838 h.client.get(&thread_url).await;
839 let restore_url = format!("/p/test/general/{thread_id}/posts/{post_id}/restore");
840 let resp = h.client.post_form(&restore_url, "").await;
841 assert!(
842 resp.status.is_redirection(),
843 "Expected redirect, got {}",
844 resp.status
845 );
846
847 let still_removed: bool =
848 sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
849 .bind(post_id)
850 .fetch_one(&h.db)
851 .await
852 .unwrap();
853 assert!(!still_removed, "restore must clear removed_at");
854
855 let actor: Option<uuid::Uuid> = sqlx::query_scalar(
856 "SELECT actor_id FROM mod_log WHERE target_id = $1 AND action = 'restore_post'",
857 )
858 .bind(post_id)
859 .fetch_one(&h.db)
860 .await
861 .unwrap();
862 assert_eq!(
863 actor,
864 Some(mod_id),
865 "restore must log against the acting moderator, not System"
866 );
867 }
868
869 /// Restoring while the post is still over the threshold would re-hide it on the
870 /// very next flag, so restore resolves the outstanding flags. This is the test
871 /// that pins that decision: one fresh flag after a restore must not re-hide.
872 #[tokio::test]
873 async fn restore_resolves_flags_so_the_post_does_not_immediately_rehide() {
874 let mut h = TestHarness::new().await;
875 let author_id = h.login_as("rehideauthor").await;
876 let comm_id = h.create_community("Test", "test").await;
877 let cat_id = h.create_category(comm_id, "General", "general").await;
878 h.add_membership(author_id, comm_id, "member").await;
879
880 sqlx::query("UPDATE communities SET auto_hide_threshold = 2 WHERE id = $1")
881 .bind(comm_id)
882 .execute(&h.db)
883 .await
884 .unwrap();
885
886 let thread_id = h
887 .create_thread_with_post(cat_id, author_id, "Rehide", "Fine post")
888 .await;
889 let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
890 .await
891 .unwrap()[0]
892 .id;
893
894 let thread_url = format!("/p/test/general/{thread_id}");
895 let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag");
896 for name in ["rehide1", "rehide2"] {
897 let flagger = h.login_as(name).await;
898 h.add_membership(flagger, comm_id, "member").await;
899 h.client.get(&thread_url).await;
900 h.client.post_form(&flag_url, "reason=spam").await;
901 }
902
903 let mod_id = h.login_as("rehidemod").await;
904 h.add_membership(mod_id, comm_id, "moderator").await;
905 h.client.get(&thread_url).await;
906 h.client
907 .post_form(
908 &format!("/p/test/general/{thread_id}/posts/{post_id}/restore"),
909 "",
910 )
911 .await;
912
913 let pending: i64 = sqlx::query_scalar(
914 "SELECT COUNT(*) FROM post_flags WHERE post_id = $1 AND resolved_at IS NULL",
915 )
916 .bind(post_id)
917 .fetch_one(&h.db)
918 .await
919 .unwrap();
920 assert_eq!(pending, 0, "restore must resolve the outstanding flags");
921
922 let flagger = h.login_as("rehide3").await;
923 h.add_membership(flagger, comm_id, "member").await;
924 h.client.get(&thread_url).await;
925 h.client.post_form(&flag_url, "reason=spam").await;
926
927 let removed: bool =
928 sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
929 .bind(post_id)
930 .fetch_one(&h.db)
931 .await
932 .unwrap();
933 assert!(
934 !removed,
935 "one flag after a restore is below threshold; the post must stay live"
936 );
937 }
938
939 #[tokio::test]
940 async fn member_cannot_restore_post() {
941 let mut h = TestHarness::new().await;
942 let author_id = h.login_as("norestoreauthor").await;
943 let comm_id = h.create_community("Test", "test").await;
944 let cat_id = h.create_category(comm_id, "General", "general").await;
945 h.add_membership(author_id, comm_id, "member").await;
946
947 let thread_id = h
948 .create_thread_with_post(cat_id, author_id, "NoRestore", "Content")
949 .await;
950 let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
951 .await
952 .unwrap()[0]
953 .id;
954
955 sqlx::query("UPDATE posts SET removed_at = now() WHERE id = $1")
956 .bind(post_id)
957 .execute(&h.db)
958 .await
959 .unwrap();
960
961 let member_id = h.login_as("plainmember").await;
962 h.add_membership(member_id, comm_id, "member").await;
963 let thread_url = format!("/p/test/general/{thread_id}");
964 h.client.get(&thread_url).await;
965 let resp = h
966 .client
967 .post_form(
968 &format!("/p/test/general/{thread_id}/posts/{post_id}/restore"),
969 "",
970 )
971 .await;
972 assert_eq!(
973 resp.status,
974 axum::http::StatusCode::FORBIDDEN,
975 "a member must not be able to restore"
976 );
977
978 let removed: bool =
979 sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
980 .bind(post_id)
981 .fetch_one(&h.db)
982 .await
983 .unwrap();
984 assert!(removed, "post must stay removed");
985 }
986
987 /// Restoring a live post is a no-op, not a log entry. A mod-log row for an
988 /// action that changed nothing is worse than no row: it reads as a reversal
989 /// that happened.
990 #[tokio::test]
991 async fn restoring_a_live_post_writes_no_log_row() {
992 let mut h = TestHarness::new().await;
993 let author_id = h.login_as("liveauthor").await;
994 let comm_id = h.create_community("Test", "test").await;
995 let cat_id = h.create_category(comm_id, "General", "general").await;
996 h.add_membership(author_id, comm_id, "member").await;
997
998 let thread_id = h
999 .create_thread_with_post(cat_id, author_id, "Live", "Never removed")
1000 .await;
1001 let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
1002 .await
1003 .unwrap()[0]
1004 .id;
1005
1006 let mod_id = h.login_as("nooprestoremod").await;
1007 h.add_membership(mod_id, comm_id, "moderator").await;
1008 let thread_url = format!("/p/test/general/{thread_id}");
1009 h.client.get(&thread_url).await;
1010 h.client
1011 .post_form(
1012 &format!("/p/test/general/{thread_id}/posts/{post_id}/restore"),
1013 "",
1014 )
1015 .await;
1016
1017 let rows: i64 = sqlx::query_scalar(
1018 "SELECT COUNT(*) FROM mod_log WHERE target_id = $1 AND action = 'restore_post'",
1019 )
1020 .bind(post_id)
1021 .fetch_one(&h.db)
1022 .await
1023 .unwrap();
1024 assert_eq!(rows, 0, "no-op restore must not write a mod-log row");
1025 }
1026