votes: allow admins to remove post votes.

Allow admins to remove votes on posts. This is for fixing vote abuse.

Votes can be removed by going to the vote list on the /post_votes page,
or by clicking on a post's score, then using the "Remove" option in the
"..." dropdown menu next to the vote.

Votes are soft-deleted - they're marked as deleted in the database, but
not fully deleted. Removed votes are only visible to admins, not to
regular users. When a vote is removed by an admin, it leaves a mod
action.

Technically it's possible to undelete votes, but there's no UI for it.
This commit is contained in:
evazion
2021-11-23 22:20:01 -06:00
parent 692f2848f2
commit 353e708538
19 changed files with 261 additions and 108 deletions

View File

@@ -86,10 +86,14 @@ class FavoritesControllerTest < ActionDispatch::IntegrationTest
context "destroy action" do
should "remove the favorite for the current user" do
assert_difference [-> { @faved_post.favorites.count }, -> { @faved_post.reload.fav_count }, -> { @user.reload.favorite_count }], -1 do
delete_auth favorite_path(@faved_post.id), @user, as: :javascript
assert_response :redirect
end
delete_auth favorite_path(@faved_post.id), @user, as: :javascript
assert_response :redirect
assert_equal(0, @faved_post.favorites.count)
assert_equal(0, @faved_post.reload.fav_count)
assert_equal(0, @faved_post.votes.active.count)
assert_equal(1, @faved_post.votes.deleted.count)
assert_equal(0, @user.reload.favorite_count)
end
should "allow banned users to destroy favorites" do

View File

@@ -175,51 +175,51 @@ class PostVotesControllerTest < ActionDispatch::IntegrationTest
context "create action" do
should "work for a JSON response" do
post_auth post_post_votes_path(post_id: @post.id), @user, params: { score: 1, format: "json" }
post_auth post_post_votes_path(post_id: @post.id, score: 1), @user, as: :json
assert_response 201
assert_equal(1, @post.reload.score)
end
should "not allow anonymous users to vote" do
post post_post_votes_path(post_id: @post.id), params: { score: 1, format: "js" }
post post_post_votes_path(post_id: @post.id, score: 1), xhr: true
assert_response 403
assert_equal(0, @post.reload.score)
end
should "not allow banned users to vote" do
post_auth post_post_votes_path(post_id: @post.id), create(:banned_user), params: { score: 1, format: "js"}
post_auth post_post_votes_path(post_id: @post.id, score: 1), create(:banned_user), xhr: true
assert_response 403
assert_equal(0, @post.reload.score)
end
should "not allow restricted users to vote" do
post_auth post_post_votes_path(post_id: @post.id), create(:restricted_user), params: { score: 1, format: "js"}
post_auth post_post_votes_path(post_id: @post.id, score: 1), create(:restricted_user), xhr: true
assert_response 403
assert_equal(0, @post.reload.score)
end
should "allow members to vote" do
post_auth post_post_votes_path(post_id: @post.id), create(:user), params: { score: 1, format: "js" }
post_auth post_post_votes_path(post_id: @post.id, score: 1), create(:user), xhr: true
assert_response :success
assert_equal(1, @post.reload.score)
end
should "not allow invalid scores" do
post_auth post_post_votes_path(post_id: @post.id), @user, params: { score: 3, format: "js" }
post_auth post_post_votes_path(post_id: @post.id, score: 3), @user, xhr: true
assert_response 200
assert_response :success
assert_equal(0, @post.reload.score)
assert_equal(0, @post.up_score)
assert_equal(0, @post.votes.count)
end
should "increment a post's score if the score is positive" do
post_auth post_post_votes_path(post_id: @post.id), @user, params: { score: 1, format: "js" }
post_auth post_post_votes_path(post_id: @post.id, score: 1), @user, xhr: true
assert_response :success
assert_equal(1, @post.reload.score)
@@ -228,7 +228,7 @@ class PostVotesControllerTest < ActionDispatch::IntegrationTest
end
should "decrement a post's score if the score is negative" do
post_auth post_post_votes_path(post_id: @post.id), @user, params: { score: -1, format: "js" }
post_auth post_post_votes_path(post_id: @post.id, score: -1), @user, xhr: true
assert_response :success
assert_equal(-1, @post.reload.score)
@@ -238,45 +238,66 @@ class PostVotesControllerTest < ActionDispatch::IntegrationTest
context "for a post that has already been voted on" do
should "replace the vote" do
@post.vote!(1, @user)
vote = create(:post_vote, post: @post, user: @user, score: 1)
post_auth post_post_votes_path(post_id: @post.id, score: -1), @user, xhr: true
assert_no_difference("@post.votes.count") do
post_auth post_post_votes_path(post_id: @post.id), @user, params: { score: -1, format: "js" }
assert_response :success
assert_equal(-1, @post.reload.score)
assert_equal(0, @post.up_score)
assert_equal(-1, @post.down_score)
end
assert_response :success
assert_equal(-1, @post.reload.score)
assert_equal(0, @post.up_score)
assert_equal(-1, @post.down_score)
assert_equal(1, @post.votes.negative.active.count)
assert_equal(1, @post.votes.positive.deleted.count)
assert_equal(true, vote.reload.is_deleted?)
end
end
end
context "destroy action" do
should "do nothing for anonymous users" do
delete post_post_votes_path(post_id: @post.id), xhr: true
assert_response 200
assert_equal(0, @post.reload.score)
setup do
@vote = create(:post_vote, post: @post, user: @user, score: 1)
end
should "do nothing if the post hasn't been voted on" do
delete_auth post_post_votes_path(post_id: @post.id), @user, xhr: true
should "allow users to remove their own votes" do
delete_auth post_post_votes_path(post_id: @vote.post_id), @user, xhr: true
assert_response :success
assert_equal(0, @post.reload.score)
assert_equal(0, @post.down_score)
assert_equal(0, @post.votes.count)
assert_equal(0, @post.up_score)
assert_equal(0, @post.votes.active.count)
assert_equal(true, @vote.reload.is_deleted?)
end
should "remove a vote" do
@post.vote!(1, @user)
delete_auth post_post_votes_path(post_id: @post.id), @user, xhr: true
should "not allow regular users to remove votes by other users" do
delete_auth post_vote_path(@vote), create(:user), xhr: true
assert_response 403
assert_equal(1, @post.reload.score)
assert_equal(1, @post.up_score)
assert_equal(1, @post.votes.active.count)
assert_equal(false, @vote.reload.is_deleted?)
end
should "allow admins to remove votes by other users" do
admin = create(:admin_user)
delete_auth post_vote_path(@vote), admin, xhr: true
assert_response :success
assert_equal(0, @post.reload.score)
assert_equal(0, @post.down_score)
assert_equal(0, @post.votes.count)
assert_equal(0, @post.up_score)
assert_equal(0, @post.votes.active.count)
assert_equal(true, @vote.reload.is_deleted?)
assert_match(/#{admin.name} deleted post vote #\d+ on post #\d+/, ModAction.post_vote_delete.last.description)
end
should "not fail when attempting to remove an already removed vote" do
@vote.soft_delete!
delete_auth post_post_votes_path(post_id: @vote.post_id), @user, xhr: true
assert_response :success
assert_equal(0, @post.reload.score)
assert_equal(0, @post.up_score)
assert_equal(0, @post.votes.active.count)
assert_equal(true, @vote.reload.is_deleted?)
end
end
end

View File

@@ -40,7 +40,8 @@ class FavoriteTest < ActiveSupport::TestCase
assert_equal(0, @user.reload.favorite_count)
assert_equal(0, @p1.reload.fav_count)
assert_equal(0, @p1.reload.score)
refute(PostVote.positive.exists?(post: @p1, user: @user))
refute(PostVote.active.positive.exists?(post: @p1, user: @user))
assert(PostVote.deleted.positive.exists?(post: @p1, user: @user))
end
end
@@ -75,8 +76,8 @@ class FavoriteTest < ActiveSupport::TestCase
assert_equal(1, @user.reload.favorite_count)
assert_equal(1, @p1.reload.fav_count)
assert_equal(1, @p1.reload.score)
assert(PostVote.positive.exists?(post: @p1, user: @user))
refute(PostVote.negative.exists?(post: @p1, user: @user))
assert(PostVote.active.positive.exists?(post: @p1, user: @user))
assert(PostVote.deleted.negative.exists?(post: @p1, user: @user))
end
should "not allow duplicate favorites" do

View File

@@ -701,12 +701,12 @@ class PostTest < ActiveSupport::TestCase
@post.update(tag_string: "aaa fav:self")
assert_equal(1, @post.reload.score)
assert_equal(1, @post.favorites.where(user: @user).count)
assert_equal(1, @post.votes.positive.where(user: @user).count)
assert_equal(1, @post.votes.active.positive.where(user: @user).count)
@post.update(tag_string: "aaa -fav:self")
assert_equal(0, @post.reload.score)
assert_equal(0, @post.favorites.count)
assert_equal(0, @post.votes.positive.where(user: @user).count)
assert_equal(0, @post.votes.active.positive.where(user: @user).count)
end
should "not allow banned users to fav" do
@@ -1433,11 +1433,12 @@ class PostTest < ActiveSupport::TestCase
end
end
should "not decrement the post's score for basic users" do
should "decrement the post's score for basic users" do
@member = FactoryBot.create(:user)
assert_no_difference("@post.score") { create(:favorite, post: @post, user: @member) }
assert_no_difference("@post.score") { Favorite.destroy_by(post: @post, user: @member) }
assert_difference("@post.reload.score", -1) do
Favorite.destroy_by(post: @post, user: @user)
end
end
should "not decrement the user's favorite_count if the user did not favorite the post" do
@@ -1565,7 +1566,7 @@ class PostTest < ActiveSupport::TestCase
post.vote!(1, user)
assert_equal(1, post.reload.score)
assert_equal(1, post.votes.count)
assert_equal(1, post.votes.active.count)
end
should "allow undoing of votes" do
@@ -1578,31 +1579,33 @@ class PostTest < ActiveSupport::TestCase
assert_equal(1, post.score)
assert_equal(1, post.up_score)
assert_equal(0, post.down_score)
assert_equal(1, post.votes.positive.count)
assert_equal(1, post.votes.active.positive.count)
post.unvote!(user)
post.votes.last.soft_delete!
post.reload
assert_equal(0, post.score)
assert_equal(0, post.up_score)
assert_equal(0, post.down_score)
assert_equal(0, post.votes.count)
assert_equal(0, post.votes.active.count)
post.vote!(-1, user)
assert_equal(-1, post.score)
assert_equal(0, post.up_score)
assert_equal(-1, post.down_score)
assert_equal(1, post.votes.negative.count)
assert_equal(1, post.votes.active.negative.count)
post.unvote!(user)
post.votes.last.soft_delete!
post.reload
assert_equal(0, post.score)
assert_equal(0, post.up_score)
assert_equal(0, post.down_score)
assert_equal(0, post.votes.count)
assert_equal(0, post.votes.active.count)
post.vote!(1, user)
assert_equal(1, post.score)
assert_equal(1, post.up_score)
assert_equal(0, post.down_score)
assert_equal(1, post.votes.positive.count)
assert_equal(1, post.votes.active.positive.count)
post.reload
assert_equal(1, post.score)

View File

@@ -9,7 +9,6 @@ class PostVoteTest < ActiveSupport::TestCase
context "during validation" do
subject { build(:post_vote, post: @post) }
should validate_uniqueness_of(:user_id).scoped_to(:post_id).with_message("have already voted for this post")
should validate_inclusion_of(:score).in_array([-1, 1]).with_message("must be 1 or -1")
end
@@ -21,7 +20,20 @@ class PostVoteTest < ActiveSupport::TestCase
assert_equal(1, @post.reload.score)
assert_equal(1, @post.up_score)
assert_equal(0, @post.down_score)
assert_equal(1, @post.votes.positive.count)
assert_equal(1, @post.votes.active.positive.count)
end
should "soft delete other votes" do
@user = create(:user)
vote1 = create(:post_vote, post: @post, user: @user, score: -1)
vote2 = create(:post_vote, post: @post, user: @user, score: 1)
assert_equal(1, @post.reload.score)
assert_equal(1, @post.up_score)
assert_equal(0, @post.down_score)
assert_equal(1, @post.votes.active.positive.count)
assert_equal(0, @post.votes.active.negative.count)
assert_equal(true, vote1.reload.is_deleted?)
end
end
@@ -32,25 +44,40 @@ class PostVoteTest < ActiveSupport::TestCase
assert_equal(-1, @post.reload.score)
assert_equal(0, @post.up_score)
assert_equal(-1, @post.down_score)
assert_equal(1, @post.votes.negative.count)
assert_equal(1, @post.votes.active.negative.count)
end
should "soft delete other votes" do
@user = create(:user)
vote1 = create(:post_vote, post: @post, user: @user, score: 1)
vote2 = create(:post_vote, post: @post, user: @user, score: -1)
assert_equal(-1, @post.reload.score)
assert_equal(0, @post.up_score)
assert_equal(-1, @post.down_score)
assert_equal(0, @post.votes.active.positive.count)
assert_equal(1, @post.votes.active.negative.count)
assert_equal(true, vote1.reload.is_deleted?)
end
end
end
context "destroying" do
context "soft deleting" do
context "an upvote" do
should "decrement the post's score" do
vote = create(:post_vote, post: @post, score: 1)
assert_equal(1, @post.reload.score)
assert_equal(1, @post.up_score)
assert_equal(0, @post.down_score)
assert_equal(1, @post.votes.count)
assert_equal(1, @post.votes.active.count)
assert_equal(0, @post.votes.deleted.count)
vote.destroy
vote.soft_delete
assert_equal(0, @post.reload.score)
assert_equal(0, @post.up_score)
assert_equal(0, @post.down_score)
assert_equal(0, @post.votes.count)
assert_equal(0, @post.votes.active.count)
assert_equal(1, @post.votes.deleted.count)
end
end
@@ -60,15 +87,49 @@ class PostVoteTest < ActiveSupport::TestCase
assert_equal(-1, @post.reload.score)
assert_equal(0, @post.up_score)
assert_equal(-1, @post.down_score)
assert_equal(1, @post.votes.count)
assert_equal(1, @post.votes.active.count)
assert_equal(0, @post.votes.deleted.count)
vote.destroy
vote.soft_delete
assert_equal(0, @post.reload.score)
assert_equal(0, @post.up_score)
assert_equal(0, @post.down_score)
assert_equal(0, @post.votes.count)
assert_equal(0, @post.votes.active.count)
assert_equal(1, @post.votes.deleted.count)
end
end
end
context "deleting a vote by another user" do
should "leave a mod action" do
admin = create(:admin_user, name: "admin")
vote = create(:post_vote, post: @post, score: 1)
vote.soft_delete!(updater: admin)
assert_match(/admin deleted post vote #\d+ on post #\d+/, ModAction.post_vote_delete.last.description)
end
end
context "undeleting a vote by another user" do
setup do
@admin = create(:admin_user, name: "admin")
@vote = create(:post_vote, post: @post, score: 1)
@vote.soft_delete!(updater: @admin)
@vote.update!(is_deleted: false, updater: @admin)
end
should "restore the score" do
assert_equal(1, @post.reload.score)
assert_equal(1, @post.up_score)
assert_equal(0, @post.down_score)
assert_equal(1, @post.votes.active.count)
assert_equal(0, @post.votes.deleted.count)
end
should "leave a mod action" do
assert_match(/admin undeleted post vote #\d+ on post #\d+/, ModAction.post_vote_undelete.last.description)
end
end
end
end