Commit 93ac9733 authored by Chad Woolley's avatar Chad Woolley

Refactor SpamActionService and its usage

* Removes 'SpamCheckMethods' and makes spam-checking workflow
  explicit in callers
* Moves captcha verification to be encapsulated in 'SpamActionService'
* Introduces 'CaptchaVerificationService', which can be expanded upon
  on the future for additional captcha implementations.
* Moves handling of 'request' parameter to be called only where it
  is needed.
* Removes memoization of GitLab::Recaptcha.load_configurations! -
  it complicates testing/mocking, and it isn't clear why it was
  ever needed - it isn't memoized other places.
* Rewrites issue and snippet service create/update tests to remove
  tests of internal behavior of other services, and instead just
  test their collaborations via mock expectations.
* Expands and improves test coverage of SpamActionService
* Other related changes to accomodate new interface and behavior.
parent 1ae7050e
...@@ -3,9 +3,6 @@ ...@@ -3,9 +3,6 @@
module SpammableActions module SpammableActions
extend ActiveSupport::Concern extend ActiveSupport::Concern
include Recaptcha::Verify
include Gitlab::Utils::StrongMemoize
included do included do
before_action :authorize_submit_spammable!, only: :mark_as_spam before_action :authorize_submit_spammable!, only: :mark_as_spam
end end
...@@ -21,9 +18,7 @@ module SpammableActions ...@@ -21,9 +18,7 @@ module SpammableActions
private private
def ensure_spam_config_loaded! def ensure_spam_config_loaded!
strong_memoize(:spam_config_loaded) do Gitlab::Recaptcha.load_configurations!
Gitlab::Recaptcha.load_configurations!
end
end end
def recaptcha_check_with_fallback(should_redirect = true, &fallback) def recaptcha_check_with_fallback(should_redirect = true, &fallback)
...@@ -50,33 +45,30 @@ module SpammableActions ...@@ -50,33 +45,30 @@ module SpammableActions
end end
def spammable_params def spammable_params
default_params = { request: request } # NOTE: For the legacy reCAPTCHA implementation based on the HTML/HAML form, the
# 'g-recaptcha-response' field name comes from `Recaptcha::ClientHelper#recaptcha_tags` in the
recaptcha_check = recaptcha_response && # recaptcha gem, which is called from the HAML `_recaptcha_form.html.haml` form.
ensure_spam_config_loaded! &&
verify_recaptcha(response: recaptcha_response)
return default_params unless recaptcha_check
{ recaptcha_verified: true,
spam_log_id: params[:spam_log_id] }.merge(default_params)
end
def recaptcha_response
# NOTE: This field name comes from `Recaptcha::ClientHelper#recaptcha_tags` in the recaptcha
# gem, which is called from the HAML `_recaptcha_form.html.haml` form.
# #
# It is used in the `Recaptcha::Verify#verify_recaptcha` if the `response` option is not # It is used in the `Recaptcha::Verify#verify_recaptcha` to extract the value from `params`,
# passed explicitly. # if the `response` option is not passed explicitly.
# #
# Instead of relying on this behavior, we are extracting and passing it explicitly. This will # Instead of relying on this behavior, we are extracting and passing it explicitly. This will
# make it consistent with the newer, modern reCAPTCHA verification process as it will be # make it consistent with the newer, modern reCAPTCHA verification process as it will be
# implemented via the GraphQL API and in Vue components via the native reCAPTCHA Javascript API, # implemented via the GraphQL API and in Vue components via the native reCAPTCHA Javascript API,
# which requires that the recaptcha response param be obtained and passed explicitly. # which requires that the recaptcha response param be obtained and passed explicitly.
# #
# After this newer GraphQL/JS API process is fully supported by the backend, we can remove this # It can also be expanded to multiple fields when we move to future alternative captcha
# (and other) HAML-specific support. # implementations such as FriendlyCaptcha. See https://gitlab.com/gitlab-org/gitlab/-/issues/273480
params['g-recaptcha-response']
# After this newer GraphQL/JS API process is fully supported by the backend, we can remove the
# check for the 'g-recaptcha-response' field and other HTML/HAML form-specific support.
captcha_response = params['g-recaptcha-response']
{
request: request,
spam_log_id: params[:spam_log_id],
captcha_response: captcha_response
}
end end
def spammable def spammable
......
# frozen_string_literal: true
module Captcha
##
# Encapsulates logic of checking captchas.
#
class CaptchaVerificationService
include Recaptcha::Verify
##
# Performs verification of a captcha response.
#
# 'captcha_response' parameter is the response from the user solving a client-side captcha.
#
# 'request' parameter is the request which submitted the captcha.
#
# NOTE: Currently only supports reCAPTCHA, and is not yet used in all places of the app in which
# captchas are verified, but these can be addressed in future MRs. See:
# https://gitlab.com/gitlab-org/gitlab/-/issues/273480
def execute(captcha_response: nil, request:)
return false unless captcha_response
@request = request
Gitlab::Recaptcha.load_configurations!
# NOTE: We could pass the model and let the recaptcha gem automatically add errors to it,
# but we do not, for two reasons:
#
# 1. We want control over when the errors are added
# 2. We want control over the wording and i18n of the message
# 3. We want a consistent interface and behavior when adding support for other captcha
# libraries which may not support automatically adding errors to the model.
verify_recaptcha(response: captcha_response)
end
private
# The recaptcha library's Recaptcha::Verify#verify_recaptcha method requires that
# 'request' be a readable attribute - it doesn't support passing it as an options argument.
attr_reader :request
end
end
# frozen_string_literal: true
# SpamCheckMethods
#
# Provide helper methods for checking if a given spammable object has
# potential spam data.
#
# Dependencies:
# - params with :request
module SpamCheckMethods
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def filter_spam_check_params
@request = params.delete(:request)
@api = params.delete(:api)
@recaptcha_verified = params.delete(:recaptcha_verified)
@spam_log_id = params.delete(:spam_log_id)
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
# In order to be proceed to the spam check process, @spammable has to be
# a dirty instance, which means it should be already assigned with the new
# attribute values.
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def spam_check(spammable, user, action:)
raise ArgumentError.new('Please provide an action, such as :create') unless action
Spam::SpamActionService.new(
spammable: spammable,
request: @request,
user: user,
context: { action: action }
).execute(
api: @api,
recaptcha_verified: @recaptcha_verified,
spam_log_id: @spam_log_id)
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
end
...@@ -2,20 +2,26 @@ ...@@ -2,20 +2,26 @@
module Issues module Issues
class CreateService < Issues::BaseService class CreateService < Issues::BaseService
include SpamCheckMethods
include ResolveDiscussions include ResolveDiscussions
def execute(skip_system_notes: false) def execute(skip_system_notes: false)
@request = params.delete(:request)
@spam_params = Spam::SpamActionService.filter_spam_params!(params)
@issue = BuildService.new(project, current_user, params).execute @issue = BuildService.new(project, current_user, params).execute
filter_spam_check_params
filter_resolve_discussion_params filter_resolve_discussion_params
create(@issue, skip_system_notes: skip_system_notes) create(@issue, skip_system_notes: skip_system_notes)
end end
def before_create(issue) def before_create(issue)
spam_check(issue, current_user, action: :create) Spam::SpamActionService.new(
spammable: issue,
request: request,
user: current_user,
action: :create
).execute(spam_params: spam_params)
# current_user (defined in BaseService) is not available within run_after_commit block # current_user (defined in BaseService) is not available within run_after_commit block
user = current_user user = current_user
...@@ -46,8 +52,10 @@ module Issues ...@@ -46,8 +52,10 @@ module Issues
private private
attr_reader :request, :spam_params
def user_agent_detail_service def user_agent_detail_service
UserAgentDetailService.new(@issue, @request) UserAgentDetailService.new(@issue, request)
end end
# Applies label "incident" (creates it if missing) to incident issues. # Applies label "incident" (creates it if missing) to incident issues.
......
...@@ -2,12 +2,14 @@ ...@@ -2,12 +2,14 @@
module Issues module Issues
class UpdateService < Issues::BaseService class UpdateService < Issues::BaseService
include SpamCheckMethods
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
def execute(issue) def execute(issue)
handle_move_between_ids(issue) handle_move_between_ids(issue)
filter_spam_check_params
@request = params.delete(:request)
@spam_params = Spam::SpamActionService.filter_spam_params!(params)
change_issue_duplicate(issue) change_issue_duplicate(issue)
move_issue_to_new_project(issue) || clone_issue(issue) || update_task_event(issue) || update(issue) move_issue_to_new_project(issue) || clone_issue(issue) || update_task_event(issue) || update(issue)
end end
...@@ -30,7 +32,14 @@ module Issues ...@@ -30,7 +32,14 @@ module Issues
end end
def before_update(issue, skip_spam_check: false) def before_update(issue, skip_spam_check: false)
spam_check(issue, current_user, action: :update) unless skip_spam_check return if skip_spam_check
Spam::SpamActionService.new(
spammable: issue,
request: request,
user: current_user,
action: :update
).execute(spam_params: spam_params)
end end
def after_update(issue) def after_update(issue)
...@@ -126,6 +135,8 @@ module Issues ...@@ -126,6 +135,8 @@ module Issues
private private
attr_reader :request, :spam_params
def clone_issue(issue) def clone_issue(issue)
target_project = params.delete(:target_clone_project) target_project = params.delete(:target_clone_project)
with_notes = params.delete(:clone_with_notes) with_notes = params.delete(:clone_with_notes)
......
...@@ -2,8 +2,6 @@ ...@@ -2,8 +2,6 @@
module Snippets module Snippets
class BaseService < ::BaseService class BaseService < ::BaseService
include SpamCheckMethods
UPDATE_COMMIT_MSG = 'Update snippet' UPDATE_COMMIT_MSG = 'Update snippet'
INITIAL_COMMIT_MSG = 'Initial commit' INITIAL_COMMIT_MSG = 'Initial commit'
...@@ -18,8 +16,6 @@ module Snippets ...@@ -18,8 +16,6 @@ module Snippets
input_actions = Array(@params.delete(:snippet_actions).presence) input_actions = Array(@params.delete(:snippet_actions).presence)
@snippet_actions = SnippetInputActionCollection.new(input_actions, allowed_actions: restricted_files_actions) @snippet_actions = SnippetInputActionCollection.new(input_actions, allowed_actions: restricted_files_actions)
filter_spam_check_params
end end
private private
......
...@@ -3,20 +3,28 @@ ...@@ -3,20 +3,28 @@
module Snippets module Snippets
class CreateService < Snippets::BaseService class CreateService < Snippets::BaseService
def execute def execute
@request = params.delete(:request)
@spam_params = Spam::SpamActionService.filter_spam_params!(params)
@snippet = build_from_params @snippet = build_from_params
return invalid_params_error(@snippet) unless valid_params? return invalid_params_error(@snippet) unless valid_params?
unless visibility_allowed?(@snippet, @snippet.visibility_level) unless visibility_allowed?(snippet, snippet.visibility_level)
return forbidden_visibility_error(@snippet) return forbidden_visibility_error(snippet)
end end
@snippet.author = current_user @snippet.author = current_user
spam_check(@snippet, current_user, action: :create) Spam::SpamActionService.new(
spammable: @snippet,
request: request,
user: current_user,
action: :create
).execute(spam_params: spam_params)
if save_and_commit if save_and_commit
UserAgentDetailService.new(@snippet, @request).create UserAgentDetailService.new(@snippet, request).create
Gitlab::UsageDataCounters::SnippetCounter.count(:create) Gitlab::UsageDataCounters::SnippetCounter.count(:create)
move_temporary_files move_temporary_files
...@@ -29,6 +37,8 @@ module Snippets ...@@ -29,6 +37,8 @@ module Snippets
private private
attr_reader :snippet, :request, :spam_params
def build_from_params def build_from_params
if project if project
project.snippets.build(create_params) project.snippets.build(create_params)
......
...@@ -7,6 +7,9 @@ module Snippets ...@@ -7,6 +7,9 @@ module Snippets
UpdateError = Class.new(StandardError) UpdateError = Class.new(StandardError)
def execute(snippet) def execute(snippet)
@request = params.delete(:request)
@spam_params = Spam::SpamActionService.filter_spam_params!(params)
return invalid_params_error(snippet) unless valid_params? return invalid_params_error(snippet) unless valid_params?
if visibility_changed?(snippet) && !visibility_allowed?(snippet, visibility_level) if visibility_changed?(snippet) && !visibility_allowed?(snippet, visibility_level)
...@@ -14,7 +17,12 @@ module Snippets ...@@ -14,7 +17,12 @@ module Snippets
end end
update_snippet_attributes(snippet) update_snippet_attributes(snippet)
spam_check(snippet, current_user, action: :update) Spam::SpamActionService.new(
spammable: snippet,
request: request,
user: current_user,
action: :update
).execute(spam_params: spam_params)
if save_and_commit(snippet) if save_and_commit(snippet)
Gitlab::UsageDataCounters::SnippetCounter.count(:update) Gitlab::UsageDataCounters::SnippetCounter.count(:update)
...@@ -27,6 +35,8 @@ module Snippets ...@@ -27,6 +35,8 @@ module Snippets
private private
attr_reader :request, :spam_params
def visibility_changed?(snippet) def visibility_changed?(snippet)
visibility_level && visibility_level.to_i != snippet.visibility_level visibility_level && visibility_level.to_i != snippet.visibility_level
end end
......
...@@ -4,37 +4,69 @@ module Spam ...@@ -4,37 +4,69 @@ module Spam
class SpamActionService class SpamActionService
include SpamConstants include SpamConstants
##
# Utility method to filter SpamParams from parameters, which will later be passed to #execute
# after the spammable is created/updated based on the remaining parameters.
#
# Takes a hash of parameters from an incoming request to modify a model (via a controller,
# service, or GraphQL mutation).
#
# Deletes the parameters which are related to spam and captcha processing, and returns
# them in a SpamParams parameters object. See:
# https://refactoring.com/catalog/introduceParameterObject.html
def self.filter_spam_params!(params)
# NOTE: The 'captcha_response' field can be expanded to multiple fields when we move to future
# alternative captcha implementations such as FriendlyCaptcha. See
# https://gitlab.com/gitlab-org/gitlab/-/issues/273480
captcha_response = params.delete(:captcha_response)
SpamParams.new(
api: params.delete(:api),
captcha_response: captcha_response,
spam_log_id: params.delete(:spam_log_id)
)
end
attr_accessor :target, :request, :options attr_accessor :target, :request, :options
attr_reader :spam_log attr_reader :spam_log
def initialize(spammable:, request:, user:, context: {}) def initialize(spammable:, request:, user:, action:)
@target = spammable @target = spammable
@request = request @request = request
@user = user @user = user
@context = context @action = action
@options = {} @options = {}
end
if @request def execute(spam_params:)
@options[:ip_address] = @request.env['action_dispatch.remote_ip'].to_s if request
@options[:user_agent] = @request.env['HTTP_USER_AGENT'] options[:ip_address] = request.env['action_dispatch.remote_ip'].to_s
@options[:referrer] = @request.env['HTTP_REFERRER'] options[:user_agent] = request.env['HTTP_USER_AGENT']
options[:referrer] = request.env['HTTP_REFERRER']
else else
@options[:ip_address] = @target.ip_address # TODO: This code is never used, because we do not perform a verification if there is not a
@options[:user_agent] = @target.user_agent # request. Why? Should it be deleted? Or should we check even if there is no request?
options[:ip_address] = target.ip_address
options[:user_agent] = target.user_agent
end end
end
def execute(api: false, recaptcha_verified:, spam_log_id:) recaptcha_verified = Captcha::CaptchaVerificationService.new.execute(
captcha_response: spam_params.captcha_response,
request: request
)
if recaptcha_verified if recaptcha_verified
# If it's a request which is already verified through reCAPTCHA, # If it's a request which is already verified through captcha,
# update the spam log accordingly. # update the spam log accordingly.
SpamLog.verify_recaptcha!(user_id: user.id, id: spam_log_id) SpamLog.verify_recaptcha!(user_id: user.id, id: spam_params.spam_log_id)
ServiceResponse.success(message: "Captcha was successfully verified")
else else
return if allowlisted?(user) return ServiceResponse.success(message: 'Skipped spam check because user was allowlisted') if allowlisted?(user)
return unless request return ServiceResponse.success(message: 'Skipped spam check because request was not present') unless request
return unless check_for_spam? return ServiceResponse.success(message: 'Skipped spam check because it was not required') unless check_for_spam?
perform_spam_service_check(api) perform_spam_service_check(spam_params.api)
ServiceResponse.success(message: "Spam check performed, check #{target.class.name} spammable model for any errors or captcha requirement")
end end
end end
...@@ -42,13 +74,27 @@ module Spam ...@@ -42,13 +74,27 @@ module Spam
private private
attr_reader :user, :context attr_reader :user, :action
##
# In order to be proceed to the spam check process, the target must be
# a dirty instance, which means it should be already assigned with the new
# attribute values.
def ensure_target_is_dirty
msg = "Target instance of #{target.class.name} must be dirty (must have changes to save)"
raise(msg) unless target.has_changes_to_save?
end
def allowlisted?(user) def allowlisted?(user)
user.try(:gitlab_employee?) || user.try(:gitlab_bot?) || user.try(:gitlab_service_user?) user.try(:gitlab_employee?) || user.try(:gitlab_bot?) || user.try(:gitlab_service_user?)
end end
##
# Performs the spam check using the spam verdict service, and modifies the target model
# accordingly based on the result.
def perform_spam_service_check(api) def perform_spam_service_check(api)
ensure_target_is_dirty
# since we can check for spam, and recaptcha is not verified, # since we can check for spam, and recaptcha is not verified,
# ask the SpamVerdictService what to do with the target. # ask the SpamVerdictService what to do with the target.
spam_verdict_service.execute.tap do |result| spam_verdict_service.execute.tap do |result|
...@@ -79,7 +125,7 @@ module Spam ...@@ -79,7 +125,7 @@ module Spam
description: target.spam_description, description: target.spam_description,
source_ip: options[:ip_address], source_ip: options[:ip_address],
user_agent: options[:user_agent], user_agent: options[:user_agent],
noteable_type: notable_type, noteable_type: noteable_type,
via_api: api via_api: api
} }
) )
...@@ -88,14 +134,19 @@ module Spam ...@@ -88,14 +134,19 @@ module Spam
end end
def spam_verdict_service def spam_verdict_service
context = {
action: action,
target_type: noteable_type
}
SpamVerdictService.new(target: target, SpamVerdictService.new(target: target,
user: user, user: user,
request: @request, request: request,
options: options, options: options,
context: context.merge(target_type: notable_type)) context: context)
end end
def notable_type def noteable_type
@notable_type ||= target.class.to_s @notable_type ||= target.class.to_s
end end
end end
......
# frozen_string_literal: true
module Spam
##
# This class is a Parameter Object (https://refactoring.com/catalog/introduceParameterObject.html)
# which acts as an container abstraction for multiple parameter values related to spam and
# captcha processing for a request.
#
# Values contained are:
#
# api: A boolean flag indicating if the request was submitted via the REST or GraphQL API
# captcha_response: The response resulting from the user solving a captcha. Currently it is
# a scalar reCAPTCHA response string, but it can be expanded to an object in the future to
# support other captcha implementations such as FriendlyCaptcha.
# spam_log_id: The id of a SpamLog record.
class SpamParams
attr_reader :api, :captcha_response, :spam_log_id
def initialize(api:, captcha_response:, spam_log_id:)
@api = api.present?
@captcha_response = captcha_response
@spam_log_id = spam_log_id
end
def ==(other)
other.class == self.class &&
other.api == self.api &&
other.captcha_response == self.captcha_response &&
other.spam_log_id == self.spam_log_id
end
end
end
...@@ -121,71 +121,7 @@ module Gitlab ...@@ -121,71 +121,7 @@ module Gitlab
end end
``` ```
Now the cop doesn't complain. Here's a bad example which we could rewrite: Now the cop doesn't complain.
``` ruby
module SpamCheckService
def filter_spam_check_params
@request = params.delete(:request)
@api = params.delete(:api)
@recaptcha_verified = params.delete(:recaptcha_verified)
@spam_log_id = params.delete(:spam_log_id)
end
def spam_check(spammable, user)
spam_service = SpamService.new(spammable, @request)
spam_service.when_recaptcha_verified(@recaptcha_verified, @api) do
user.spam_logs.find_by(id: @spam_log_id)&.update!(recaptcha_verified: true)
end
end
end
```
There are several implicit dependencies here. First, `params` should be
defined before use. Second, `filter_spam_check_params` should be called
before `spam_check`. These are all implicit and the includer could be using
those instance variables without awareness.
This should be rewritten like:
``` ruby
class SpamCheckService
def initialize(request:, api:, recaptcha_verified:, spam_log_id:)
@request = request
@api = api
@recaptcha_verified = recaptcha_verified
@spam_log_id = spam_log_id
end
def spam_check(spammable, user)
spam_service = SpamService.new(spammable, @request)
spam_service.when_recaptcha_verified(@recaptcha_verified, @api) do
user.spam_logs.find_by(id: @spam_log_id)&.update!(recaptcha_verified: true)
end
end
end
```
And use it like:
``` ruby
class UpdateSnippetService < BaseService
def execute
# ...
spam = SpamCheckService.new(params.slice!(:request, :api, :recaptcha_verified, :spam_log_id))
spam.check(snippet, current_user)
# ...
end
end
```
This way, all those instance variables are isolated in `SpamCheckService`
rather than whatever includes the module, and those modules which were also
included, making it much easier to track down any issues,
and reducing the chance of having name conflicts.
## How to disable this cop ## How to disable this cop
......
...@@ -2,8 +2,10 @@ ...@@ -2,8 +2,10 @@
module Gitlab module Gitlab
module Recaptcha module Recaptcha
extend Gitlab::Utils::StrongMemoize
def self.load_configurations! def self.load_configurations!
if Gitlab::CurrentSettings.recaptcha_enabled || enabled_on_login? if enabled? || enabled_on_login?
::Recaptcha.configure do |config| ::Recaptcha.configure do |config|
config.site_key = Gitlab::CurrentSettings.recaptcha_site_key config.site_key = Gitlab::CurrentSettings.recaptcha_site_key
config.secret_key = Gitlab::CurrentSettings.recaptcha_private_key config.secret_key = Gitlab::CurrentSettings.recaptcha_private_key
......
...@@ -6,21 +6,8 @@ RSpec.describe SpammableActions do ...@@ -6,21 +6,8 @@ RSpec.describe SpammableActions do
controller(ActionController::Base) do controller(ActionController::Base) do
include SpammableActions include SpammableActions
# #create is used to test spammable_params # #update is used here to test #recaptcha_check_with_fallback, but it could be invoked
# for testing purposes # from #create or any other action which mutates a spammable via a controller.
def create
spam_params = spammable_params
# replace the actual request with a string in the JSON response, all we care is that it got set
spam_params[:request] = 'this is the request' if spam_params[:request]
# just return the params in the response so they can be verified in this fake controller spec.
# Normally, they are processed further by the controller action
render json: spam_params.to_json, status: :ok
end
# #update is used to test recaptcha_check_with_fallback
# for testing purposes
def update def update
should_redirect = params[:should_redirect] == 'true' should_redirect = params[:should_redirect] == 'true'
...@@ -35,80 +22,7 @@ RSpec.describe SpammableActions do ...@@ -35,80 +22,7 @@ RSpec.describe SpammableActions do
end end
before do before do
# Ordinarily we would not stub a method on the class under test, but :ensure_spam_config_loaded! allow(Gitlab::Recaptcha).to receive(:load_configurations!) { true }
# returns false in the test environment, and is also strong_memoized, so we need to stub it
allow(controller).to receive(:ensure_spam_config_loaded!) { true }
end
describe '#spammable_params' do
subject { post :create, format: :json, params: params }
shared_examples 'expects request param only' do
it do
subject
expect(response).to be_successful
expect(json_response).to eq({ 'request' => 'this is the request' })
end
end
shared_examples 'expects all spammable params' do
it do
subject
expect(response).to be_successful
expect(json_response['request']).to eq('this is the request')
expect(json_response['recaptcha_verified']).to eq(true)
expect(json_response['spam_log_id']).to eq('1')
end
end
let(:recaptcha_response) { nil }
let(:spam_log_id) { nil }
context 'when recaptcha response is not present' do
let(:params) do
{
spam_log_id: spam_log_id
}
end
it_behaves_like 'expects request param only'
end
context 'when recaptcha response is present' do
let(:recaptcha_response) { 'abd123' }
let(:params) do
{
'g-recaptcha-response': recaptcha_response,
spam_log_id: spam_log_id
}
end
context 'when verify_recaptcha returns falsey' do
before do
expect(controller).to receive(:verify_recaptcha).with(
{
response: recaptcha_response
}) { false }
end
it_behaves_like 'expects request param only'
end
context 'when verify_recaptcha returns truthy' do
let(:spam_log_id) { 1 }
before do
expect(controller).to receive(:verify_recaptcha).with(
{
response: recaptcha_response
}) { true }
end
it_behaves_like 'expects all spammable params'
end
end
end end
describe '#recaptcha_check_with_fallback' do describe '#recaptcha_check_with_fallback' do
......
...@@ -1314,11 +1314,13 @@ RSpec.describe Projects::IssuesController do ...@@ -1314,11 +1314,13 @@ RSpec.describe Projects::IssuesController do
let!(:last_spam_log) { spam_logs.last } let!(:last_spam_log) { spam_logs.last }
def post_verified_issue def post_verified_issue
post_new_issue({}, { spam_log_id: last_spam_log.id, 'g-recaptcha-response': true } ) post_new_issue({}, { spam_log_id: last_spam_log.id, 'g-recaptcha-response': 'abc123' } )
end end
before do before do
expect(controller).to receive_messages(verify_recaptcha: true) expect_next_instance_of(Captcha::CaptchaVerificationService) do |instance|
expect(instance).to receive(:execute) { true }
end
end end
it 'accepts an issue after reCAPTCHA is verified' do it 'accepts an issue after reCAPTCHA is verified' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Captcha::CaptchaVerificationService do
describe '#execute' do
let(:captcha_response) { nil }
let(:request) { double(:request) }
let(:service) { described_class.new }
subject { service.execute(captcha_response: captcha_response, request: request) }
context 'when there is no captcha_response' do
it 'returns false' do
expect(subject).to eq(false)
end
end
context 'when there is a captcha_response' do
let(:captcha_response) { 'abc123' }
before do
expect(Gitlab::Recaptcha).to receive(:load_configurations!)
end
it 'returns false' do
expect(service).to receive(:verify_recaptcha).with(response: captcha_response) { true }
expect(subject).to eq(true)
end
it 'has a request method which returns the request' do
subject
expect(service.send(:request)).to eq(request)
end
end
end
end
...@@ -452,162 +452,50 @@ RSpec.describe Issues::CreateService do ...@@ -452,162 +452,50 @@ RSpec.describe Issues::CreateService do
end end
context 'checking spam' do context 'checking spam' do
include_context 'includes Spam constants' let(:request) { double(:request) }
let(:api) { true }
let(:captcha_response) { 'abc123' }
let(:spam_log_id) { 1 }
let(:title) { 'Legit issue' } let(:params) do
let(:description) { 'please fix' }
let(:opts) do
{ {
title: title, title: 'Spam issue',
description: description, request: request,
request: double(:request, env: {}) api: api,
captcha_response: captcha_response,
spam_log_id: spam_log_id
} }
end end
subject { described_class.new(project, user, opts) } subject do
described_class.new(project, user, params)
before do
stub_feature_flags(allow_possible_spam: false)
end end
context 'when reCAPTCHA was verified' do before do
let(:log_user) { user } allow_next_instance_of(UserAgentDetailService) do |instance|
let(:spam_logs) { create_list(:spam_log, 2, user: log_user, title: title) } allow(instance).to receive(:create)
let(:target_spam_log) { spam_logs.last }
before do
opts[:recaptcha_verified] = true
opts[:spam_log_id] = target_spam_log.id
expect(Spam::SpamVerdictService).not_to receive(:new)
end
it 'does not mark an issue as spam' do
expect(issue).not_to be_spam
end
it 'creates a valid issue' do
expect(issue).to be_valid
end
it 'does not assign a spam_log to the issue' do
expect(issue.spam_log).to be_nil
end
it 'marks related spam_log as recaptcha_verified' do
expect { issue }.to change { target_spam_log.reload.recaptcha_verified }.from(false).to(true)
end
context 'when spam log does not belong to a user' do
let(:log_user) { create(:user) }
it 'does not mark spam_log as recaptcha_verified' do
expect { issue }.not_to change { target_spam_log.reload.recaptcha_verified }
end
end end
end end
context 'when reCAPTCHA was not verified' do it 'executes SpamActionService' do
before do spam_params = Spam::SpamParams.new(
expect_next_instance_of(Spam::SpamActionService) do |spam_service| api: api,
expect(spam_service).to receive_messages(check_for_spam?: true) captcha_response: captcha_response,
end spam_log_id: spam_log_id
end )
expect_next_instance_of(
context 'when SpamVerdictService requires reCAPTCHA' do Spam::SpamActionService,
before do {
expect_next_instance_of(Spam::SpamVerdictService) do |verdict_service| spammable: an_instance_of(Issue),
expect(verdict_service).to receive(:execute).and_return(CONDITIONAL_ALLOW) request: request,
end user: user,
end action: :create
}
it 'does not mark the issue as spam' do ) do |instance|
expect(issue).not_to be_spam expect(instance).to receive(:execute).with(spam_params: spam_params)
end
it 'marks the issue as needing reCAPTCHA' do
expect(issue.needs_recaptcha?).to be_truthy
end
it 'invalidates the issue' do
expect(issue).to be_invalid
end
it 'creates a new spam_log' do
expect { issue }
.to have_spam_log(title: title, description: description, user_id: user.id, noteable_type: 'Issue')
end
end
context 'when SpamVerdictService disallows creation' do
before do
expect_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
expect(verdict_service).to receive(:execute).and_return(DISALLOW)
end
end
context 'when allow_possible_spam feature flag is false' do
it 'marks the issue as spam' do
expect(issue).to be_spam
end
it 'does not mark the issue as needing reCAPTCHA' do
expect(issue.needs_recaptcha?).to be_falsey
end
it 'invalidates the issue' do
expect(issue).to be_invalid
end
it 'creates a new spam_log' do
expect { issue }
.to have_spam_log(title: title, description: description, user_id: user.id, noteable_type: 'Issue')
end
end
context 'when allow_possible_spam feature flag is true' do
before do
stub_feature_flags(allow_possible_spam: true)
end
it 'does not mark the issue as spam' do
expect(issue).not_to be_spam
end
it 'does not mark the issue as needing reCAPTCHA' do
expect(issue.needs_recaptcha?).to be_falsey
end
it 'creates a valid issue' do
expect(issue).to be_valid
end
it 'creates a new spam_log' do
expect { issue }
.to have_spam_log(title: title, description: description, user_id: user.id, noteable_type: 'Issue')
end
end
end end
context 'when the SpamVerdictService allows creation' do subject.execute
before do
expect_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
expect(verdict_service).to receive(:execute).and_return(ALLOW)
end
end
it 'does not mark an issue as spam' do
expect(issue).not_to be_spam
end
it 'creates a valid issue' do
expect(issue).to be_valid
end
it 'does not assign a spam_log to an issue' do
expect(issue.spam_log).to be_nil
end
end
end end
end end
end end
......
...@@ -711,7 +711,7 @@ RSpec.describe Issues::UpdateService, :mailer do ...@@ -711,7 +711,7 @@ RSpec.describe Issues::UpdateService, :mailer do
} }
service = described_class.new(project, user, params) service = described_class.new(project, user, params)
expect(service).not_to receive(:spam_check) expect(Spam::SpamActionService).not_to receive(:new)
service.execute(issue) service.execute(issue)
end end
......
...@@ -6,6 +6,7 @@ RSpec.describe Snippets::CreateService do ...@@ -6,6 +6,7 @@ RSpec.describe Snippets::CreateService do
describe '#execute' do describe '#execute' do
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:admin) { create(:user, :admin) } let_it_be(:admin) { create(:user, :admin) }
let(:action) { :create }
let(:opts) { base_opts.merge(extra_opts) } let(:opts) { base_opts.merge(extra_opts) }
let(:base_opts) do let(:base_opts) do
{ {
...@@ -309,7 +310,7 @@ RSpec.describe Snippets::CreateService do ...@@ -309,7 +310,7 @@ RSpec.describe Snippets::CreateService do
it_behaves_like 'a service that creates a snippet' it_behaves_like 'a service that creates a snippet'
it_behaves_like 'public visibility level restrictions apply' it_behaves_like 'public visibility level restrictions apply'
it_behaves_like 'snippets spam check is performed' it_behaves_like 'checking spam'
it_behaves_like 'snippet create data is tracked' it_behaves_like 'snippet create data is tracked'
it_behaves_like 'an error service response when save fails' it_behaves_like 'an error service response when save fails'
it_behaves_like 'creates repository and files' it_behaves_like 'creates repository and files'
...@@ -337,7 +338,7 @@ RSpec.describe Snippets::CreateService do ...@@ -337,7 +338,7 @@ RSpec.describe Snippets::CreateService do
it_behaves_like 'a service that creates a snippet' it_behaves_like 'a service that creates a snippet'
it_behaves_like 'public visibility level restrictions apply' it_behaves_like 'public visibility level restrictions apply'
it_behaves_like 'snippets spam check is performed' it_behaves_like 'checking spam'
it_behaves_like 'snippet create data is tracked' it_behaves_like 'snippet create data is tracked'
it_behaves_like 'an error service response when save fails' it_behaves_like 'an error service response when save fails'
it_behaves_like 'creates repository and files' it_behaves_like 'creates repository and files'
......
...@@ -6,6 +6,7 @@ RSpec.describe Snippets::UpdateService do ...@@ -6,6 +6,7 @@ RSpec.describe Snippets::UpdateService do
describe '#execute', :aggregate_failures do describe '#execute', :aggregate_failures do
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:admin) { create :user, admin: true } let_it_be(:admin) { create :user, admin: true }
let(:action) { :update }
let(:visibility_level) { Gitlab::VisibilityLevel::PRIVATE } let(:visibility_level) { Gitlab::VisibilityLevel::PRIVATE }
let(:base_opts) do let(:base_opts) do
{ {
...@@ -738,11 +739,7 @@ RSpec.describe Snippets::UpdateService do ...@@ -738,11 +739,7 @@ RSpec.describe Snippets::UpdateService do
it_behaves_like 'only file_name is present' it_behaves_like 'only file_name is present'
it_behaves_like 'only content is present' it_behaves_like 'only content is present'
it_behaves_like 'invalid params error response' it_behaves_like 'invalid params error response'
it_behaves_like 'snippets spam check is performed' do it_behaves_like 'checking spam'
before do
subject
end
end
context 'when snippet does not have a repository' do context 'when snippet does not have a repository' do
let!(:snippet) { create(:project_snippet, author: user, project: project) } let!(:snippet) { create(:project_snippet, author: user, project: project) }
...@@ -766,11 +763,7 @@ RSpec.describe Snippets::UpdateService do ...@@ -766,11 +763,7 @@ RSpec.describe Snippets::UpdateService do
it_behaves_like 'only file_name is present' it_behaves_like 'only file_name is present'
it_behaves_like 'only content is present' it_behaves_like 'only content is present'
it_behaves_like 'invalid params error response' it_behaves_like 'invalid params error response'
it_behaves_like 'snippets spam check is performed' do it_behaves_like 'checking spam'
before do
subject
end
end
context 'when snippet does not have a repository' do context 'when snippet does not have a repository' do
let!(:snippet) { create(:personal_snippet, author: user, project: project) } let!(:snippet) { create(:personal_snippet, author: user, project: project) }
......
...@@ -24,41 +24,16 @@ RSpec.describe Spam::SpamActionService do ...@@ -24,41 +24,16 @@ RSpec.describe Spam::SpamActionService do
issue.spam = false issue.spam = false
end end
describe '#initialize' do
subject { described_class.new(spammable: issue, request: request, user: user) }
context 'when the request is nil' do
let(:request) { nil }
it 'assembles the options with information from the spammable' do
aggregate_failures do
expect(subject.options[:ip_address]).to eq(issue.ip_address)
expect(subject.options[:user_agent]).to eq(issue.user_agent)
expect(subject.options.key?(:referrer)).to be_falsey
end
end
end
context 'when the request is present' do
let(:request) { double(:request, env: env) }
it 'assembles the options with information from the spammable' do
aggregate_failures do
expect(subject.options[:ip_address]).to eq(fake_ip)
expect(subject.options[:user_agent]).to eq(fake_user_agent)
expect(subject.options[:referrer]).to eq(fake_referrer)
end
end
end
end
shared_examples 'only checks for spam if a request is provided' do shared_examples 'only checks for spam if a request is provided' do
context 'when request is missing' do context 'when request is missing' do
subject { described_class.new(spammable: issue, request: nil, user: user) } let(:request) { nil }
it "doesn't check as spam" do it "doesn't check as spam" do
subject expect(fake_verdict_service).not_to receive(:execute)
response = subject
expect(response.message).to match(/request was not present/)
expect(issue).not_to be_spam expect(issue).not_to be_spam
end end
end end
...@@ -66,34 +41,88 @@ RSpec.describe Spam::SpamActionService do ...@@ -66,34 +41,88 @@ RSpec.describe Spam::SpamActionService do
context 'when request exists' do context 'when request exists' do
it 'creates a spam log' do it 'creates a spam log' do
expect { subject } expect { subject }
.to log_spam(title: issue.title, description: issue.description, noteable_type: 'Issue') .to log_spam(title: issue.title, description: issue.description, noteable_type: 'Issue')
end end
end end
end end
shared_examples 'creates a spam log' do
it do
expect { subject }.to change { SpamLog.count }.by(1)
new_spam_log = SpamLog.last
expect(new_spam_log.user_id).to eq(user.id)
expect(new_spam_log.title).to eq(issue.title)
expect(new_spam_log.description).to eq(issue.description)
expect(new_spam_log.source_ip).to eq(fake_ip)
expect(new_spam_log.user_agent).to eq(fake_user_agent)
expect(new_spam_log.noteable_type).to eq('Issue')
expect(new_spam_log.via_api).to eq(false)
end
end
describe '#execute' do describe '#execute' do
let(:request) { double(:request, env: env) } let(:request) { double(:request, env: env) }
let(:fake_captcha_verification_service) { double(:captcha_verification_service) }
let(:fake_verdict_service) { double(:spam_verdict_service) } let(:fake_verdict_service) { double(:spam_verdict_service) }
let(:allowlisted) { false } let(:allowlisted) { false }
let(:api) { nil }
let(:captcha_response) { 'abc123' }
let(:spam_log_id) { existing_spam_log.id }
let(:spam_params) do
Spam::SpamActionService.filter_spam_params!(
api: api,
captcha_response: captcha_response,
spam_log_id: spam_log_id
)
end
let(:verdict_service_opts) do
{
ip_address: fake_ip,
user_agent: fake_user_agent,
referrer: fake_referrer
}
end
let(:verdict_service_args) do
{
target: issue,
user: user,
request: request,
options: verdict_service_opts,
context: {
action: :create,
target_type: 'Issue'
}
}
end
let_it_be(:existing_spam_log) { create(:spam_log, user: user, recaptcha_verified: false) } let_it_be(:existing_spam_log) { create(:spam_log, user: user, recaptcha_verified: false) }
subject do subject do
described_service = described_class.new(spammable: issue, request: request, user: user) described_service = described_class.new(spammable: issue, request: request, user: user, action: :create)
allow(described_service).to receive(:allowlisted?).and_return(allowlisted) allow(described_service).to receive(:allowlisted?).and_return(allowlisted)
described_service.execute(api: nil, recaptcha_verified: recaptcha_verified, spam_log_id: existing_spam_log.id) described_service.execute(spam_params: spam_params)
end end
before do before do
allow(Spam::SpamVerdictService).to receive(:new).and_return(fake_verdict_service) allow(Captcha::CaptchaVerificationService).to receive(:new) { fake_captcha_verification_service }
allow(Spam::SpamVerdictService).to receive(:new).with(verdict_service_args).and_return(fake_verdict_service)
end end
context 'when reCAPTCHA was already verified' do context 'when captcha response verification returns true' do
let(:recaptcha_verified) { true } before do
expect(fake_captcha_verification_service)
.to receive(:execute).with(captcha_response: captcha_response, request: request) { true }
end
it "doesn't check with the SpamVerdictService" do it "doesn't check with the SpamVerdictService" do
aggregate_failures do aggregate_failures do
expect(SpamLog).to receive(:verify_recaptcha!) expect(SpamLog).to receive(:verify_recaptcha!).with(
user_id: user.id,
id: spam_log_id
)
expect(fake_verdict_service).not_to receive(:execute) expect(fake_verdict_service).not_to receive(:execute)
end end
...@@ -105,8 +134,11 @@ RSpec.describe Spam::SpamActionService do ...@@ -105,8 +134,11 @@ RSpec.describe Spam::SpamActionService do
end end
end end
context 'when reCAPTCHA was not verified' do context 'when captcha response verification returns false' do
let(:recaptcha_verified) { false } before do
expect(fake_captcha_verification_service)
.to receive(:execute).with(captcha_response: captcha_response, request: request) { false }
end
context 'when spammable attributes have not changed' do context 'when spammable attributes have not changed' do
before do before do
...@@ -120,6 +152,10 @@ RSpec.describe Spam::SpamActionService do ...@@ -120,6 +152,10 @@ RSpec.describe Spam::SpamActionService do
end end
context 'when spammable attributes have changed' do context 'when spammable attributes have changed' do
let(:expected_service_check_response_message) do
/check Issue spammable model for any errors or captcha requirement/
end
before do before do
issue.description = 'SPAM!' issue.description = 'SPAM!'
end end
...@@ -130,7 +166,9 @@ RSpec.describe Spam::SpamActionService do ...@@ -130,7 +166,9 @@ RSpec.describe Spam::SpamActionService do
it 'does not perform spam check' do it 'does not perform spam check' do
expect(Spam::SpamVerdictService).not_to receive(:new) expect(Spam::SpamVerdictService).not_to receive(:new)
subject response = subject
expect(response.message).to match(/user was allowlisted/)
end end
end end
...@@ -147,8 +185,9 @@ RSpec.describe Spam::SpamActionService do ...@@ -147,8 +185,9 @@ RSpec.describe Spam::SpamActionService do
it_behaves_like 'only checks for spam if a request is provided' it_behaves_like 'only checks for spam if a request is provided'
it 'marks as spam' do it 'marks as spam' do
subject response = subject
expect(response.message).to match(expected_service_check_response_message)
expect(issue).to be_spam expect(issue).to be_spam
end end
end end
...@@ -157,8 +196,9 @@ RSpec.describe Spam::SpamActionService do ...@@ -157,8 +196,9 @@ RSpec.describe Spam::SpamActionService do
it_behaves_like 'only checks for spam if a request is provided' it_behaves_like 'only checks for spam if a request is provided'
it 'does not mark as spam' do it 'does not mark as spam' do
subject response = subject
expect(response.message).to match(expected_service_check_response_message)
expect(issue).not_to be_spam expect(issue).not_to be_spam
end end
end end
...@@ -176,15 +216,19 @@ RSpec.describe Spam::SpamActionService do ...@@ -176,15 +216,19 @@ RSpec.describe Spam::SpamActionService do
it_behaves_like 'only checks for spam if a request is provided' it_behaves_like 'only checks for spam if a request is provided'
it_behaves_like 'creates a spam log'
it 'does not mark as spam' do it 'does not mark as spam' do
subject response = subject
expect(response.message).to match(expected_service_check_response_message)
expect(issue).not_to be_spam expect(issue).not_to be_spam
end end
it 'marks as needing reCAPTCHA' do it 'marks as needing reCAPTCHA' do
subject response = subject
expect(response.message).to match(expected_service_check_response_message)
expect(issue.needs_recaptcha?).to be_truthy expect(issue.needs_recaptcha?).to be_truthy
end end
end end
...@@ -192,9 +236,12 @@ RSpec.describe Spam::SpamActionService do ...@@ -192,9 +236,12 @@ RSpec.describe Spam::SpamActionService do
context 'when allow_possible_spam feature flag is true' do context 'when allow_possible_spam feature flag is true' do
it_behaves_like 'only checks for spam if a request is provided' it_behaves_like 'only checks for spam if a request is provided'
it_behaves_like 'creates a spam log'
it 'does not mark as needing reCAPTCHA' do it 'does not mark as needing reCAPTCHA' do
subject response = subject
expect(response.message).to match(expected_service_check_response_message)
expect(issue.needs_recaptcha).to be_falsey expect(issue.needs_recaptcha).to be_falsey
end end
end end
...@@ -209,6 +256,51 @@ RSpec.describe Spam::SpamActionService do ...@@ -209,6 +256,51 @@ RSpec.describe Spam::SpamActionService do
expect { subject } expect { subject }
.not_to change { SpamLog.count } .not_to change { SpamLog.count }
end end
it 'clears spam flags' do
expect(issue).to receive(:clear_spam_flags!)
subject
end
end
context 'spam verdict service options' do
before do
allow(fake_verdict_service).to receive(:execute) { ALLOW }
end
context 'when the request is nil' do
let(:request) { nil }
let(:issue_ip_address) { '1.2.3.4' }
let(:issue_user_agent) { 'lynx' }
let(:verdict_service_opts) do
{
ip_address: issue_ip_address,
user_agent: issue_user_agent
}
end
before do
allow(issue).to receive(:ip_address) { issue_ip_address }
allow(issue).to receive(:user_agent) { issue_user_agent }
end
it 'assembles the options with information from the spammable' do
# TODO: This code untestable, because we do not perform a verification if there is not a
# request. See corresponding comment in code
# expect(Spam::SpamVerdictService).to receive(:new).with(verdict_service_args)
subject
end
end
context 'when the request is present' do
it 'assembles the options with information from the request' do
expect(Spam::SpamVerdictService).to receive(:new).with(verdict_service_args)
subject
end
end
end end
end end
end end
......
...@@ -21,10 +21,10 @@ RSpec.shared_examples 'can raise spam flag' do ...@@ -21,10 +21,10 @@ RSpec.shared_examples 'can raise spam flag' do
context 'when the snippet is detected as spam' do context 'when the snippet is detected as spam' do
it 'raises spam flag' do it 'raises spam flag' do
allow_next_instance_of(service) do |instance| allow_next_instance_of(Spam::SpamActionService) do |instance|
allow(instance).to receive(:spam_check) do |snippet, user, _| allow(instance).to receive(:execute) { true }
snippet.spam! instance.target.spam!
end instance.target.unrecoverable_spam_error!
end end
subject subject
......
# frozen_string_literal: true # frozen_string_literal: true
RSpec.shared_examples 'snippets spam check is performed' do RSpec.shared_examples 'checking spam' do
shared_examples 'marked as spam' do let(:request) { double(:request) }
it 'marks a snippet as spam' do let(:api) { true }
expect(snippet).to be_spam let(:captcha_response) { 'abc123' }
end let(:spam_log_id) { 1 }
it 'invalidates the snippet' do
expect(snippet).to be_invalid
end
it 'creates a new spam_log' do
expect { snippet }
.to have_spam_log(title: snippet.title, noteable_type: snippet.class.name)
end
it 'assigns a spam_log to an issue' do
expect(snippet.spam_log).to eq(SpamLog.last)
end
end
let(:extra_opts) do let(:extra_opts) do
{ visibility_level: Gitlab::VisibilityLevel::PUBLIC, request: double(:request, env: {}) } {
request: request,
api: api,
captcha_response: captcha_response,
spam_log_id: spam_log_id
}
end end
before do before do
expect_next_instance_of(Spam::AkismetService) do |akismet_service| allow_next_instance_of(UserAgentDetailService) do |instance|
expect(akismet_service).to receive_messages(spam?: true) allow(instance).to receive(:create)
end end
end end
[true, false, nil].each do |allow_possible_spam| it 'executes SpamActionService' do
context "when allow_possible_spam flag is #{allow_possible_spam.inspect}" do spam_params = Spam::SpamParams.new(
before do api: api,
stub_feature_flags(allow_possible_spam: allow_possible_spam) unless allow_possible_spam.nil? captcha_response: captcha_response,
end spam_log_id: spam_log_id
)
it_behaves_like 'marked as spam' expect_next_instance_of(
end Spam::SpamActionService,
{
spammable: kind_of(Snippet),
request: request,
user: an_instance_of(User),
action: action
}
) do |instance|
expect(instance).to receive(:execute).with(spam_params: spam_params)
end
subject
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment