Skip to main content

max / makenotwork

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