mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 13:08:40 +08:00
When a moderator picks "Delete User" or "Delete and Block User" on one of the spammer's flagged posts, deleting the user is meant to settle all of their reviewables, since there is no user left to act on. Previously only part of that happened: - `UserDestroyer#agree_with_flags` resolved the spammer's other flagged posts by checking for the `agree_and_keep` action, but a flag on a hidden post only offers `agree_and_keep_hidden`, so those flags were skipped and stayed pending forever. - Queued posts by the spammer were never touched, so they also stayed pending. - `UserDestroyer#destroy` skips resolving the spammer's account reviewable when a `reviewable_id` option is present. That option is meant to prevent the account reviewable from resolving itself twice when the deletion starts from it, but the guard only checked presence, so a deletion starting from a flagged post also skipped the account reviewable. - The browser updates review queue rows from message bus broadcasts. Those can arrive after the page has stopped waiting, so the acting moderator kept seeing the affected rows as "Pending" until a full reload. Acting on one of those stale rows returns a 403 because the target user no longer exists. This PR resolves every reviewable tied to the deleted user and updates the acting moderator's review queue without a reload.
406 lines
14 KiB
Ruby
Vendored
406 lines
14 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
describe "Reviewables" do
|
|
let(:review_page) { PageObjects::Pages::Review.new }
|
|
fab!(:admin)
|
|
fab!(:theme)
|
|
fab!(:long_post, :post_with_very_long_raw_content)
|
|
fab!(:post)
|
|
let(:composer) { PageObjects::Components::Composer.new }
|
|
let(:moderator) { Fabricate(:moderator) }
|
|
let(:toasts) { PageObjects::Components::Toasts.new }
|
|
|
|
before { sign_in(admin) }
|
|
|
|
describe "when there is a flagged post reviewable with a short post" do
|
|
fab!(:short_reviewable) { Fabricate(:reviewable_flagged_post, target: post) }
|
|
|
|
it "should not show a button to expand/collapse the post content" do
|
|
visit("/review")
|
|
expect(review_page).to have_no_post_body_collapsed
|
|
expect(review_page).to have_no_post_body_toggle
|
|
end
|
|
|
|
describe "reviewable actions" do
|
|
it "should have agree_and_edit action" do
|
|
visit("/review")
|
|
select_kit =
|
|
PageObjects::Components::SelectKit.new(".dropdown-select-box.post-agree-and-hide")
|
|
select_kit.expand
|
|
|
|
expect(select_kit).to have_option_value("post-agree_and_edit")
|
|
end
|
|
|
|
it "agree_and_edit should open the composer" do
|
|
visit("/review")
|
|
select_kit =
|
|
PageObjects::Components::SelectKit.new(".dropdown-select-box.post-agree-and-hide")
|
|
select_kit.expand
|
|
|
|
find("[data-value='post-agree_and_edit']").click
|
|
|
|
expect(composer).to be_opened
|
|
expect(composer.composer_input.value).to eq(post.raw)
|
|
expect(toasts).to have_success(I18n.t("reviewables.actions.agree_and_edit.complete"))
|
|
end
|
|
|
|
it "should open a modal when suspending a user" do
|
|
visit("/review")
|
|
|
|
select_kit =
|
|
PageObjects::Components::SelectKit.new(".dropdown-select-box.post-agree-and-hide")
|
|
select_kit.expand
|
|
|
|
select_kit.select_row_by_value("post-agree_and_suspend")
|
|
|
|
expect(review_page).to have_css(
|
|
"#discourse-modal-title",
|
|
text: I18n.t("js.flagging.take_action_options.suspend.title"),
|
|
)
|
|
end
|
|
|
|
it "should show a toast when disagreeing with a flag flag" do
|
|
visit("/review")
|
|
|
|
select_kit = PageObjects::Components::SelectKit.new(".dropdown-select-box.post-disagree")
|
|
select_kit.expand
|
|
select_kit.select_row_by_value("post-disagree")
|
|
|
|
expect(toasts).to have_success(I18n.t("reviewables.actions.disagree.complete"))
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "when there is a queued post reviewable with a short post" do
|
|
fab!(:short_queued_reviewable, :reviewable_queued_post)
|
|
|
|
it "should not show a button to expand/collapse the post content" do
|
|
visit("/review")
|
|
expect(review_page).to have_no_post_body_collapsed
|
|
expect(review_page).to have_no_post_body_toggle
|
|
end
|
|
end
|
|
|
|
describe "when there is a reviewable user" do
|
|
fab!(:user)
|
|
let(:rejection_reason_modal) { PageObjects::Modals::RejectReasonReviewable.new }
|
|
let(:scrub_user_modal) { PageObjects::Modals::ScrubRejectedUser.new }
|
|
|
|
before do
|
|
SiteSetting.must_approve_users = true
|
|
Jobs.run_immediately!
|
|
user.update!(approved: false)
|
|
user.activate
|
|
end
|
|
|
|
it "Rejecting user sends rejection email and updates reviewable with rejection reason" do
|
|
rejection_reason = "user is spamming"
|
|
reviewable = ReviewableUser.find_by_target_id(user.id)
|
|
# cache it for later assertion instead of querying UserHistory
|
|
user_email = user.email
|
|
|
|
review_page.visit_reviewable(reviewable)
|
|
review_page.select_bundled_action(reviewable, "user-delete_user")
|
|
rejection_reason_modal.fill_in_rejection_reason(rejection_reason)
|
|
rejection_reason_modal.select_send_rejection_email_checkbox
|
|
rejection_reason_modal.delete_user
|
|
|
|
expect(review_page).to have_reviewable_with_rejected_status(reviewable)
|
|
|
|
mail = ActionMailer::Base.deliveries.first
|
|
expect(mail.to).to eq([user_email])
|
|
expect(mail.subject).to match(/You've been rejected on Discourse/)
|
|
expect(mail.body.raw_source).to include rejection_reason
|
|
end
|
|
|
|
it "Allows scrubbing user data after rejection" do
|
|
rejection_reason = "user is spamming"
|
|
scrubbing_reason = "a spammer who knows how to make GDPR requests"
|
|
reviewable = ReviewableUser.find_by_target_id(user.id)
|
|
|
|
review_page.visit_reviewable(reviewable)
|
|
review_page.select_bundled_action(reviewable, "user-delete_user")
|
|
rejection_reason_modal.fill_in_rejection_reason(rejection_reason)
|
|
rejection_reason_modal.delete_user
|
|
|
|
expect(review_page).to have_reviewable_with_rejected_status(reviewable)
|
|
|
|
review_page.click_scrub_user_button
|
|
|
|
expect(scrub_user_modal.scrub_button).to be_disabled
|
|
scrub_user_modal.fill_in_scrub_reason(scrubbing_reason)
|
|
expect(scrub_user_modal.scrub_button).not_to be_disabled
|
|
scrub_user_modal.scrub_button.click
|
|
|
|
expect(review_page).to have_reviewable_with_scrubbed_by(reviewable, admin.username)
|
|
expect(review_page).to have_reviewable_with_scrubbed_reason(reviewable, scrubbing_reason)
|
|
expect(review_page).to have_reviewable_with_scrubbed_at(
|
|
reviewable,
|
|
reviewable.payload["scrubbed_at"],
|
|
)
|
|
end
|
|
end
|
|
|
|
context "when performing a review action from the show route" do
|
|
fab!(:contact_group, :group)
|
|
fab!(:contact_user, :user)
|
|
|
|
before do
|
|
SiteSetting.site_contact_group_name = contact_group.name
|
|
SiteSetting.site_contact_username = contact_user.username
|
|
end
|
|
|
|
context "with a ReviewableQueuedPost" do
|
|
fab!(:queued_post_reviewable, :reviewable_queued_post)
|
|
|
|
it "delete_user does not delete reviewable" do
|
|
review_page.visit_reviewable(queued_post_reviewable)
|
|
|
|
expect(queued_post_reviewable).to be_pending
|
|
expect(queued_post_reviewable.target_created_by).to be_present
|
|
expect(review_page).to have_reviewable_with_pending_status(queued_post_reviewable)
|
|
|
|
review_page.select_bundled_action(queued_post_reviewable, "delete_user")
|
|
|
|
expect(review_page).to have_no_error_dialog_visible
|
|
expect(review_page).to have_reviewable_with_rejected_status(queued_post_reviewable)
|
|
expect(review_page).to have_no_reviewable_action_dropdown
|
|
expect(queued_post_reviewable.reload).to be_rejected
|
|
expect(queued_post_reviewable.target_created_by).to be_nil
|
|
end
|
|
|
|
it "allows revising and rejecting to send a PM to the user" do
|
|
revise_modal = PageObjects::Modals::Base.new
|
|
|
|
review_page.visit_reviewable(queued_post_reviewable)
|
|
|
|
expect(queued_post_reviewable).to be_pending
|
|
expect(queued_post_reviewable.target_created_by).to be_present
|
|
|
|
review_page.select_bundled_action(queued_post_reviewable, "revise_and_reject_post")
|
|
|
|
expect(revise_modal).to be_open
|
|
|
|
reason_dropdown =
|
|
PageObjects::Components::SelectKit.new(".revise-and-reject-reviewable__reason")
|
|
reason_dropdown.select_row_by_value(SiteSetting.reviewable_revision_reasons_map.first)
|
|
find(".revise-and-reject-reviewable__feedback").fill_in(with: "This is a test")
|
|
revise_modal.click_primary_button
|
|
|
|
expect(review_page).to have_reviewable_with_rejected_status(queued_post_reviewable)
|
|
expect(queued_post_reviewable.reload).to be_rejected
|
|
|
|
topic = Topic.where(archetype: Archetype.private_message).last
|
|
expect(topic.topic_allowed_users.pluck(:user_id)).to include(contact_user.id)
|
|
expect(topic.topic_allowed_groups.pluck(:group_id)).to include(contact_group.id)
|
|
expect(topic.title).to eq(
|
|
I18n.t(
|
|
"system_messages.reviewable_queued_post_revise_and_reject.subject_template",
|
|
topic_title: queued_post_reviewable.topic.title,
|
|
),
|
|
)
|
|
end
|
|
|
|
it "allows selecting a custom reason for revise and reject" do
|
|
revise_modal = PageObjects::Modals::Base.new
|
|
|
|
review_page.visit_reviewable(queued_post_reviewable)
|
|
|
|
expect(queued_post_reviewable).to be_pending
|
|
expect(queued_post_reviewable.target_created_by).to be_present
|
|
|
|
review_page.select_bundled_action(queued_post_reviewable, "revise_and_reject_post")
|
|
expect(revise_modal).to be_open
|
|
|
|
reason_dropdown =
|
|
PageObjects::Components::SelectKit.new(".revise-and-reject-reviewable__reason")
|
|
reason_dropdown.select_row_by_value("other_reason")
|
|
find(".revise-and-reject-reviewable__custom-reason").fill_in(with: "I felt like it")
|
|
find(".revise-and-reject-reviewable__feedback").fill_in(with: "This is a test")
|
|
revise_modal.click_primary_button
|
|
|
|
expect(review_page).to have_reviewable_with_rejected_status(queued_post_reviewable)
|
|
end
|
|
|
|
context "with reviewable claiming enabled" do
|
|
before { SiteSetting.reviewable_claiming = "required" }
|
|
|
|
it "properly claims and unclaims the reviewable" do
|
|
review_page.visit_reviewable(queued_post_reviewable)
|
|
|
|
expect(review_page).to have_no_reviewable_action_dropdown
|
|
|
|
review_page.click_claim_reviewable
|
|
|
|
expect(review_page).to have_reviewable_action_dropdown
|
|
|
|
review_page.click_unclaim_reviewable
|
|
|
|
expect(review_page).to have_no_reviewable_action_dropdown
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "when there is an unknown plugin reviewable" do
|
|
fab!(:reviewable) { Fabricate(:reviewable_flagged_post, target: long_post) }
|
|
fab!(:reviewable2, :reviewable)
|
|
|
|
before do
|
|
reviewable.update_columns(type: "UnknownPlugin", type_source: "some-plugin")
|
|
reviewable2.update_columns(type: "UnknownSource", type_source: "unknown")
|
|
end
|
|
|
|
it "informs admin and allows to delete them" do
|
|
visit("/review")
|
|
expect(review_page).to have_information_about_unknown_reviewables_visible
|
|
expect(review_page).to have_listing_for_unknown_reviewables_plugin(
|
|
reviewable.type,
|
|
reviewable.type_source,
|
|
)
|
|
expect(review_page).to have_listing_for_unknown_reviewables_unknown_source(reviewable2.type)
|
|
review_page.click_ignore_all_unknown_reviewables
|
|
expect(review_page).to have_no_information_about_unknown_reviewables_visible
|
|
end
|
|
|
|
it "does not inform moderator about them" do
|
|
sign_in(moderator)
|
|
|
|
visit("/review")
|
|
expect(review_page).to have_no_information_about_unknown_reviewables_visible
|
|
end
|
|
end
|
|
|
|
describe "custom community moderator guide topic" do
|
|
fab!(:group)
|
|
fab!(:topic) { Fabricate(:topic, title: "Moderator guide") }
|
|
fab!(:post) { Fabricate(:post, topic: topic) }
|
|
fab!(:reviewable, :reviewable_queued_post)
|
|
|
|
before { group.add(admin) }
|
|
|
|
it "displays the custom guide topic link when configured" do
|
|
SiteSetting.moderator_guide_topic = topic.id
|
|
|
|
review_page.visit_reviewable(reviewable)
|
|
expect(review_page).to have_css(
|
|
"a.review-resources__link",
|
|
text: I18n.t("js.review.help.community_moderation_guide"),
|
|
)
|
|
end
|
|
|
|
it "does not display anything when no custom guide topic configured" do
|
|
SiteSetting.moderator_guide_topic = ""
|
|
|
|
review_page.visit_reviewable(reviewable)
|
|
expect(review_page).to have_no_css(
|
|
"a.review-resources__link",
|
|
text: I18n.t("js.review.help.community_moderation_guide"),
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "XSS prevention in queued post titles via server-side cooking" do
|
|
fab!(:untrusted_user) { Fabricate(:user, trust_level: 0) }
|
|
|
|
before do
|
|
SiteSetting.approve_post_count = 1
|
|
sign_in(admin)
|
|
end
|
|
|
|
it "prevents stored XSS in topic title when viewing review queue" do
|
|
xss_payload = '<img src=x onerror="alert(\'XSS\')">'
|
|
reviewable =
|
|
ReviewableQueuedPost.needs_review!(
|
|
target_created_by: untrusted_user,
|
|
created_by: untrusted_user,
|
|
payload: {
|
|
raw: "This is the post body",
|
|
title: xss_payload,
|
|
},
|
|
)
|
|
|
|
visit("/review")
|
|
|
|
# The title should be visible as text but not execute
|
|
expect(page).to have_no_css("img[src='x']")
|
|
expect(page).to have_no_css("img[onerror]")
|
|
|
|
# Verify the XSS payload is escaped in the HTML
|
|
title_element = page.find(".title-text", match: :first)
|
|
title_html = title_element.native.inner_html
|
|
expect(title_html).to include("<img")
|
|
expect(title_html).to include(">")
|
|
expect(title_html).not_to include("<img src=x onerror")
|
|
end
|
|
|
|
it "prevents stored XSS with script tags in topic title" do
|
|
xss_payload = '<script>alert("XSS")</script>Malicious Title'
|
|
reviewable =
|
|
ReviewableQueuedPost.needs_review!(
|
|
target_created_by: untrusted_user,
|
|
created_by: untrusted_user,
|
|
payload: {
|
|
raw: "This is the post body",
|
|
title: xss_payload,
|
|
},
|
|
)
|
|
|
|
visit("/review")
|
|
|
|
expect(page).to have_no_css("script")
|
|
title_element = page.find(".title-text", match: :first)
|
|
title_html = title_element.native.inner_html
|
|
expect(title_html).to include("<script>")
|
|
expect(title_html).not_to include("<script>alert")
|
|
end
|
|
|
|
it "escapes special characters in title" do
|
|
special_chars_title = "Test & <b>Bold</b> & \"Quotes\" & 'Apostrophes'"
|
|
reviewable =
|
|
ReviewableQueuedPost.needs_review!(
|
|
target_created_by: untrusted_user,
|
|
created_by: untrusted_user,
|
|
payload: {
|
|
raw: "This is the post body",
|
|
title: special_chars_title,
|
|
},
|
|
)
|
|
|
|
visit("/review")
|
|
|
|
# The <b> tag should not render as bold
|
|
expect(page).to have_no_css(".title-text b")
|
|
title_element = page.find(".title-text", match: :first)
|
|
title_html = title_element.native.inner_html
|
|
expect(title_html).to include("&")
|
|
expect(title_html).to include("<b>")
|
|
end
|
|
end
|
|
|
|
describe "when deleting and blocking a spammer from a hidden flagged post" do
|
|
let(:acted_reviewable) do
|
|
flag = PostActionCreator.spam(flagger, Fabricate(:post, user: spammer)).reviewable
|
|
flag.target.update!(
|
|
hidden: true,
|
|
hidden_at: Time.zone.now,
|
|
hidden_reason_id: Post.hidden_reasons[:flag_threshold_reached],
|
|
)
|
|
flag
|
|
end
|
|
|
|
include_examples "resolving a spammer's reviewables on user deletion"
|
|
end
|
|
|
|
describe "when deleting a spammer from a queued post" do
|
|
let(:acted_reviewable) do
|
|
Fabricate(
|
|
:reviewable_queued_post,
|
|
created_by: Discourse.system_user,
|
|
target_created_by: spammer,
|
|
)
|
|
end
|
|
|
|
include_examples "resolving a spammer's reviewables on user deletion"
|
|
end
|
|
end
|