| 663 |
663 |
|
"thread must not be deleted via a mismatched community slug"
|
| 664 |
664 |
|
);
|
| 665 |
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 |
+ |
}
|