Skip to main content

max / makenotwork

29.6 KB · 907 lines History Blame Raw
1 use crate::harness::TestHarness;
2
3 #[tokio::test]
4 async fn pin_toggle() {
5 let mut h = TestHarness::new().await;
6 let mod_id = h.login_as("pintoggler").await;
7 let comm_id = h.create_community("Test", "test").await;
8 let cat_id = h.create_category(comm_id, "General", "general").await;
9 h.add_membership(mod_id, comm_id, "moderator").await;
10
11 let thread_id = h
12 .create_thread_with_post(cat_id, mod_id, "Toggle Pin", "Body")
13 .await;
14
15 let thread_url = format!("/p/test/general/{thread_id}");
16
17 // Pin the thread
18 h.client.get(&thread_url).await;
19 let pin_url = format!("/p/test/general/{thread_id}/pin");
20 h.client.post_form(&pin_url, "").await;
21
22 // Verify pinned badge
23 let resp = h.client.get(&thread_url).await;
24 assert!(resp.text.contains("[pinned]"), "Expected [pinned] badge");
25
26 // Unpin
27 h.client.post_form(&pin_url, "").await;
28 let resp = h.client.get(&thread_url).await;
29 assert!(
30 !resp.text.contains("[pinned]"),
31 "Expected [pinned] badge to be gone after unpin"
32 );
33 }
34
35 #[tokio::test]
36 async fn lock_prevents_replies() {
37 let mut h = TestHarness::new().await;
38 let mod_id = h.login_as("locker").await;
39 let comm_id = h.create_community("Test", "test").await;
40 let cat_id = h.create_category(comm_id, "General", "general").await;
41 h.add_membership(mod_id, comm_id, "moderator").await;
42
43 let thread_id = h
44 .create_thread_with_post(cat_id, mod_id, "Lock Test", "Body")
45 .await;
46
47 let thread_url = format!("/p/test/general/{thread_id}");
48
49 // Lock
50 h.client.get(&thread_url).await;
51 let lock_url = format!("/p/test/general/{thread_id}/lock");
52 h.client.post_form(&lock_url, "").await;
53
54 // Try to reply
55 h.client.get(&thread_url).await;
56 let reply_url = format!("/p/test/general/{thread_id}/reply");
57 let resp = h.client.post_form(&reply_url, "body=Nope").await;
58
59 assert_eq!(resp.status.as_u16(), 403);
60 }
61
62 #[tokio::test]
63 async fn pinned_threads_first_in_listing() {
64 let mut h = TestHarness::new().await;
65 let mod_id = h.login_as("orderer").await;
66 let comm_id = h.create_community("Test", "test").await;
67 let cat_id = h.create_category(comm_id, "General", "general").await;
68 h.add_membership(mod_id, comm_id, "moderator").await;
69
70 // Create two threads
71 let _thread1 = h
72 .create_thread_with_post(cat_id, mod_id, "First Thread", "First body")
73 .await;
74
75 // Small delay so last_activity differs
76 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
77
78 let thread2 = h
79 .create_thread_with_post(cat_id, mod_id, "Second Thread", "Second body")
80 .await;
81
82 // Pin the second thread
83 mt_db::mutations::set_thread_pinned(&h.db, thread2, true)
84 .await
85 .unwrap();
86
87 let resp = h.client.get("/p/test/general").await;
88
89 // The pinned "Second Thread" should appear before "First Thread"
90 let pos_second = resp
91 .text
92 .find("Second Thread")
93 .expect("Second Thread not found");
94 let pos_first = resp
95 .text
96 .find("First Thread")
97 .expect("First Thread not found");
98 assert!(
99 pos_second < pos_first,
100 "Pinned thread should appear before unpinned thread"
101 );
102 }
103
104 #[tokio::test]
105 async fn update_community_settings() {
106 let mut h = TestHarness::new().await;
107 let owner_id = h.login_as("settingswriter").await;
108 let comm_id = h.create_community("Old Name", "test").await;
109 h.add_membership(owner_id, comm_id, "owner").await;
110 let _cat_id = h.create_category(comm_id, "General", "general").await;
111
112 // GET settings to get CSRF
113 h.client.get("/p/test/settings").await;
114
115 let resp = h
116 .client
117 .post_form("/p/test/settings", "name=New+Name&description=Updated+desc")
118 .await;
119
120 assert!(
121 resp.status.is_redirection(),
122 "Expected redirect, got {}",
123 resp.status
124 );
125
126 // Verify the name was saved
127 let community = mt_db::queries::get_community_by_slug(&h.db, "test")
128 .await
129 .unwrap()
130 .unwrap();
131 assert_eq!(community.name, "New Name");
132 }
133
134 #[tokio::test]
135 async fn create_category_via_settings() {
136 let mut h = TestHarness::new().await;
137 let owner_id = h.login_as("catcreator").await;
138 let comm_id = h.create_community("Test", "test").await;
139 h.add_membership(owner_id, comm_id, "owner").await;
140 let _cat_id = h.create_category(comm_id, "General", "general").await;
141
142 // GET settings for CSRF
143 h.client.get("/p/test/settings").await;
144
145 let resp = h
146 .client
147 .post_form(
148 "/p/test/settings/categories/new",
149 "name=New+Category&slug=new-cat&description=A+new+category",
150 )
151 .await;
152
153 assert!(
154 resp.status.is_redirection(),
155 "Expected redirect, got {}",
156 resp.status
157 );
158
159 // Verify category appears in settings
160 let resp = h.client.get("/p/test/settings").await;
161 assert!(
162 resp.text.contains("New Category"),
163 "New category should appear in settings page"
164 );
165 }
166
167 #[tokio::test]
168 async fn edit_category_via_settings() {
169 let mut h = TestHarness::new().await;
170 let owner_id = h.login_as("cateditor").await;
171 let comm_id = h.create_community("Test", "test").await;
172 h.add_membership(owner_id, comm_id, "owner").await;
173 let cat_id = h.create_category(comm_id, "Old Name", "general").await;
174
175 // GET edit form for CSRF
176 h.client
177 .get(&format!("/p/test/settings/categories/{cat_id}/edit"))
178 .await;
179
180 let resp = h
181 .client
182 .post_form(
183 &format!("/p/test/settings/categories/{cat_id}/edit"),
184 "name=New+Name&description=Updated",
185 )
186 .await;
187
188 assert!(
189 resp.status.is_redirection(),
190 "Expected redirect, got {}",
191 resp.status
192 );
193
194 // Verify the name was saved
195 let cat = mt_db::queries::get_category_in_community(&h.db, cat_id, comm_id)
196 .await
197 .unwrap()
198 .unwrap();
199 assert_eq!(cat.name, "New Name");
200 }
201
202 #[tokio::test]
203 async fn edit_category_form_via_wrong_community_404s() {
204 // C1: an owner of community B must not render community A's category edit form
205 // by borrowing B's slug with A's category id.
206 let mut h = TestHarness::new().await;
207 let owner_id = h.login_as("owner").await;
208 let comm_a = h.create_community("Alpha", "alpha").await;
209 h.add_membership(owner_id, comm_a, "owner").await;
210 let cat_a = h.create_category(comm_a, "Secret", "secret").await;
211
212 let comm_b = h.create_community("Beta", "beta").await;
213 h.add_membership(owner_id, comm_b, "owner").await;
214
215 // Owner of beta requests alpha's category through beta's slug → 404.
216 let resp = h
217 .client
218 .get(&format!("/p/beta/settings/categories/{cat_a}/edit"))
219 .await;
220 assert_eq!(
221 resp.status,
222 axum::http::StatusCode::NOT_FOUND,
223 "got {}",
224 resp.status
225 );
226
227 // Sanity: through the correct slug it still renders.
228 let resp = h
229 .client
230 .get(&format!("/p/alpha/settings/categories/{cat_a}/edit"))
231 .await;
232 assert_eq!(
233 resp.status,
234 axum::http::StatusCode::OK,
235 "got {}",
236 resp.status
237 );
238 }
239
240 #[tokio::test]
241 async fn reorder_categories_via_settings() {
242 let mut h = TestHarness::new().await;
243 let owner_id = h.login_as("catmover").await;
244 let comm_id = h.create_community("Test", "test").await;
245 h.add_membership(owner_id, comm_id, "owner").await;
246
247 // Create two categories with explicit sort order
248 let cat_a = h.create_category(comm_id, "Alpha", "alpha").await;
249 // Update sort_order so cat_a=0 (default from create_category)
250 sqlx::query("UPDATE categories SET sort_order = 1 WHERE id = $1")
251 .bind(cat_a)
252 .execute(&h.db)
253 .await
254 .unwrap();
255 let cat_b = h.create_category(comm_id, "Beta", "beta").await;
256 sqlx::query("UPDATE categories SET sort_order = 2 WHERE id = $1")
257 .bind(cat_b)
258 .execute(&h.db)
259 .await
260 .unwrap();
261
262 // Verify initial order: Alpha(1), Beta(2)
263 let cats = mt_db::queries::list_categories_for_settings(&h.db, comm_id)
264 .await
265 .unwrap();
266 assert_eq!(cats[0].name, "Alpha");
267 assert_eq!(cats[1].name, "Beta");
268
269 // Move Alpha down (swap with Beta)
270 h.client.get("/p/test/settings").await;
271 let resp = h
272 .client
273 .post_form(
274 &format!("/p/test/settings/categories/{cat_a}/move"),
275 "direction=down",
276 )
277 .await;
278 assert!(
279 resp.status.is_redirection(),
280 "Expected redirect, got {}",
281 resp.status
282 );
283
284 // Verify new order: Beta(1), Alpha(2)
285 let cats = mt_db::queries::list_categories_for_settings(&h.db, comm_id)
286 .await
287 .unwrap();
288 assert_eq!(cats[0].name, "Beta", "Beta should be first after move");
289 assert_eq!(cats[1].name, "Alpha", "Alpha should be second after move");
290 }
291
292 // Post removal via direct handler (not via flag)
293
294 #[tokio::test]
295 async fn mod_remove_post_directly() {
296 let mut h = TestHarness::new().await;
297 let author_id = h.login_as("postauthor").await;
298 let comm_id = h.create_community("Test", "test").await;
299 let cat_id = h.create_category(comm_id, "General", "general").await;
300 h.add_membership(author_id, comm_id, "member").await;
301
302 let thread_id = h
303 .create_thread_with_post(cat_id, author_id, "Remove Direct", "Content to remove")
304 .await;
305
306 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
307 .await
308 .unwrap();
309 let post_id = posts[0].id;
310
311 // Log in as moderator
312 let mod_id = h.login_as("directmod").await;
313 h.add_membership(mod_id, comm_id, "moderator").await;
314
315 let thread_url = format!("/p/test/general/{thread_id}");
316 h.client.get(&thread_url).await;
317
318 let remove_url = format!("/p/test/general/{thread_id}/posts/{post_id}/remove");
319 let resp = h.client.post_form(&remove_url, "").await;
320 assert!(
321 resp.status.is_redirection(),
322 "Expected redirect, got {}",
323 resp.status
324 );
325
326 // Verify post is removed in DB
327 let removed: bool =
328 sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
329 .bind(post_id)
330 .fetch_one(&h.db)
331 .await
332 .unwrap();
333 assert!(removed, "Post should be marked as removed");
334
335 // Verify removed_by is the mod
336 let removed_by: uuid::Uuid = sqlx::query_scalar("SELECT removed_by FROM posts WHERE id = $1")
337 .bind(post_id)
338 .fetch_one(&h.db)
339 .await
340 .unwrap();
341 assert_eq!(removed_by, mod_id, "removed_by should be the moderator");
342 }
343
344 #[tokio::test]
345 async fn member_cannot_remove_post() {
346 let mut h = TestHarness::new().await;
347 let author_id = h.login_as("noremoveauthor").await;
348 let comm_id = h.create_community("Test", "test").await;
349 let cat_id = h.create_category(comm_id, "General", "general").await;
350 h.add_membership(author_id, comm_id, "member").await;
351
352 let thread_id = h
353 .create_thread_with_post(cat_id, author_id, "No Remove", "Content")
354 .await;
355
356 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
357 .await
358 .unwrap();
359 let post_id = posts[0].id;
360
361 // Log in as a different member (not mod)
362 let member_id = h.login_as("regularmember").await;
363 h.add_membership(member_id, comm_id, "member").await;
364
365 let thread_url = format!("/p/test/general/{thread_id}");
366 h.client.get(&thread_url).await;
367
368 let remove_url = format!("/p/test/general/{thread_id}/posts/{post_id}/remove");
369 let resp = h.client.post_form(&remove_url, "").await;
370 assert_eq!(
371 resp.status.as_u16(),
372 403,
373 "Non-mod should get 403 when trying to remove a post"
374 );
375 }
376
377 #[tokio::test]
378 async fn removed_post_shows_removed_in_thread() {
379 let mut h = TestHarness::new().await;
380 let author_id = h.login_as("removedviewauthor").await;
381 let comm_id = h.create_community("Test", "test").await;
382 let cat_id = h.create_category(comm_id, "General", "general").await;
383 h.add_membership(author_id, comm_id, "member").await;
384
385 let thread_id = h
386 .create_thread_with_post(cat_id, author_id, "View Removed", "Visible content")
387 .await;
388
389 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
390 .await
391 .unwrap();
392 let post_id = posts[0].id;
393
394 // Mod removes the post
395 mt_db::mutations::mod_remove_post(&h.db, post_id, author_id)
396 .await
397 .unwrap();
398
399 // View the thread, should contain the "removed" CSS class
400 let thread_url = format!("/p/test/general/{thread_id}");
401 let resp = h.client.get(&thread_url).await;
402 assert!(
403 resp.text.contains("post-removed"),
404 "Removed post should have 'post-removed' class in thread view"
405 );
406 }
407
408 // Mod log page
409
410 #[tokio::test]
411 async fn mod_log_shows_actions() {
412 let mut h = TestHarness::new().await;
413 let mod_id = h.login_as("logmod").await;
414 let comm_id = h.create_community("Test", "test").await;
415 let cat_id = h.create_category(comm_id, "General", "general").await;
416 h.add_membership(mod_id, comm_id, "moderator").await;
417
418 // Perform a mod action (pin a thread) to generate a log entry
419 let thread_id = h
420 .create_thread_with_post(cat_id, mod_id, "Log Test Thread", "Body")
421 .await;
422
423 let thread_url = format!("/p/test/general/{thread_id}");
424 h.client.get(&thread_url).await;
425 let pin_url = format!("/p/test/general/{thread_id}/pin");
426 h.client.post_form(&pin_url, "").await;
427
428 // View mod log
429 let resp = h.client.get("/p/test/moderation/log").await;
430 assert_eq!(resp.status.as_u16(), 200);
431 assert!(
432 resp.text.contains("pin_thread"),
433 "Mod log should contain the pin_thread action"
434 );
435 assert!(
436 resp.text.contains("logmod"),
437 "Mod log should show the acting moderator's username"
438 );
439 }
440
441 #[tokio::test]
442 async fn mod_log_forbidden_for_members() {
443 let mut h = TestHarness::new().await;
444 let member_id = h.login_as("logmember").await;
445 let comm_id = h.create_community("Test", "test").await;
446 h.add_membership(member_id, comm_id, "member").await;
447
448 let resp = h.client.get("/p/test/moderation/log").await;
449 assert_eq!(
450 resp.status.as_u16(),
451 403,
452 "Non-mod should get 403 for mod log"
453 );
454 }
455
456 #[tokio::test]
457 async fn moderation_page_shows_bans_and_flags() {
458 let mut h = TestHarness::new().await;
459 let owner_id = h.login_as("modpageowner").await;
460 let comm_id = h.create_community("Test", "test").await;
461 let cat_id = h.create_category(comm_id, "General", "general").await;
462 h.add_membership(owner_id, comm_id, "owner").await;
463
464 // Create a member and ban them
465 let member_id = uuid::Uuid::new_v4();
466 sqlx::query(
467 "INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, 'banneduser', 'Banned User')",
468 )
469 .bind(member_id)
470 .execute(&h.db)
471 .await
472 .unwrap();
473 h.add_membership(member_id, comm_id, "member").await;
474 h.ban_user(comm_id, member_id, owner_id, "ban").await;
475
476 // Create a post and flag it
477 let thread_id = h
478 .create_thread_with_post(cat_id, owner_id, "Flagged Thread", "Some content")
479 .await;
480 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
481 .await
482 .unwrap();
483 let post_id = posts[0].id;
484
485 let flagger_id = uuid::Uuid::new_v4();
486 sqlx::query(
487 "INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, 'flaggeruser', 'Flagger')",
488 )
489 .bind(flagger_id)
490 .execute(&h.db)
491 .await
492 .unwrap();
493 mt_db::mutations::insert_flag(&h.db, post_id, flagger_id, "spam", None)
494 .await
495 .unwrap();
496
497 // View moderation page
498 let resp = h.client.get("/p/test/moderation").await;
499 assert_eq!(resp.status.as_u16(), 200);
500 assert!(
501 resp.text.contains("banneduser"),
502 "Moderation page should show banned user"
503 );
504 assert!(
505 resp.text.contains("spam"),
506 "Moderation page should show pending flag reason"
507 );
508 }
509
510 // OP-removal cascade, removing the opening post soft-deletes the whole thread
511
512 #[tokio::test]
513 async fn removing_op_cascades_to_thread_delete() {
514 let mut h = TestHarness::new().await;
515 let author_id = h.login_as("cascadeauthor").await;
516 let comm_id = h.create_community("Test", "test").await;
517 let cat_id = h.create_category(comm_id, "General", "general").await;
518 h.add_membership(author_id, comm_id, "member").await;
519
520 let thread_id = h
521 .create_thread_with_post(cat_id, author_id, "Cascade Thread", "Opening content")
522 .await;
523
524 // Add a reply so the thread is more than just its OP.
525 mt_db::mutations::create_post(&h.db, thread_id, author_id, "A reply", "<p>A reply</p>")
526 .await
527 .unwrap();
528
529 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
530 .await
531 .unwrap();
532 let op_id = posts[0].id;
533
534 // Mod removes the opening post via the HTTP endpoint.
535 let mod_id = h.login_as("cascademod").await;
536 h.add_membership(mod_id, comm_id, "moderator").await;
537 let thread_url = format!("/p/test/general/{thread_id}");
538 h.client.get(&thread_url).await; // prime CSRF token
539
540 let remove_url = format!("/p/test/general/{thread_id}/posts/{op_id}/remove");
541 let resp = h.client.post_form(&remove_url, "").await;
542 assert!(
543 resp.status.is_redirection(),
544 "Expected redirect, got {}",
545 resp.status
546 );
547
548 // The whole thread is now soft-deleted.
549 let deleted: (bool,) =
550 sqlx::query_as("SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1")
551 .bind(thread_id)
552 .fetch_one(&h.db)
553 .await
554 .unwrap();
555 assert!(deleted.0, "Removing the OP should soft-delete the thread");
556
557 // The thread page now 404s instead of leaving a headless thread.
558 let resp = h.client.get(&thread_url).await;
559 assert_eq!(resp.status.as_u16(), 404, "Cascaded thread should 404");
560
561 // Replying to the cascaded thread is rejected (no live headless thread).
562 let reply_resp = h
563 .client
564 .post_form(&format!("{thread_url}/reply"), "body=ghost+reply")
565 .await;
566 assert_eq!(
567 reply_resp.status.as_u16(),
568 404,
569 "Replying to a cascaded thread should 404"
570 );
571 }
572
573 #[tokio::test]
574 async fn removing_reply_does_not_cascade() {
575 let mut h = TestHarness::new().await;
576 let author_id = h.login_as("noncascadeauthor").await;
577 let comm_id = h.create_community("Test", "test").await;
578 let cat_id = h.create_category(comm_id, "General", "general").await;
579 h.add_membership(author_id, comm_id, "member").await;
580
581 let thread_id = h
582 .create_thread_with_post(cat_id, author_id, "Survivor Thread", "Opening content")
583 .await;
584 mt_db::mutations::create_post(&h.db, thread_id, author_id, "A reply", "<p>A reply</p>")
585 .await
586 .unwrap();
587
588 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
589 .await
590 .unwrap();
591 let reply_id = posts[1].id;
592
593 // Mod removes the reply (not the OP).
594 let mod_id = h.login_as("noncascademod").await;
595 h.add_membership(mod_id, comm_id, "moderator").await;
596 let thread_url = format!("/p/test/general/{thread_id}");
597 h.client.get(&thread_url).await;
598
599 let remove_url = format!("/p/test/general/{thread_id}/posts/{reply_id}/remove");
600 let resp = h.client.post_form(&remove_url, "").await;
601 assert!(
602 resp.status.is_redirection(),
603 "Expected redirect, got {}",
604 resp.status
605 );
606
607 // The thread survives: not deleted, still viewable.
608 let deleted: (bool,) =
609 sqlx::query_as("SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1")
610 .bind(thread_id)
611 .fetch_one(&h.db)
612 .await
613 .unwrap();
614 assert!(!deleted.0, "Removing a reply must not delete the thread");
615
616 let resp = h.client.get(&thread_url).await;
617 assert_eq!(resp.status.as_u16(), 200, "Thread should still be viewable");
618 }
619
620 /// Regression (ultra-fuzz CHRONIC C1): a thread can only be acted on through its
621 /// own community's slug. CommunityScope::resolve 404s when the slug names a
622 /// different community than the resource lives in, even for a user who is a
623 /// moderator/owner in the slug's community, so role can't paper over the
624 /// mismatch. This is the structural seal the per-site guard kept failing to hold.
625 #[tokio::test]
626 async fn delete_thread_via_wrong_community_slug_404s() {
627 let mut h = TestHarness::new().await;
628 let actor = h.login_as("c1actor").await;
629
630 let alpha = h.create_community("Alpha", "alpha").await;
631 let alpha_cat = h.create_category(alpha, "General", "general").await;
632 h.add_membership(actor, alpha, "moderator").await;
633
634 // The actor is an OWNER in Beta, so a role check against Beta would pass,
635 // only the resource-in-community check can stop this.
636 let beta = h.create_community("Beta", "beta").await;
637 let _beta_cat = h.create_category(beta, "General", "general").await;
638 h.add_membership(actor, beta, "owner").await;
639
640 let thread = h
641 .create_thread_with_post(alpha_cat, actor, "Lives in Alpha", "Body")
642 .await;
643
644 // Attempt to delete Alpha's thread through Beta's slug.
645 h.client.get("/p/beta/general").await;
646 let url = format!("/p/beta/general/{thread}/delete");
647 let resp = h.client.post_form(&url, "").await;
648 assert_eq!(
649 resp.status.as_u16(),
650 404,
651 "a thread acted on through the wrong community's slug must 404"
652 );
653
654 // Alpha's thread is untouched.
655 let deleted: bool =
656 sqlx::query_scalar("SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1")
657 .bind(thread)
658 .fetch_one(&h.db)
659 .await
660 .unwrap();
661 assert!(
662 !deleted,
663 "thread must not be deleted via a mismatched community slug"
664 );
665 }
666
667 // Deleted-threads surface
668
669 /// The gap this closes: removing an OP cascades the thread to `deleted_at`, and
670 /// every thread loader filters on that, so the thread page 404s and the
671 /// post-level restore control (which lives on that page) is unreachable. Without
672 /// this surface the delete is permanent even though every row survives.
673 #[tokio::test]
674 async fn deleted_thread_can_be_restored_after_op_removal_cascade() {
675 let mut h = TestHarness::new().await;
676 let author_id = h.login_as("cascadeauthor").await;
677 let comm_id = h.create_community("Test", "test").await;
678 let cat_id = h.create_category(comm_id, "General", "general").await;
679 h.add_membership(author_id, comm_id, "member").await;
680
681 let thread_id = h
682 .create_thread_with_post(cat_id, author_id, "Cascade Restore", "Opening content")
683 .await;
684 let op_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
685 .await
686 .unwrap()[0]
687 .id;
688
689 let mod_id = h.login_as("cascaderestoremod").await;
690 h.add_membership(mod_id, comm_id, "moderator").await;
691 h.client.get(&format!("/p/test/general/{thread_id}")).await;
692 h.client
693 .post_form(
694 &format!("/p/test/general/{thread_id}/posts/{op_id}/remove"),
695 "",
696 )
697 .await;
698
699 // The thread page is gone, so the post-level restore control cannot be reached.
700 let resp = h.client.get(&format!("/p/test/general/{thread_id}")).await;
701 assert_eq!(
702 resp.status,
703 axum::http::StatusCode::NOT_FOUND,
704 "cascade-deleted thread must 404"
705 );
706
707 // The deleted-threads page lists it.
708 let resp = h.client.get("/p/test/moderation/deleted").await;
709 assert!(resp.status.is_success());
710 assert!(
711 resp.text.contains("Cascade Restore"),
712 "deleted thread must be listed for restore"
713 );
714
715 let resp = h
716 .client
717 .post_form(
718 &format!("/p/test/moderation/threads/{thread_id}/restore"),
719 "",
720 )
721 .await;
722 assert!(
723 resp.status.is_redirection(),
724 "Expected redirect, got {}",
725 resp.status
726 );
727
728 let (thread_deleted, op_removed): (bool, bool) = sqlx::query_as(
729 "SELECT (SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1),
730 (SELECT removed_at IS NOT NULL FROM posts WHERE id = $2)",
731 )
732 .bind(thread_id)
733 .bind(op_id)
734 .fetch_one(&h.db)
735 .await
736 .unwrap();
737 assert!(!thread_deleted, "thread must be restored");
738 assert!(
739 !op_removed,
740 "the opening post must come back with the thread, not leave it headless"
741 );
742
743 let resp = h.client.get(&format!("/p/test/general/{thread_id}")).await;
744 assert!(resp.status.is_success(), "restored thread must load again");
745 }
746
747 /// The other delete path: a mod deletes the thread directly and the opening post
748 /// is never removed. Restore must not "restore" a post that was fine all along.
749 #[tokio::test]
750 async fn directly_deleted_thread_restores_without_touching_the_op() {
751 let mut h = TestHarness::new().await;
752 let author_id = h.login_as("directdelauthor").await;
753 let comm_id = h.create_community("Test", "test").await;
754 let cat_id = h.create_category(comm_id, "General", "general").await;
755 h.add_membership(author_id, comm_id, "member").await;
756
757 let thread_id = h
758 .create_thread_with_post(cat_id, author_id, "Direct Delete", "Opening content")
759 .await;
760 let op_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
761 .await
762 .unwrap()[0]
763 .id;
764
765 let mod_id = h.login_as("directdelmod").await;
766 h.add_membership(mod_id, comm_id, "moderator").await;
767 h.client.get(&format!("/p/test/general/{thread_id}")).await;
768 h.client
769 .post_form(&format!("/p/test/general/{thread_id}/delete"), "")
770 .await;
771
772 let deleted: bool =
773 sqlx::query_scalar("SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1")
774 .bind(thread_id)
775 .fetch_one(&h.db)
776 .await
777 .unwrap();
778 assert!(deleted, "thread should be soft-deleted");
779
780 h.client
781 .post_form(
782 &format!("/p/test/moderation/threads/{thread_id}/restore"),
783 "",
784 )
785 .await;
786
787 let (thread_deleted, op_removed): (bool, bool) = sqlx::query_as(
788 "SELECT (SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1),
789 (SELECT removed_at IS NOT NULL FROM posts WHERE id = $2)",
790 )
791 .bind(thread_id)
792 .bind(op_id)
793 .fetch_one(&h.db)
794 .await
795 .unwrap();
796 assert!(!thread_deleted, "thread must be restored");
797 assert!(!op_removed, "the OP was never removed and must stay live");
798
799 let rows: i64 = sqlx::query_scalar(
800 "SELECT COUNT(*) FROM mod_log WHERE target_id = $1 AND action = 'restore_post'",
801 )
802 .bind(thread_id)
803 .fetch_one(&h.db)
804 .await
805 .unwrap();
806 assert_eq!(
807 rows, 0,
808 "no post was restored, so no restore_post row should be logged"
809 );
810 }
811
812 #[tokio::test]
813 async fn member_cannot_see_or_restore_deleted_threads() {
814 let mut h = TestHarness::new().await;
815 let author_id = h.login_as("nodelauthor").await;
816 let comm_id = h.create_community("Test", "test").await;
817 let cat_id = h.create_category(comm_id, "General", "general").await;
818 h.add_membership(author_id, comm_id, "member").await;
819
820 let thread_id = h
821 .create_thread_with_post(cat_id, author_id, "Hidden", "Opening content")
822 .await;
823 sqlx::query("UPDATE threads SET deleted_at = now() WHERE id = $1")
824 .bind(thread_id)
825 .execute(&h.db)
826 .await
827 .unwrap();
828
829 let member_id = h.login_as("nodelmember").await;
830 h.add_membership(member_id, comm_id, "member").await;
831
832 let resp = h.client.get("/p/test/moderation/deleted").await;
833 assert_eq!(
834 resp.status,
835 axum::http::StatusCode::FORBIDDEN,
836 "a member must not see the deleted-threads list"
837 );
838
839 let resp = h
840 .client
841 .post_form(
842 &format!("/p/test/moderation/threads/{thread_id}/restore"),
843 "",
844 )
845 .await;
846 assert_eq!(
847 resp.status,
848 axum::http::StatusCode::FORBIDDEN,
849 "a member must not restore a thread"
850 );
851
852 let deleted: bool =
853 sqlx::query_scalar("SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1")
854 .bind(thread_id)
855 .fetch_one(&h.db)
856 .await
857 .unwrap();
858 assert!(deleted, "thread must stay deleted");
859 }
860
861 /// The community-id check in `get_deleted_thread_in_community` is what stops a
862 /// mod of one community restoring another's thread; `CommunityScope` cannot do
863 /// it here because every scoped thread loader filters deleted rows out.
864 #[tokio::test]
865 async fn mod_cannot_restore_a_thread_in_another_community() {
866 let mut h = TestHarness::new().await;
867 let author_id = h.login_as("xcommauthor").await;
868 let comm_a = h.create_community("Alpha", "alpha").await;
869 let cat_a = h.create_category(comm_a, "General", "general").await;
870 h.add_membership(author_id, comm_a, "member").await;
871
872 let thread_id = h
873 .create_thread_with_post(cat_a, author_id, "Alpha Thread", "Opening content")
874 .await;
875 sqlx::query("UPDATE threads SET deleted_at = now() WHERE id = $1")
876 .bind(thread_id)
877 .execute(&h.db)
878 .await
879 .unwrap();
880
881 let comm_b = h.create_community("Beta", "beta").await;
882 h.create_category(comm_b, "General", "general").await;
883 let mod_b = h.login_as("betamod").await;
884 h.add_membership(mod_b, comm_b, "moderator").await;
885
886 let resp = h
887 .client
888 .post_form(
889 &format!("/p/beta/moderation/threads/{thread_id}/restore"),
890 "",
891 )
892 .await;
893 assert_eq!(
894 resp.status,
895 axum::http::StatusCode::NOT_FOUND,
896 "beta's mod must not reach alpha's thread"
897 );
898
899 let deleted: bool =
900 sqlx::query_scalar("SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1")
901 .bind(thread_id)
902 .fetch_one(&h.db)
903 .await
904 .unwrap();
905 assert!(deleted, "alpha's thread must stay deleted");
906 }
907