Commit dc9fb467 authored by Chad Woolley's avatar Chad Woolley

Add CAPTCHA to REST API

- Add new support class to be consistent with
  spam/CAPTCHA controller support classes
- Update all relevant API endpoints to use the
  new support class
- Make necessary updates to API helper error methods
- Add some docs for REST API, and make some
  corresponding updates to GraphQL docs

Changelog: added
MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80793
parent 3235302b
# frozen_string_literal: true
# This module should be included to support CAPTCHA check for REST API actions via Grape.
#
# If the request is directly handled by a controller action, then the corresponding module which
# supports HTML or JSON formats should be used instead.
module SpammableActions::CaptchaCheck::RestApiActionsSupport
extend ActiveSupport::Concern
include SpammableActions::CaptchaCheck::Common
include Spam::Concerns::HasSpamActionResponseFields
private
def with_captcha_check_rest_api(spammable:, &block)
# In the case of the REST API, the request is handled by Grape, so if there is a spam-related
# error, we don't render directly, instead we will pass the error message and other necessary
# fields to the Grape api error helper for it to handle.
captcha_render_lambda = -> do
fields = spam_action_response_fields(spammable)
fields.delete :spam
# NOTE: "409 - Conflict" seems to be the most appropriate HTTP status code for a response
# which requires a CAPTCHA to be solved in order for the request to be resubmitted.
# https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.10
status = 409
# NOTE: This nested 'error' key may not be consistent with all other API error responses,
# because they are not currently consistent across different API endpoints
# and models. Some (snippets) will nest errors in an errors key like this,
# while others (issues) will return the model's errors hash without an errors key,
# while still others just return a plain string error.
# See https://gitlab.com/groups/gitlab-org/-/epics/5527#revisit-inconsistent-shape-of-error-responses-in-rest-api
fields[:message] = { error: spammable.errors.full_messages.to_sentence }
render_structured_api_error!(fields, status)
end
with_captcha_check_common(spammable: spammable, captcha_render_lambda: captcha_render_lambda, &block)
end
end
...@@ -25,6 +25,7 @@ module Spam ...@@ -25,6 +25,7 @@ module Spam
# then the spam check may fail, or the SpamLog or UserAgentDetail may have missing fields. # then the spam check may fail, or the SpamLog or UserAgentDetail may have missing fields.
class SpamParams class SpamParams
def self.new_from_request(request:) def self.new_from_request(request:)
self.normalize_grape_request_headers(request: request)
self.new( self.new(
captcha_response: request.headers['X-GitLab-Captcha-Response'], captcha_response: request.headers['X-GitLab-Captcha-Response'],
spam_log_id: request.headers['X-GitLab-Spam-Log-Id'], spam_log_id: request.headers['X-GitLab-Spam-Log-Id'],
...@@ -52,5 +53,14 @@ module Spam ...@@ -52,5 +53,14 @@ module Spam
other.user_agent == user_agent && other.user_agent == user_agent &&
other.referer == referer other.referer == referer
end end
def self.normalize_grape_request_headers(request:)
# If needed, make a normalized copy of Grape headers with the case of 'GitLab' (with an
# uppercase 'L') instead of 'Gitlab' (with a lowercase 'l'), because Grape header helper keys
# are "coerced into a capitalized kebab case". See https://github.com/ruby-grape/grape#request
%w[X-Gitlab-Captcha-Response X-Gitlab-Spam-Log-Id].each do |header|
request.headers[header.gsub('Gitlab', 'GitLab')] = request.headers[header] if request.headers.key?(header)
end
end
end end
end end
...@@ -187,13 +187,17 @@ NOTE: ...@@ -187,13 +187,17 @@ NOTE:
The complexity limits may be revised in future, and additionally, the complexity The complexity limits may be revised in future, and additionally, the complexity
of a query may be altered. of a query may be altered.
## Spam ## Resolve mutations detected as spam
GraphQL mutations can be detected as spam. If this happens, a > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/327360) in GitLab 13.11.
[GraphQL top-level error](https://spec.graphql.org/June2018/#sec-Errors) is raised. For example:
```json GraphQL mutations can be detected as spam. If a mutation is detected as spam and:
{
- A CAPTCHA service is not configured, a
[GraphQL top-level error](https://spec.graphql.org/June2018/#sec-Errors) is raised. For example:
```json
{
"errors": [ "errors": [
{ {
"message": "Request denied. Spam detected", "message": "Request denied. Spam detected",
...@@ -209,17 +213,17 @@ GraphQL mutations can be detected as spam. If this happens, a ...@@ -209,17 +213,17 @@ GraphQL mutations can be detected as spam. If this happens, a
"snippet": null "snippet": null
} }
} }
} }
``` ```
If a mutation is detected as potential spam and a CAPTCHA service is configured: - A CAPTCHA service is configured, you receive a response with:
- `needsCaptchaResponse` set to `true`.
- The `spamLogId` and `captchaSiteKey` fields set.
- Use the `captchaSiteKey` to obtain a CAPTCHA response value using the appropriate CAPTCHA API. For example:
Only [Google reCAPTCHA v2](https://developers.google.com/recaptcha/docs/display) is supported.
- Resubmit the request with the `X-GitLab-Captcha-Response` and `X-GitLab-Spam-Log-Id` headers set.
```json ```json
{ {
"errors": [ "errors": [
{ {
"message": "Request denied. Solve CAPTCHA challenge and retry", "message": "Request denied. Solve CAPTCHA challenge and retry",
...@@ -237,5 +241,20 @@ If a mutation is detected as potential spam and a CAPTCHA service is configured: ...@@ -237,5 +241,20 @@ If a mutation is detected as potential spam and a CAPTCHA service is configured:
"snippet": null, "snippet": null,
} }
} }
} }
```
- Use the `captchaSiteKey` to obtain a CAPTCHA response value using the appropriate CAPTCHA API.
Only [Google reCAPTCHA v2](https://developers.google.com/recaptcha/docs/display) is supported.
- Resubmit the request with the `X-GitLab-Captcha-Response` and `X-GitLab-Spam-Log-Id` headers set.
NOTE:
The GitLab GraphiQL implementation doesn't permit passing of headers, so we must write
this as a cURL query. `--data-binary` is used to properly handle escaped double quotes
in the JSON-embedded query.
```shell
export CAPTCHA_RESPONSE="<CAPTCHA response obtained from CAPTCHA service>"
export SPAM_LOG_ID="<spam_log_id obtained from initial REST response>"
curl --header "Authorization: Bearer $PRIVATE_TOKEN" --header "Content-Type: application/json" --header "X-GitLab-Captcha-Response: $CAPTCHA_RESPONSE" --header "X-GitLab-Spam-Log-Id: $SPAM_LOG_ID" --request POST --data-binary '{"query": "mutation {createSnippet(input: {title: \"Title\" visibilityLevel: public blobActions: [ { action: create filePath: \"BlobPath\" content: \"BlobContent\" } ] }) { snippet { id title } errors }}"}' "https://gitlab.example.com/api/graphql"
``` ```
...@@ -767,3 +767,35 @@ some API endpoints also support `text/plain`. ...@@ -767,3 +767,35 @@ some API endpoints also support `text/plain`.
In [GitLab 13.10 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/250342), In [GitLab 13.10 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/250342),
API endpoints do not support `text/plain` by default, unless it's explicitly documented. API endpoints do not support `text/plain` by default, unless it's explicitly documented.
## Resolve requests detected as spam
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/352913) in GitLab 14.9.
REST API requests can be detected as spam. If a request is detected as spam and:
- A CAPTCHA service is not configured, an error response is returned. For example:
```json
{"message":{"error":"Your snippet has been recognized as spam and has been discarded."}}
```
- A CAPTCHA service is configured, you receive a response with:
- `needs_captcha_response` set to `true`.
- The `spam_log_id` and `captcha_site_key` fields set.
For example:
```json
{"needs_captcha_response":true,"spam_log_id":42,"captcha_site_key":"6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI","message":{"error":"Your snippet has been recognized as spam. Please, change the content or solve the reCAPTCHA to proceed."}}
```
- Use the `captcha_site_key` to obtain a CAPTCHA response value using the appropriate CAPTCHA API.
Only [Google reCAPTCHA v2](https://developers.google.com/recaptcha/docs/display) is supported.
- Resubmit the request with the `X-GitLab-Captcha-Response` and `X-GitLab-Spam-Log-Id` headers set.
```shell
export CAPTCHA_RESPONSE="<CAPTCHA response obtained from CAPTCHA service>"
export SPAM_LOG_ID="<spam_log_id obtained from initial REST response>"
curl --request POST --header "PRIVATE-TOKEN: $PRIVATE_TOKEN" --header "X-GitLab-Captcha-Response: $CAPTCHA_RESPONSE" --header "X-GitLab-Spam-Log-Id: $SPAM_LOG_ID" "https://gitlab.example.com/api/v4/snippets?title=Title&file_name=FileName&content=Content&visibility=public"
```
...@@ -474,17 +474,22 @@ module API ...@@ -474,17 +474,22 @@ module API
model.errors.messages model.errors.messages
end end
def render_spam_error! def render_api_error!(message, status)
render_api_error!({ error: 'Spam detected' }, 400) render_structured_api_error!({ 'message' => message }, status)
end end
def render_api_error!(message, status) def render_structured_api_error!(hash, status)
# Use this method instead of `render_api_error!` when you have additional top-level
# hash entries in addition to 'message' which need to be passed to `#error!`
set_status_code_in_env(status)
error!(hash, status, header)
end
def set_status_code_in_env(status)
# grape-logging doesn't pass the status code, so this is a # grape-logging doesn't pass the status code, so this is a
# workaround for getting that information in the loggers: # workaround for getting that information in the loggers:
# https://github.com/aserafin/grape_logging/issues/71 # https://github.com/aserafin/grape_logging/issues/71
env[API_RESPONSE_STATUS_CODE] = Rack::Utils.status_code(status) env[API_RESPONSE_STATUS_CODE] = Rack::Utils.status_code(status)
error!({ 'message' => message }, status, header)
end end
def handle_api_exception(exception) def handle_api_exception(exception)
......
...@@ -4,6 +4,7 @@ module API ...@@ -4,6 +4,7 @@ module API
class Issues < ::API::Base class Issues < ::API::Base
include PaginationParams include PaginationParams
helpers Helpers::IssuesHelpers helpers Helpers::IssuesHelpers
helpers SpammableActions::CaptchaCheck::RestApiActionsSupport
before { authenticate_non_get! } before { authenticate_non_get! }
...@@ -275,15 +276,13 @@ module API ...@@ -275,15 +276,13 @@ module API
params: issue_params, params: issue_params,
spam_params: spam_params).execute spam_params: spam_params).execute
if issue.spam?
render_api_error!({ error: 'Spam detected' }, 400)
end
if issue.valid? if issue.valid?
present issue, with: Entities::Issue, current_user: current_user, project: user_project present issue, with: Entities::Issue, current_user: current_user, project: user_project
else else
with_captcha_check_rest_api(spammable: issue) do
render_validation_error!(issue) render_validation_error!(issue)
end end
end
rescue ::ActiveRecord::RecordNotUnique rescue ::ActiveRecord::RecordNotUnique
render_api_error!('Duplicated issue', 409) render_api_error!('Duplicated issue', 409)
end end
...@@ -320,14 +319,14 @@ module API ...@@ -320,14 +319,14 @@ module API
params: update_params, params: update_params,
spam_params: spam_params).execute(issue) spam_params: spam_params).execute(issue)
render_spam_error! if issue.spam?
if issue.valid? if issue.valid?
present issue, with: Entities::Issue, current_user: current_user, project: user_project present issue, with: Entities::Issue, current_user: current_user, project: user_project
else else
with_captcha_check_rest_api(spammable: issue) do
render_validation_error!(issue) render_validation_error!(issue)
end end
end end
end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
desc 'Reorder an existing issue' do desc 'Reorder an existing issue' do
......
...@@ -13,6 +13,7 @@ module API ...@@ -13,6 +13,7 @@ module API
end end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
helpers Helpers::SnippetsHelpers helpers Helpers::SnippetsHelpers
helpers SpammableActions::CaptchaCheck::RestApiActionsSupport
helpers do helpers do
def check_snippets_enabled def check_snippets_enabled
forbidden! unless user_project.feature_available?(:snippets, current_user) forbidden! unless user_project.feature_available?(:snippets, current_user)
...@@ -82,11 +83,11 @@ module API ...@@ -82,11 +83,11 @@ module API
if service_response.success? if service_response.success?
present snippet, with: Entities::ProjectSnippet, current_user: current_user present snippet, with: Entities::ProjectSnippet, current_user: current_user
else else
render_spam_error! if snippet.spam? with_captcha_check_rest_api(spammable: snippet) do
render_api_error!({ error: service_response.message }, service_response.http_status) render_api_error!({ error: service_response.message }, service_response.http_status)
end end
end end
end
desc 'Update an existing project snippet' do desc 'Update an existing project snippet' do
success Entities::ProjectSnippet success Entities::ProjectSnippet
...@@ -124,11 +125,11 @@ module API ...@@ -124,11 +125,11 @@ module API
if service_response.success? if service_response.success?
present snippet, with: Entities::ProjectSnippet, current_user: current_user present snippet, with: Entities::ProjectSnippet, current_user: current_user
else else
render_spam_error! if snippet.spam? with_captcha_check_rest_api(spammable: snippet) do
render_api_error!({ error: service_response.message }, service_response.http_status) render_api_error!({ error: service_response.message }, service_response.http_status)
end end
end end
end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
desc 'Delete a project snippet' desc 'Delete a project snippet'
......
...@@ -9,6 +9,7 @@ module API ...@@ -9,6 +9,7 @@ module API
resource :snippets do resource :snippets do
helpers Helpers::SnippetsHelpers helpers Helpers::SnippetsHelpers
helpers SpammableActions::CaptchaCheck::RestApiActionsSupport
helpers do helpers do
def snippets_for_current_user def snippets_for_current_user
SnippetsFinder.new(current_user, author: current_user).execute SnippetsFinder.new(current_user, author: current_user).execute
...@@ -91,11 +92,11 @@ module API ...@@ -91,11 +92,11 @@ module API
if service_response.success? if service_response.success?
present snippet, with: Entities::PersonalSnippet, current_user: current_user present snippet, with: Entities::PersonalSnippet, current_user: current_user
else else
render_spam_error! if snippet.spam? with_captcha_check_rest_api(spammable: snippet) do
render_api_error!({ error: service_response.message }, service_response.http_status) render_api_error!({ error: service_response.message }, service_response.http_status)
end end
end end
end
desc 'Update an existing snippet' do desc 'Update an existing snippet' do
detail 'This feature was introduced in GitLab 8.15.' detail 'This feature was introduced in GitLab 8.15.'
...@@ -135,11 +136,11 @@ module API ...@@ -135,11 +136,11 @@ module API
if service_response.success? if service_response.success?
present snippet, with: Entities::PersonalSnippet, current_user: current_user present snippet, with: Entities::PersonalSnippet, current_user: current_user
else else
render_spam_error! if snippet.spam? with_captcha_check_rest_api(spammable: snippet) do
render_api_error!({ error: service_response.message }, service_response.http_status) render_api_error!({ error: service_response.message }, service_response.http_status)
end end
end end
end
desc 'Remove snippet' do desc 'Remove snippet' do
detail 'This feature was introduced in GitLab 8.15.' detail 'This feature was introduced in GitLab 8.15.'
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe SpammableActions::CaptchaCheck::RestApiActionsSupport do
include Rack::Test::Methods
subject do
Class.new(Grape::API) do
helpers API::Helpers
helpers SpammableActions::CaptchaCheck::RestApiActionsSupport
get ':id' do
# NOTE: This was the only way that seemed to work to inject the mock spammable into the
# Grape rack app instance. If there's a better way, improvements are welcome.
spammable = Object.fake_spammable_factory
with_captcha_check_rest_api(spammable: spammable) do
render_api_error!(spammable.errors, 400)
end
end
end
end
def app
subject
end
before do
allow(Gitlab::Recaptcha).to receive(:load_configurations!) { true }
end
describe '#with_captcha_check_json_format' do
let(:spammable) { instance_double(Snippet) }
before do
expect(spammable).to receive(:render_recaptcha?).at_least(:once) { render_recaptcha }
allow(Object).to receive(:fake_spammable_factory) { spammable }
end
context 'when spammable.render_recaptcha? is true' do
let(:render_recaptcha) { true }
let(:spam_log) { instance_double(SpamLog, id: 1) }
let(:spammable) { instance_double(Snippet, spam?: true, render_recaptcha?: render_recaptcha, spam_log: spam_log) }
let(:recaptcha_site_key) { 'abc123' }
let(:err_msg) { 'You gotta solve the CAPTCHA' }
let(:spam_action_response_fields) do
{
spam: true,
needs_captcha_response: render_recaptcha,
spam_log_id: 1,
captcha_site_key: recaptcha_site_key
}
end
it 'renders json containing spam_action_response_fields' do
allow(spammable).to receive_message_chain('errors.full_messages.to_sentence') { err_msg }
allow(Gitlab::CurrentSettings).to receive(:recaptcha_site_key) { recaptcha_site_key }
response = get '/test'
expected_response = {
'needs_captcha_response' => render_recaptcha,
'spam_log_id' => 1,
'captcha_site_key' => recaptcha_site_key,
'message' => { 'error' => err_msg }
}
expect(Gitlab::Json.parse(response.body)).to eq(expected_response)
expect(response.status).to eq(409)
end
end
context 'when spammable.render_recaptcha? is false' do
let(:render_recaptcha) { false }
let(:errors) { { 'base' => "It's definitely spam" } }
it 'yields to block' do
allow(spammable).to receive(:errors) { errors }
response = get 'test'
expected_response = {
'message' => errors
}
expect(Gitlab::Json.parse(response.body)).to eq(expected_response)
expect(response.status).to eq(400)
end
end
end
end
...@@ -1018,6 +1018,7 @@ RSpec.describe Projects::IssuesController do ...@@ -1018,6 +1018,7 @@ RSpec.describe Projects::IssuesController do
end end
it 'returns 200 status' do it 'returns 200 status' do
update_verified_issue
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
end end
......
...@@ -447,7 +447,7 @@ RSpec.describe API::Issues do ...@@ -447,7 +447,7 @@ RSpec.describe API::Issues do
post_issue post_issue
expect(response).to have_gitlab_http_status(:bad_request) expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq({ 'error' => 'Spam detected' }) expect(json_response['message']['base']).to match_array([/issue has been recognized as spam/])
end end
it 'creates a new spam log entry' do it 'creates a new spam log entry' do
......
...@@ -217,7 +217,7 @@ RSpec.describe API::Issues do ...@@ -217,7 +217,7 @@ RSpec.describe API::Issues do
update_issue update_issue
expect(response).to have_gitlab_http_status(:bad_request) expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response).to include('message' => { 'error' => 'Spam detected' }) expect(json_response['message']['base']).to match_array([/issue has been recognized as spam/])
end end
it 'creates a new spam log entry' do it 'creates a new spam log entry' do
......
...@@ -276,7 +276,7 @@ RSpec.describe API::ProjectSnippets do ...@@ -276,7 +276,7 @@ RSpec.describe API::ProjectSnippets do
it 'rejects the snippet' do it 'rejects the snippet' do
expect { subject }.not_to change { Snippet.count } expect { subject }.not_to change { Snippet.count }
expect(response).to have_gitlab_http_status(:bad_request) expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq({ "error" => "Spam detected" }) expect(json_response['message']['error']).to match(/snippet has been recognized as spam/)
end end
it 'creates a spam log' do it 'creates a spam log' do
...@@ -344,7 +344,7 @@ RSpec.describe API::ProjectSnippets do ...@@ -344,7 +344,7 @@ RSpec.describe API::ProjectSnippets do
.not_to change { snippet.reload.title } .not_to change { snippet.reload.title }
expect(response).to have_gitlab_http_status(:bad_request) expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq({ "error" => "Spam detected" }) expect(json_response['message']['error']).to match(/snippet has been recognized as spam/)
end end
it 'creates a spam log' do it 'creates a spam log' do
......
...@@ -325,7 +325,7 @@ RSpec.describe API::Snippets, factory_default: :keep do ...@@ -325,7 +325,7 @@ RSpec.describe API::Snippets, factory_default: :keep do
expect { subject }.not_to change { Snippet.count } expect { subject }.not_to change { Snippet.count }
expect(response).to have_gitlab_http_status(:bad_request) expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq({ "error" => "Spam detected" }) expect(json_response['message']['error']).to match(/snippet has been recognized as spam/)
end end
it 'creates a spam log' do it 'creates a spam log' do
...@@ -392,7 +392,7 @@ RSpec.describe API::Snippets, factory_default: :keep do ...@@ -392,7 +392,7 @@ RSpec.describe API::Snippets, factory_default: :keep do
.not_to change { snippet.reload.title } .not_to change { snippet.reload.title }
expect(response).to have_gitlab_http_status(:bad_request) expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq({ "error" => "Spam detected" }) expect(json_response['message']['error']).to match(/snippet has been recognized as spam/)
end end
it 'creates a spam log' do it 'creates a spam log' do
......
...@@ -3,12 +3,37 @@ ...@@ -3,12 +3,37 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Spam::SpamParams do RSpec.describe Spam::SpamParams do
shared_examples 'constructs from a request' do
it 'constructs from a request' do
expected = ::Spam::SpamParams.new(
captcha_response: captcha_response,
spam_log_id: spam_log_id,
ip_address: ip_address,
user_agent: user_agent,
referer: referer
)
expect(described_class.new_from_request(request: request)).to eq(expected)
end
end
describe '.new_from_request' do describe '.new_from_request' do
let(:captcha_response) { 'abc123' } let(:captcha_response) { 'abc123' }
let(:spam_log_id) { 42 } let(:spam_log_id) { 42 }
let(:ip_address) { '0.0.0.0' } let(:ip_address) { '0.0.0.0' }
let(:user_agent) { 'Lynx' } let(:user_agent) { 'Lynx' }
let(:referer) { 'http://localhost' } let(:referer) { 'http://localhost' }
let(:env) do
{
'action_dispatch.remote_ip' => ip_address,
'HTTP_USER_AGENT' => user_agent,
'HTTP_REFERER' => referer
}
end
let(:request) { double(:request, headers: headers, env: env) }
context 'with a normal Rails request' do
let(:headers) do let(:headers) do
{ {
'X-GitLab-Captcha-Response' => captcha_response, 'X-GitLab-Captcha-Response' => captcha_response,
...@@ -16,25 +41,18 @@ RSpec.describe Spam::SpamParams do ...@@ -16,25 +41,18 @@ RSpec.describe Spam::SpamParams do
} }
end end
let(:env) do it_behaves_like 'constructs from a request'
end
context 'with a grape request' do
let(:headers) do
{ {
'action_dispatch.remote_ip' => ip_address, 'X-Gitlab-Captcha-Response' => captcha_response,
'HTTP_USER_AGENT' => user_agent, 'X-Gitlab-Spam-Log-Id' => spam_log_id
'HTTP_REFERER' => referer
} }
end end
let(:request) {double(:request, headers: headers, env: env)} it_behaves_like 'constructs from a request'
it 'constructs from a request' do
expected = ::Spam::SpamParams.new(
captcha_response: captcha_response,
spam_log_id: spam_log_id,
ip_address: ip_address,
user_agent: user_agent,
referer: referer
)
expect(described_class.new_from_request(request: request)).to eq(expected)
end end
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