diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index dce0a48aa..d9ad73a8b 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -138,12 +138,12 @@ def rss_feed def post_types @post_types = @category.top_level_post_types - if @post_types.one? + if !user_signed_in? + redirect_to_sign_in + elsif @post_types.one? redirect_to new_category_post_path(post_type: @post_types.first, category: @category) elsif @post_types.empty? && current_user&.admin? redirect_to edit_category_post_types_path(@category, no_return: '1') - elsif !user_signed_in? - redirect_to_sign_in end end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 53f6561cb..eb7aea3a7 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -30,7 +30,7 @@ def new end if @post_type.system? - check_permissions + check_permissions! # return # uncomment if you add more code after this end end @@ -60,31 +60,8 @@ def create @post = Post.new(post_params.merge(user: current_user, body: helpers.rendered_post(:post, :body_markdown), category: @category, post_type: @post_type, parent: @parent)) - if @post.title? && (@post.title.include? '$$') - flash[:danger] = I18n.t 'posts.no_block_mathjax_title' - render :new, status: :bad_request - return - end - - if @post_type.has_parent? && @parent.nil? - flash[:danger] = helpers.i18ns('posts.type_requires_parent', type: @post_type.name) - redirect_back fallback_location: root_path - return - end - - if @post_type.has_category? && @category.nil? && @parent.nil? - flash[:danger] = helpers.i18ns('posts.type_requires_category', type: @post_type.name) - redirect_back fallback_location: root_path - return - end - - if @category.present? && !current_user.can_post_in?(@category) - @post.errors.add(:base, helpers.i18ns('posts.category_low_trust_level', name: @category.name)) - render :new, status: :forbidden - return - end - - if @post_type.system? && !check_permissions + # Not a validation: check_permissions! calls verify_* methods in turn, which render/redirect. + if @post_type.system? && !check_permissions! return end @@ -738,7 +715,7 @@ def set_scoped_post @post = Post.find(params[:id]) end - def check_permissions + def check_permissions! if @post.post_type_id == HelpDoc.post_type_id verify_moderator elsif @post.post_type_id == PolicyDoc.post_type_id diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index 205336318..43c391709 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -8,7 +8,11 @@ class Users::RegistrationsController < Devise::RegistrationsController def create super do |user| unless user.errors.any? - rate_limit = AppConfig.server_settings['registration_rate_limit'] + rate_limit = if Rails.env.development? + 0 + else + AppConfig.server_settings['registration_rate_limit'] + end ip_list = [user.current_sign_in_ip, request.remote_ip].compact previous_ip_users = User.where(current_sign_in_ip: ip_list).or(User.where(last_sign_in_ip: ip_list)) .where(created_at: rate_limit.seconds.ago..DateTime.now) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 7c42defe5..56eee0403 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -342,7 +342,9 @@ def soft_delete return end - @user.soft_delete(current_user) + max_rep = @user.community_users.maximum(:reputation) + check_threshold = AppConfig.spam_protection['deletion_block_max_rep'] + DeleteUserJob.perform_later(@user, current_user, perform_spam_check: max_rep < check_threshold) else render json: { status: 'failed', message: 'Unrecognised deletion type.' }, status: 400 return diff --git a/app/jobs/check_cidr_job.rb b/app/jobs/check_cidr_job.rb new file mode 100644 index 000000000..6319c1e39 --- /dev/null +++ b/app/jobs/check_cidr_job.rb @@ -0,0 +1,47 @@ +class CheckCIDRJob < ApplicationJob + queue_as :default + + def perform(post) + @post = post + relevant_ips = [post.user.current_sign_in_ip, post.user.last_sign_in_ip] + prefixes = BlockedItem.where(item_type: 'ip_prefix') + .where(Arel.sql('expires >= CURRENT_TIMESTAMP')) + .where("? LIKE CONCAT(`value`, '%') OR ? LIKE CONCAT(`value`, '%')", *relevant_ips) + + if prefixes.any? + create_flag prefixes[0] + return # because prefixes are more performant than CIDR checks, so if we can match there then that'll do + end + + cidrs = BlockedItem.where(item_type: 'ip_cidr') + .where(Arel.sql('expires >= CURRENT_TIMESTAMP')) + cidrs.each do |cidr| + network = IPAddress.parse(cidr.value) + relevant_ips.each do |ip| + ip = IPAddress.parse(ip) + # rubocop:disable Style/Next + if network.include?(ip) + create_flag cidr + # rubocop:disable Lint/NonLocalExitFromIterator + return + # rubocop:enable Lint/NonLocalExitFromIterator + end + # rubocop:enable Style/Next + end + end + end + + def create_flag(match) + reason = 'Automatically escalated spam flag - please leave this for the community team to handle.' + spam_flag_type = PostFlagType.unscoped.where(community: @post.community, name: "it's spam").first + flag = @post.flags.create(user: helpers.system_user, community: @post.community, post_flag_type: spam_flag_type, + reason: reason, escalated: true, escalated_at: DateTime.now, + escalated_by: helpers.system_user, + escalation_comment: "Suspicious IP address: user IP matches #{match.value}") + FlagMailer.with(flag: flag).flag_escalated.deliver_now + end + + def helpers + ApplicationController.helpers + end +end diff --git a/app/jobs/delete_user_job.rb b/app/jobs/delete_user_job.rb new file mode 100644 index 000000000..e4b4e87a6 --- /dev/null +++ b/app/jobs/delete_user_job.rb @@ -0,0 +1,28 @@ +class DeleteUserJob < ApplicationJob + queue_as :default + + ## + # Perform a network-wide soft-deletion of a user account. Also optionally checks for helpful spam flags against + # the target user and applies a spam block if found. The caller is responsible for managing thresholds for this spam + # check. + # @param user [User] user to soft-delete + # @param attribute_to [User] the user performing the deletion + # @param perform_spam_check [Boolean] whether to perform the spam check + def perform(user, attribute_to, perform_spam_check: true) + if perform_spam_check + # Can't use model helper methods very easily here, because we want network-wide flags and that doesn't play + # nicely with default scopes. + flag_query = Post.unscoped + .joins(Arel.sql("INNER JOIN flags ON flags.post_type = 'Post' AND flags.post_id = posts.id")) + .joins(Arel.sql('INNER JOIN post_flag_types ON flags.post_flag_type_id = post_flag_types.id')) + .where(flags: { status: 'helpful' }, + post_flag_types: { name: "it's spam" }, + posts: { user_id: user.id }) + if flag_query.any? + user.block('automatic block from spam check during deletion') + end + end + + user.soft_delete(attribute_to) + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 24d4ef204..e24f58411 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -26,6 +26,10 @@ def attributes_print(join: ', ') end.join(join) end + def helpers + ApplicationController.helpers + end + def self.sanitize_for_search(term, **cols) cols = cols.map do |k, v| if v.is_a?(Array) diff --git a/app/models/concerns/post_creation_validations.rb b/app/models/concerns/post_creation_validations.rb new file mode 100644 index 000000000..3e4b7a802 --- /dev/null +++ b/app/models/concerns/post_creation_validations.rb @@ -0,0 +1,81 @@ +module PostCreationValidations + extend ActiveSupport::Concern + + # rubocop:disable Metrics/BlockLength + included do + validate :no_mathjax_in_title, on: :create + validate :post_type_requires_parent, on: :create + validate :post_type_has_category, on: :create + validate :can_post_in_category, on: :create + validate :identical_post_spam, on: :create + validate :no_active_spam_flags, on: :create + + after_create :escalate_suspicious_cidr + + private + + def no_mathjax_in_title + if title? && title.include?('$$') + errors.add(:base, I18n.t('posts.no_block_mathjax_title')) + end + end + + def post_type_requires_parent + if post_type.has_parent? && parent.nil? + errors.add(:base, helpers.i18ns('posts.type_requires_parent', type: post_type.name)) + end + end + + def post_type_has_category + if post_type.has_category? && category.nil? && parent.nil? + errors.add(:base, helpers.i18ns('posts.type_requires_category', type: post_type.name)) + end + end + + def can_post_in_category + if category.present? && !user.can_post_in?(category) + errors.add(:base, helpers.i18ns('posts.category_low_trust_level', name: category.name)) + end + end + + def identical_post_spam + threshold = AppConfig.spam_protection['identical_post_spam_threshold'] + prev_non_deleted_count = Post.unscoped.where(user: user, deleted: false).count + unless prev_non_deleted_count >= threshold + identical_posts = Post.unscoped.where(user: user, body_markdown: body_markdown).where.not(id: id) + if identical_posts.any? + errors.add(:base, I18n.t('posts.spam_blocked')) + end + end + end + + def no_active_spam_flags + posts_threshold = AppConfig.spam_protection['spam_flag_posts_threshold'] + time_threshold = AppConfig.spam_protection['spam_flag_time_threshold'] + prev_non_deleted_count = Post.unscoped.where(user: user, deleted: false).count + unless prev_non_deleted_count >= posts_threshold + active = Post.unscoped + .joins(Arel.sql("INNER JOIN flags ON flags.post_type = 'Post' AND flags.post_id = posts.id")) + .joins(Arel.sql('INNER JOIN post_flag_types ON flags.post_flag_type_id = post_flag_types.id')) + .where(flags: { status: nil }, + post_flag_types: { name: "it's spam" }, + posts: { user_id: user.id }) + helpful = Post.unscoped + .joins(Arel.sql("INNER JOIN flags ON flags.post_type = 'Post' AND flags.post_id = posts.id")) + .joins(Arel.sql('INNER JOIN post_flag_types ON flags.post_flag_type_id = post_flag_types.id')) + .where(flags: { status: 'helpful' }, + post_flag_types: { name: "it's spam" }, + posts: { user_id: user.id }) + .where('flags.created_at <= ?', time_threshold.days.ago) + if active.any? || helpful.any? + errors.add(:base, I18n.t('posts.spam_blocked')) + end + end + end + + def escalate_suspicious_cidr + CheckCIDRJob.perform_later(self) + end + end + # rubocop:enable Metrics/BlockLength +end diff --git a/app/models/concerns/post_validations.rb b/app/models/concerns/post_validations.rb index 1deaa0e51..de0cfa281 100644 --- a/app/models/concerns/post_validations.rb +++ b/app/models/concerns/post_validations.rb @@ -71,6 +71,8 @@ def maximum_title_length end def tags_in_tag_set + return if category.nil? + tag_set = category.tag_set unless tags.all? { |t| t.tag_set_id == tag_set.id } errors.add(:base, "Not all of this question's tags are in the correct tag set.") diff --git a/app/models/post.rb b/app/models/post.rb index fa5bb9ba8..c610cfd01 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -3,6 +3,7 @@ class Post < ApplicationRecord include Lockable include PostNormalizations include PostValidations + include PostCreationValidations include SoftDeletable include Timestamped include UserSortable @@ -293,6 +294,8 @@ def question? ## # Before-validation callback. Update the tags association from the tags_cache. def update_tag_associations + return if category.nil? + tags_cache.each do |tag_name| tag, name_used = Tag.find_or_create_synonymized name: tag_name, tag_set: category.tag_set unless tags.include? tag diff --git a/app/models/user.rb b/app/models/user.rb index 769d53983..aac66bed5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -453,7 +453,7 @@ def send_welcome_tour_message 'how this site works.', '/tour') end - def block(reason, length: 180.days, automatic: true) + def block(reason, length: 10.years, automatic: true) user_email = email user_ip = [last_sign_in_ip] diff --git a/config/config/server_settings.yml b/config/config/server_settings.yml index c788b315f..75ffd23f9 100644 --- a/config/config/server_settings.yml +++ b/config/config/server_settings.yml @@ -1,4 +1,11 @@ -registration_rate_limit: 300 +# Minimum number of seconds between registration attempts from the same IP. +registration_rate_limit: 3600 + +# Number of minutes that sudo mode will last for before asking for password re-entry. user_sudo_duration: 30 + +# Base domain for the network: if you have communities for a.example.com and b.example.com, this should be example.com. network_base_domain: codidact.com + +# File path on your server to which database backups will be saved while awaiting upload to S3. db_backups_path: /var/sql-backups diff --git a/config/config/spam_protection.yml b/config/config/spam_protection.yml new file mode 100644 index 000000000..804794429 --- /dev/null +++ b/config/config/spam_protection.yml @@ -0,0 +1,14 @@ +# When a user is deleted, helpful spam flags against the user's post will additionally cause the user to be fail-banned. +# Users with equal to or more than this reputation value in any community are exempt from this check. +deletion_block_max_rep: 100 + +# Users are blocked from posting something identical to any of their previous posts, unless they have equal to or +# greater than this number of non-deleted posts anywhere on the network. +identical_post_spam_threshold: 10 + +# Users are blocked from posting if there is an active (i.e. not reviewed) spam flag against any of their posts. Users +# with equal to or greater than this number of non-deleted posts in any community are exempt from this check. +spam_flag_posts_threshold: 10 + +# Helpful spam flags will also block posting as above for up to this number of days after the flag was cast. +spam_flag_time_threshold: 7 diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index e20959af4..95518552c 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -17,4 +17,5 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym 'SE' + inflect.acronym 'CIDR' end diff --git a/config/locales/strings/en.posts.yml b/config/locales/strings/en.posts.yml index e4a9c6889..1a367d654 100644 --- a/config/locales/strings/en.posts.yml +++ b/config/locales/strings/en.posts.yml @@ -9,6 +9,8 @@ en: You can't create a :type without a category. type_requires_parent: > You can't create a :type without a parent post. + spam_blocked: > + Couldn't create your post. Try again later. not_public_editable: > This type of post can only be edited by its author and the site moderators. cant_close_post: > diff --git a/test/controllers/concerns/users/user_test_helpers.rb b/test/controllers/concerns/users/user_test_helpers.rb new file mode 100644 index 000000000..8d803dae3 --- /dev/null +++ b/test/controllers/concerns/users/user_test_helpers.rb @@ -0,0 +1,40 @@ +module UserTestHelpers + extend ActiveSupport::Concern + + included do + private + + def create_other_user + other_community = Community.create(host: 'other.qpixel.com', name: 'Other') + RequestContext.redis.hset('network/community_registrations', 'other@example.com', other_community.id) + other_user = User.create!(email: 'other@example.com', password: 'abcdefghijklmnopqrstuvwxyz', username: 'other_user') + other_user.community_users.create!(community: other_community) + other_user + end + + # @param type [String] deletion type (user or profile) + # @param user [User] user to soft delete + def try_soft_delete_user(type, user) + perform_enqueued_jobs do + delete :soft_delete, params: { id: user.id, + type: type, + format: :json } + end + end + + def try_save_preference(name, value, community: nil) + post :set_preference, params: { + community: community, + name: name, + value: value, + format: :json + } + end + + # @param user [User] user to undelete + def try_undelete_user(user) + post :undelete, params: { id: user.id, + format: :json } + end + end +end diff --git a/test/controllers/concerns/users/users_abilities_test.rb b/test/controllers/concerns/users/users_abilities_test.rb deleted file mode 100644 index 295e922e4..000000000 --- a/test/controllers/concerns/users/users_abilities_test.rb +++ /dev/null @@ -1,54 +0,0 @@ -module UsersAbilitiesTest - extend ActiveSupport::Concern - - included do - test 'mod_privilege_action: grant as new' do - sign_in users(:moderator) - post :mod_privilege_action, params: { ability: abilities(:flag_curate).internal_id, do: 'grant', - id: users(:standard_user).id } - assert_response(:success) - assert users(:standard_user).community_user.ability?(abilities(:flag_curate).internal_id), - "User was not granted expected ability #{abilities(:flag_curate).internal_id}" - end - - test 'mod_privilege_action: grant as unsuspend' do - sign_in users(:moderator) - post :mod_privilege_action, params: { ability: abilities(:edit_posts).internal_id, do: 'grant', - id: users(:enabled_2fa).id } - assert_response(:success) - assert users(:enabled_2fa).community_user.ability?(abilities(:edit_posts).internal_id), - "User was not granted expected ability #{abilities(:edit_posts).internal_id}" - end - - test 'mod_privilege_action: suspend' do - sign_in users(:moderator) - post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'suspend', - id: users(:standard_user).id, duration: -1 } - assert_response(:success) - assert_not users(:standard_user).community_user.ability?(abilities(:unrestricted).internal_id), - "User still has ability #{abilities(:unrestricted).internal_id} that should have been suspended" - end - - test 'mod_privilege_action: delete' do - sign_in users(:moderator) - post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'delete', - id: users(:standard_user).id } - assert_response(:success) - assert_not users(:standard_user).community_user.ability?(abilities(:unrestricted).internal_id), - "User still has ability #{abilities(:unrestricted).internal_id} that should have been deleted" - end - - test 'mod_privilege_action: unrecognized action' do - sign_in users(:moderator) - post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'unrecognized', - id: users(:standard_user).id } - assert_response(:not_found) - end - - test 'mod_privilege_action: require moderator' do - post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'unrecognized', - id: users(:standard_user).id } - assert_response(:not_found) - end - end -end diff --git a/test/controllers/posts/create_test.rb b/test/controllers/posts/create_test.rb index ff88b9053..45e58c227 100644 --- a/test/controllers/posts/create_test.rb +++ b/test/controllers/posts/create_test.rb @@ -82,10 +82,9 @@ class PostsControllerTest < ActionController::TestCase try_create_post(category: nil) - assert_response(:found) - assert_redirected_to root_path - assert_not_nil flash[:danger] + assert_response(:bad_request) assert_nil assigns(:post).id + assert_not_empty assigns(:post).errors.full_messages end test 'category post type checks required trust level' do @@ -93,7 +92,7 @@ class PostsControllerTest < ActionController::TestCase try_create_post(category: categories(:high_trust)) - assert_response(:forbidden) + assert_response(:bad_request) assert_nil assigns(:post).id assert_not_empty assigns(:post).errors.full_messages end @@ -103,10 +102,9 @@ class PostsControllerTest < ActionController::TestCase try_create_post(post_type: post_types(:answer)) - assert_response(:found) - assert_redirected_to root_path - assert_not_nil flash[:danger] + assert_response(:bad_request) assert_nil assigns(:post).id + assert_not_empty assigns(:post).errors.full_messages end test 'create ensures community user is created' do @@ -142,6 +140,54 @@ class PostsControllerTest < ActionController::TestCase assert_equal 1, NewThreadFollower.where(['post_id = ? AND user_id = ?', assigns(:post), user]).count end + test 'should block identical post spam from low-activity user' do + user = users(:spammer) + previous_post = posts(:non_deleted_spam_question) + sign_in user + + sample_post = sample(body_markdown: previous_post.body_markdown) + try_create_post(sample_post: sample_post) + + assert_response(:bad_request) + assert_not_nil assigns(:post) + assert assigns(:post).errors.full_messages.include?(I18n.t('posts.spam_blocked')) + end + + test 'should block posting from user with active spam flag' do + user = users(:high_rep_spammer) + sign_in user + + try_create_post + + assert_response(:bad_request) + assert_not_nil assigns(:post) + assert assigns(:post).errors.full_messages.include?(I18n.t('posts.spam_blocked')) + end + + test 'should allow posting but flag from blocked IP prefix' do + user = users(:bad_ip_prefix) + sign_in user + + before_flags = Flag.count + assert_performed_jobs 1 do + try_create_post + end + after_flags = Flag.count + assert_equal before_flags + 1, after_flags, 'Expected a flag to be created' + end + + test 'should allow posting but flag from blocked IP CIDR' do + user = users(:bad_ip_cidr) + sign_in user + + before_flags = Flag.count + assert_performed_jobs 1 do + try_create_post + end + after_flags = Flag.count + assert_equal before_flags + 1, after_flags, 'Expected a flag to be created' + end + private # Attempts to create a post @@ -152,16 +198,18 @@ class PostsControllerTest < ActionController::TestCase def try_create_post(post_type: post_types(:question), category: categories(:main), parent: nil, - license: licenses(:cc_by_sa)) + license: licenses(:cc_by_sa), + sample_post: nil) + sample_post ||= sample post :create, params: { post_type: post_type.id, parent: parent&.id, category: category&.id, post: { post_type_id: post_type.id, - title: sample.title, - body_markdown: sample.body_markdown, + title: sample_post.title, + body_markdown: sample_post.body_markdown, category_id: category&.id, parent_id: parent&.id, - tags_cache: sample.tags_cache, + tags_cache: sample_post.tags_cache, license_id: license.id } } end end diff --git a/test/controllers/users/user_mod_tools_test.rb b/test/controllers/users/user_mod_tools_test.rb new file mode 100644 index 000000000..25939cf91 --- /dev/null +++ b/test/controllers/users/user_mod_tools_test.rb @@ -0,0 +1,303 @@ +require 'test_helper' +require_relative '../concerns/users/user_test_helpers' + +class UsersControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include UserTestHelpers + + test 'full_log should correctly apply single-type items filter' do + sign_in users(:moderator) + + model_map = { + 'posts' => Post, + 'comments' => Comment, + 'edits' => SuggestedEdit, + 'flags' => Flag, + 'warnings' => ModWarning + } + + model_map.each do |filter, model| + get :full_log, params: { id: users(:standard_user).id, filter: filter } + assert_response(:success) + items = assigns(:items) + + assert(items.all?(model)) + end + end + + test 'full_log\'s \'interesting\' filter should include deleted comments' do + sign_in users(:moderator) + + get :full_log, params: { id: users(:standard_user).id, filter: 'interesting' } + assert_response(:success) + items = assigns(:items) + + deleted_comment = comments(:deleted) + + assert(items.any? { |x| x.instance_of?(Comment) && x.id == deleted_comment.id }) + end + + test 'full_log\'s \'interesting\' filter should include declined flags' do + sign_in users(:moderator) + + get :full_log, params: { id: users(:standard_user).id, filter: 'interesting' } + assert_response(:success) + items = assigns(:items) + + declined_flag = flags(:declined) + + assert(items.any? { |x| x.instance_of?(Flag) && x.id == declined_flag.id }) + end + + test 'role toggle should correctly grant & revoke moderator role' do + sign_in users(:global_admin) + + mod = users(:moderator) + + post :role_toggle, params: { id: mod.id, role: 'mod' } + assert_response(:success) + + mod.reload + assert_equal mod.moderator?, false + + post :role_toggle, params: { id: mod.id, role: 'mod' } + assert_response(:success) + + mod.reload + assert_equal mod.moderator?, true + end + + test 'role toggle should correctly grant & revoke admin role' do + sign_in users(:global_admin) + + admin = users(:admin) + + post :role_toggle, params: { id: admin.id, role: 'admin' } + assert_response(:success) + + admin.reload + assert_equal admin.admin?, false + + post :role_toggle, params: { id: admin.id, role: 'admin' } + assert_response(:success) + + admin.reload + assert_equal admin.admin?, true + end + + test 'role toggle should correctly grant & revoke global moderator role' do + sign_in users(:global_admin) + + mod = users(:moderator) + + post :role_toggle, params: { id: mod.id, role: 'mod_global' } + assert_response(:success) + + mod.reload + assert_equal mod.global_moderator?, true + + post :role_toggle, params: { id: mod.id, role: 'mod_global' } + assert_response(:success) + + mod.reload + assert_equal mod.global_moderator?, false + end + + test 'role toggle should correctly grant & revoke global admin role' do + sign_in users(:global_admin) + + admin = users(:admin) + + post :role_toggle, params: { id: admin.id, role: 'admin_global' } + assert_response(:success) + + admin.reload + assert_equal admin.global_admin?, true + + post :role_toggle, params: { id: admin.id, role: 'admin_global' } + assert_response(:success) + + admin.reload + assert_equal admin.global_admin?, false + end + + test 'full_log should only be accessible to mods or admins' do + mod = users(:moderator) + std = users(:standard_user) + + sign_in mod + get :full_log, params: { id: std.id } + assert_response(:success) + + sign_in std + get :full_log, params: { id: std.id } + assert_response(:not_found) + end + + test 'should allow moderator access to deleted account' do + sign_in users(:moderator) + get :show, params: { id: users(:deleted_account).id } + assert_response(:success) + assert_not_nil assigns(:user) + end + + test 'should allow moderator access to deleted profile' do + sign_in users(:moderator) + get :show, params: { id: users(:deleted_profile).id } + assert_response(:success) + assert_not_nil assigns(:user) + end + + test 'should get annotations' do + sign_in users(:admin) + get :annotations, params: { id: users(:standard_user).id } + assert_response(:success) + assert_not_nil assigns(:logs) + end + + test 'should annotate user' do + sign_in users(:admin) + post :annotate, params: { id: users(:standard_user).id, comment: 'some words' } + assert_response(:found) + assert_redirected_to user_annotations_path(users(:standard_user)) + end + + test 'should get mod tools page' do + sign_in users(:moderator) + get :mod, params: { id: users(:standard_user).id } + assert_not_nil assigns(:user) + assert_response(:success) + end + + test 'should require authentication to access mod tools' do + sign_out :user + get :mod, params: { id: users(:standard_user).id } + assert_nil assigns(:user) + assert_response(:not_found) + end + + test 'should require moderator status to access mod tools' do + sign_in users(:standard_user) + get :mod, params: { id: users(:standard_user).id } + assert_nil assigns(:user) + assert_response(:not_found) + end + + test 'moderators and higher should be able to delete user profiles' do + std_usr = users(:standard_user) + + users.select(&:at_least_moderator?).each do |user| + sign_in(user) + + try_soft_delete_user('profile', std_usr) + @user = assigns(:user) + + assert_response(:success) + assert_not_nil @user + assert @user.community_user.deleted + end + end + + test 'should soft-delete user' do + sign_in users(:global_admin) + + try_soft_delete_user('user', users(:standard_user)) + + assert_response(:success) + assert_not_nil assigns(:user) + assert assigns(:user).reload.deleted + end + + test 'only global moderators or admins should be able to soft-delete users' do + std_usr = users(:standard_user) + + ([nil] + users).each do |user| + if user.present? + sign_in(user) + end + + try_soft_delete_user('user', std_usr) + + if user&.at_least_global_moderator? + assert_json_success + elsif user&.at_least_moderator? + assert_json_failure(:forbidden) + else + assert_json_failure(:not_found) + end + end + end + + test 'should require authentication to undelete user profiles' do + del_usr = users(:deleted_profile) + + try_undelete_user(del_usr) + + assert_json_failure(:not_found) + end + + test 'normal users should not be able to undelete user profiles' do + del_usr = users(:deleted_profile) + + users.reject(&:at_least_moderator?).each do |user| + sign_in(user) + + try_undelete_user(del_usr) + + assert_json_failure(:not_found) + res_body = JSON.parse(response.body) + assert_includes res_body['errors'], 'not_found' + end + end + + test 'moderators and higher should be able to undelete user profiles' do + del_usr = users(:deleted_profile) + + users.select(&:at_least_moderator?).each do |user| + sign_in(user) + + try_undelete_user(del_usr) + @user = assigns(:user) + + assert_json_success + assert_not_nil @user + assert_not @user.community_user.deleted? + end + end + + test 'users that are deleted network-wide should not be undeletable' do + del_usr = users(:deleted_account) + + users.select(&:at_least_moderator?).each do |user| + sign_in(user) + + try_undelete_user(del_usr) + del_usr.reload + + assert_json_failure(:not_found) + assert del_usr.community_user.deleted? + end + end + + test 'should spam-block spammer on deletion' do + sign_in users(:global_admin) + spammer = users(:spammer) + + try_soft_delete_user('user', spammer) + + blocked_item = BlockedItem.where(item_type: 'email', value: spammer.email) + assert blocked_item.any?, + "Expected a BlockedItem for #{spammer.email} but none was found." + end + + test 'should not spam-block high-rep user on deletion' do + sign_in users(:global_admin) + high_rep_spammer = users(:high_rep_spammer) + + try_soft_delete_user('user', high_rep_spammer) + + blocked_item = BlockedItem.where(item_type: 'email', value: high_rep_spammer.email) + assert_not blocked_item.any?, + "Expected no BlockedItem for #{high_rep_spammer.email} but one was found." + end +end diff --git a/test/controllers/users/users_abilities_test.rb b/test/controllers/users/users_abilities_test.rb new file mode 100644 index 000000000..854329db6 --- /dev/null +++ b/test/controllers/users/users_abilities_test.rb @@ -0,0 +1,56 @@ +require 'test_helper' +require_relative '../concerns/users/user_test_helpers' + +class UsersControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include UserTestHelpers + + test 'mod_privilege_action: grant as new' do + sign_in users(:moderator) + post :mod_privilege_action, params: { ability: abilities(:flag_curate).internal_id, do: 'grant', + id: users(:standard_user).id } + assert_response(:success) + assert users(:standard_user).community_user.ability?(abilities(:flag_curate).internal_id), + "User was not granted expected ability #{abilities(:flag_curate).internal_id}" + end + + test 'mod_privilege_action: grant as unsuspend' do + sign_in users(:moderator) + post :mod_privilege_action, params: { ability: abilities(:edit_posts).internal_id, do: 'grant', + id: users(:enabled_2fa).id } + assert_response(:success) + assert users(:enabled_2fa).community_user.ability?(abilities(:edit_posts).internal_id), + "User was not granted expected ability #{abilities(:edit_posts).internal_id}" + end + + test 'mod_privilege_action: suspend' do + sign_in users(:moderator) + post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'suspend', + id: users(:standard_user).id, duration: -1 } + assert_response(:success) + assert_not users(:standard_user).community_user.ability?(abilities(:unrestricted).internal_id), + "User still has ability #{abilities(:unrestricted).internal_id} that should have been suspended" + end + + test 'mod_privilege_action: delete' do + sign_in users(:moderator) + post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'delete', + id: users(:standard_user).id } + assert_response(:success) + assert_not users(:standard_user).community_user.ability?(abilities(:unrestricted).internal_id), + "User still has ability #{abilities(:unrestricted).internal_id} that should have been deleted" + end + + test 'mod_privilege_action: unrecognized action' do + sign_in users(:moderator) + post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'unrecognized', + id: users(:standard_user).id } + assert_response(:not_found) + end + + test 'mod_privilege_action: require moderator' do + post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'unrecognized', + id: users(:standard_user).id } + assert_response(:not_found) + end +end diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index c23ff05dc..d462664d0 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -1,10 +1,10 @@ require 'test_helper' -require_relative 'concerns/users/users_abilities_test' +require_relative 'concerns/users/user_test_helpers' class UsersControllerTest < ActionController::TestCase include Devise::Test::ControllerHelpers include ApplicationHelper - include UsersAbilitiesTest + include UserTestHelpers test 'should get index' do [:html, :json].each do |format| @@ -62,123 +62,6 @@ class UsersControllerTest < ActionController::TestCase assert_equal assigns(:total_post_count), post_count end - test 'should get mod tools page' do - sign_in users(:moderator) - get :mod, params: { id: users(:standard_user).id } - assert_not_nil assigns(:user) - assert_response(:success) - end - - test 'should require authentication to access mod tools' do - sign_out :user - get :mod, params: { id: users(:standard_user).id } - assert_nil assigns(:user) - assert_response(:not_found) - end - - test 'should require moderator status to access mod tools' do - sign_in users(:standard_user) - get :mod, params: { id: users(:standard_user).id } - assert_nil assigns(:user) - assert_response(:not_found) - end - - test 'moderators and higher should be able to delete user profiles' do - std_usr = users(:standard_user) - - users.select(&:at_least_moderator?).each do |user| - sign_in(user) - - try_soft_delete_user('profile', std_usr) - @user = assigns(:user) - - assert_response(:success) - assert_not_nil @user - assert @user.community_user.deleted - end - end - - test 'should require authentication to undelete user profiles' do - del_usr = users(:deleted_profile) - - try_undelete_user(del_usr) - - assert_json_failure(:not_found) - end - - test 'normal users should not be able to undelete user profiles' do - del_usr = users(:deleted_profile) - - users.reject(&:at_least_moderator?).each do |user| - sign_in(user) - - try_undelete_user(del_usr) - - assert_json_failure(:not_found) - res_body = JSON.parse(response.body) - assert_includes res_body['errors'], 'not_found' - end - end - - test 'moderators and higher should be able to undelete user profiles' do - del_usr = users(:deleted_profile) - - users.select(&:at_least_moderator?).each do |user| - sign_in(user) - - try_undelete_user(del_usr) - @user = assigns(:user) - - assert_json_success - assert_not_nil @user - assert_not @user.community_user.deleted? - end - end - - test 'users that are deleted network-wide should not be undeletable' do - del_usr = users(:deleted_account) - - users.select(&:at_least_moderator?).each do |user| - sign_in(user) - - try_undelete_user(del_usr) - del_usr.reload - - assert_json_failure(:not_found) - assert del_usr.community_user.deleted? - end - end - - test 'should soft-delete user' do - sign_in users(:global_admin) - - try_soft_delete_user('user', users(:standard_user)) - - assert_response(:success) - assert_not_nil assigns(:user) - assert assigns(:user).deleted - end - - test 'only global moderators or admins should be able to soft-delete users' do - std_usr = users(:standard_user) - - ([nil] + users).each do |user| - if user.present? - sign_in(user) - end - - try_soft_delete_user('user', std_usr) - - if user&.at_least_global_moderator? - assert_json_success - elsif user&.at_least_moderator? - assert_json_failure(:forbidden) - else - assert_json_failure(:not_found) - end - end - end - test 'should require authentication to soft-delete user' do sign_out :user @@ -333,20 +216,6 @@ class UsersControllerTest < ActionController::TestCase assert_response(:not_found) end - test 'should get annotations' do - sign_in users(:admin) - get :annotations, params: { id: users(:standard_user).id } - assert_response(:success) - assert_not_nil assigns(:logs) - end - - test 'should annotate user' do - sign_in users(:admin) - post :annotate, params: { id: users(:standard_user).id, comment: 'some words' } - assert_response(:found) - assert_redirected_to user_annotations_path(users(:standard_user)) - end - test 'should deny access to deleted account' do get :show, params: { id: users(:deleted_account).id } assert_response(:not_found) @@ -358,20 +227,6 @@ class UsersControllerTest < ActionController::TestCase assert_not_nil assigns(:user) end - test 'should allow moderator access to deleted account' do - sign_in users(:moderator) - get :show, params: { id: users(:deleted_account).id } - assert_response(:success) - assert_not_nil assigns(:user) - end - - test 'should allow moderator access to deleted profile' do - sign_in users(:moderator) - get :show, params: { id: users(:deleted_profile).id } - assert_response(:success) - assert_not_nil assigns(:user) - end - test 'my_activity should redirect to user activity or to sign in for anonymous access' do users.each do |user| sign_in user @@ -455,91 +310,6 @@ class UsersControllerTest < ActionController::TestCase assert_equal data['username'], mod.username end - test 'role toggle should correctly grant & revoke moderator role' do - sign_in users(:global_admin) - - mod = users(:moderator) - - post :role_toggle, params: { id: mod.id, role: 'mod' } - assert_response(:success) - - mod.reload - assert_equal mod.moderator?, false - - post :role_toggle, params: { id: mod.id, role: 'mod' } - assert_response(:success) - - mod.reload - assert_equal mod.moderator?, true - end - - test 'role toggle should correctly grant & revoke admin role' do - sign_in users(:global_admin) - - admin = users(:admin) - - post :role_toggle, params: { id: admin.id, role: 'admin' } - assert_response(:success) - - admin.reload - assert_equal admin.admin?, false - - post :role_toggle, params: { id: admin.id, role: 'admin' } - assert_response(:success) - - admin.reload - assert_equal admin.admin?, true - end - - test 'role toggle should correctly grant & revoke global moderator role' do - sign_in users(:global_admin) - - mod = users(:moderator) - - post :role_toggle, params: { id: mod.id, role: 'mod_global' } - assert_response(:success) - - mod.reload - assert_equal mod.global_moderator?, true - - post :role_toggle, params: { id: mod.id, role: 'mod_global' } - assert_response(:success) - - mod.reload - assert_equal mod.global_moderator?, false - end - - test 'role toggle should correctly grant & revoke global admin role' do - sign_in users(:global_admin) - - admin = users(:admin) - - post :role_toggle, params: { id: admin.id, role: 'admin_global' } - assert_response(:success) - - admin.reload - assert_equal admin.global_admin?, true - - post :role_toggle, params: { id: admin.id, role: 'admin_global' } - assert_response(:success) - - admin.reload - assert_equal admin.global_admin?, false - end - - test 'full_log should only be accessible to mods or admins' do - mod = users(:moderator) - std = users(:standard_user) - - sign_in mod - get :full_log, params: { id: std.id } - assert_response(:success) - - sign_in std - get :full_log, params: { id: std.id } - assert_response(:not_found) - end - test 'activity should correctly apply single-type items filter' do std = users(:standard_user) @@ -596,50 +366,6 @@ class UsersControllerTest < ActionController::TestCase end end - test 'full_log should correctly apply single-type items filter' do - sign_in users(:moderator) - - model_map = { - 'posts' => Post, - 'comments' => Comment, - 'edits' => SuggestedEdit, - 'flags' => Flag, - 'warnings' => ModWarning - } - - model_map.each do |filter, model| - get :full_log, params: { id: users(:standard_user).id, filter: filter } - assert_response(:success) - items = assigns(:items) - - assert(items.all?(model)) - end - end - - test 'full_log\'s \'interesting\' filter should include deleted comments' do - sign_in users(:moderator) - - get :full_log, params: { id: users(:standard_user).id, filter: 'interesting' } - assert_response(:success) - items = assigns(:items) - - deleted_comment = comments(:deleted) - - assert(items.any? { |x| x.instance_of?(Comment) && x.id == deleted_comment.id }) - end - - test 'full_log\'s \'interesting\' filter should include declined flags' do - sign_in users(:moderator) - - get :full_log, params: { id: users(:standard_user).id, filter: 'interesting' } - assert_response(:success) - items = assigns(:items) - - declined_flag = flags(:declined) - - assert(items.any? { |x| x.instance_of?(Flag) && x.id == declined_flag.id }) - end - test 'set_preference should correclty save valid preferences' do sign_in users(:standard_user) @@ -676,37 +402,4 @@ class UsersControllerTest < ActionController::TestCase assert_not_nil parsed_body['message'] end end - - private - - def create_other_user - other_community = Community.create(host: 'other.qpixel.com', name: 'Other') - RequestContext.redis.hset('network/community_registrations', 'other@example.com', other_community.id) - other_user = User.create!(email: 'other@example.com', password: 'abcdefghijklmnopqrstuvwxyz', username: 'other_user') - other_user.community_users.create!(community: other_community) - other_user - end - - # @param type [String] deletion type (user or profile) - # @param user [User] user to soft delete - def try_soft_delete_user(type, user) - delete :soft_delete, params: { id: user.id, - type: type, - format: :json } - end - - # @param user [User] user to undelete - def try_undelete_user(user) - post :undelete, params: { id: user.id, - format: :json } - end - - def try_save_preference(name, value, community: nil) - post :set_preference, params: { - community: community, - name: name, - value: value, - format: :json - } - end end diff --git a/test/fixtures/blocked_items.yml b/test/fixtures/blocked_items.yml index 6070be6b2..045c58539 100644 --- a/test/fixtures/blocked_items.yml +++ b/test/fixtures/blocked_items.yml @@ -5,9 +5,25 @@ email: value: blocked@mail.com automatic: true reason: got fed up with them + expires: 2100-01-01T00:00:00 ip: item_type: ip value: 8.8.8.8 automatic: false reason: Why are we accessed by a DNS server? + expires: 2100-01-01T00:00:00 + +ip_prefix: + item_type: ip_prefix + value: '2407:f8:200' + automatic: false + reason: Because I said so + expires: 2100-01-01T00:00:00 + +ip_cidr: + item_type: ip_cidr + value: '2407:f8:300::/48' + automatic: false + reason: Because I said so + expires: 2100-01-01T00:00:00 diff --git a/test/fixtures/community_users.yml b/test/fixtures/community_users.yml index cd45f77ec..f38fb7f04 100644 --- a/test/fixtures/community_users.yml +++ b/test/fixtures/community_users.yml @@ -157,3 +157,24 @@ sample_merge_target: is_admin: false is_moderator: false reputation: 24 + +high_rep_spammer: + user: high_rep_spammer + community: sample + is_admin: false + is_moderator: false + reputation: 1000 + +bad_ip_prefix: + user: bad_ip_prefix + community: sample + is_admin: false + is_moderator: false + reputation: 1 + +bad_ip_cidr: + user: bad_ip_cidr + community: sample + is_admin: false + is_moderator: false + reputation: 1 diff --git a/test/fixtures/flags.yml b/test/fixtures/flags.yml index 80ef66b17..e1ad7b07e 100644 --- a/test/fixtures/flags.yml +++ b/test/fixtures/flags.yml @@ -5,6 +5,13 @@ one: user: standard_user community: sample +active_spam: + reason: It's obviously spam + post: deleted_high_rep_spam (Post) + post_flag_type: spam + user: standard_user + community: sample + declined: reason: Please decline it post: question_one (Post) @@ -52,3 +59,11 @@ helpful_on_spam_question: user: standard_user community: sample status: helpful + +helpful_on_high_rep_spam: + reason: It's obviously spam + post: deleted_high_rep_spam (Post) + post_flag_type: spam + user: standard_user + community: sample + status: helpful diff --git a/test/fixtures/posts.yml b/test/fixtures/posts.yml index 63a1dee77..b9adf829f 100644 --- a/test/fixtures/posts.yml +++ b/test/fixtures/posts.yml @@ -560,6 +560,20 @@ imported_question: category: main license: cc_by_sa +non_deleted_spam_question: + post_type: question + title: Best spam in the neighborhood! Call 555-55-55 + body: Find the best spam in your neighborhood on Spam Central + body_markdown:
Find the best spam in your neighborhood on Spam Central
+ tags_cache: + - discussion + tags: + - discussion + user: high_rep_spammer + community: sample + category: main + license: cc_by_sa + deleted_spam_question: post_type: question title: Best spam in the neighborhood! Call 555-55-55 @@ -577,6 +591,23 @@ deleted_spam_question: deleted_at: 2019-01-01T00:00:00.000000Z deleted_by: moderator +deleted_high_rep_spam: + post_type: question + title: Best spam in the neighborhood! Call 555-55-55 + body: Find the best spam in your neighborhood on Spam Central + body_markdown:Find the best spam in your neighborhood on Spam Central
+ tags_cache: + - discussion + tags: + - discussion + user: high_rep_spammer + community: sample + category: main + license: cc_by_sa + deleted: true + deleted_at: 2019-01-01T00:00:00.000000Z + deleted_by: moderator + without_new_thread_followers: post_type: question title: This post does not have any new thread followers diff --git a/test/fixtures/user_abilities.yml b/test/fixtures/user_abilities.yml index 945e33ce7..7d891f276 100644 --- a/test/fixtures/user_abilities.yml +++ b/test/fixtures/user_abilities.yml @@ -140,3 +140,27 @@ profile_spammer_everyone: profile_spammer_unrestricted: community_user: sample_profile_spammer ability: unrestricted + +hrs_eo: + community_user: high_rep_spammer + ability: everyone + +hrs_ur: + community_user: high_rep_spammer + ability: unrestricted + +bip_eo: + community_user: bad_ip_prefix + ability: everyone + +bip_ur: + community_user: bad_ip_prefix + ability: unrestricted + +bic_eo: + community_user: bad_ip_cidr + ability: everyone + +bic_ur: + community_user: bad_ip_cidr + ability: unrestricted diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 6d3099919..9ea975b5b 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -174,6 +174,55 @@ spammer: is_global_moderator: false confirmed_at: 2020-01-01T00:00:00.000000Z +high_rep_spammer: + email: high-rep-spammer@example.com + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' + profile: > + Find the best spam on the network here! + The yummiest spam you've ever tasted. + profile_markdown: > + Find the best spam on the network [here](https://example.com)! + The yummiest spam you've ever tasted. + sign_in_count: 1 + username: ReppySpam + is_global_admin: false + is_global_moderator: false + confirmed_at: 2020-01-01T00:00:00.000000Z + +bad_ip_prefix: + email: bad-ip-prefix@example.com + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' + profile: > + Find the best spam on the network here! + The yummiest spam you've ever tasted. + profile_markdown: > + Find the best spam on the network [here](https://example.com)! + The yummiest spam you've ever tasted. + sign_in_count: 1 + username: IPSpam + is_global_admin: false + is_global_moderator: false + confirmed_at: 2020-01-01T00:00:00.000000Z + current_sign_in_ip: '2407:f8:200:abcd::1' + last_sign_in_ip: '2407:f8:200:abcd::1' + +bad_ip_cidr: + email: bad-ip-cidr@example.com + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' + profile: > + Find the best spam on the network here! + The yummiest spam you've ever tasted. + profile_markdown: > + Find the best spam on the network [here](https://example.com)! + The yummiest spam you've ever tasted. + sign_in_count: 1 + username: CIDRSpam + is_global_admin: false + is_global_moderator: false + confirmed_at: 2020-01-01T00:00:00.000000Z + current_sign_in_ip: '2407:f8:300:abcd::1' + last_sign_in_ip: '2407:f8:300:abcd::1' + profile_spammer: email: profile-spammer@example.com encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' diff --git a/test/jobs/check_cidr_job_test.rb b/test/jobs/check_cidr_job_test.rb new file mode 100644 index 000000000..6e4280825 --- /dev/null +++ b/test/jobs/check_cidr_job_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class CheckCidrJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/jobs/delete_user_job_test.rb b/test/jobs/delete_user_job_test.rb new file mode 100644 index 000000000..74786ef5e --- /dev/null +++ b/test/jobs/delete_user_job_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class DeleteUserJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 7befbb484..b346098c5 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -211,22 +211,30 @@ def assert_redirected_to_sign_in assert_redirected_to(new_user_session_path) end + ## + # Assert that the given array contains at least one of the array of expected values. + # @param ary [Array] array to check + # @param expected [Array] array of expected values + # @param error_message [String] error message to display if the assertion fails + def assert_include_any(ary, expected, error_message = nil) + default_error = 'Expected array to contain any expected value.' + assert ary.any? { |x| expected.include?(x) }, error_message || default_error + end + PostMock = Struct.new(:title, :body_markdown, :body, :tags_cache, :edit, keyword_init: true) - def sample - PostMock.new( - title: 'This is a sample title', - body_markdown: 'This is a sample post with some **Markdown** and [a link](/).', - body: 'This is a sample post with some Markdown and a link
', - tags_cache: ['discussion', 'posts', 'tags'], - edit: PostMock.new( - title: 'This is another sample title', - body_markdown: 'This is a sample post with some more **Markdown** and [a link](/).', - body: 'This is a sample post with some more Markdown and a link
', - tags_cache: ['discussion', 'posts', 'tags', 'edits'], - edit: nil - ) - ) + def sample(**options) + PostMock.new(title: 'This is a sample title', + body_markdown: 'This is a sample post with some **Markdown** and [a link](/).', + body: 'This is a sample post with some Markdown and a link
', + tags_cache: ['discussion', 'posts', 'tags'], + edit: PostMock.new( + title: 'This is another sample title', + body_markdown: 'This is a sample post with some more **Markdown** and [a link](/).', + body: 'This is a sample post with some more Markdown and a link
', + tags_cache: ['discussion', 'posts', 'tags', 'edits'], + edit: nil + ), **options) end end