Skip to content
Snippets Groups Projects
Commit fe887433 authored by Alexis Kalderimis's avatar Alexis Kalderimis :speech_balloon:
Browse files

Create new spam protection concern for mutations

parent ce5c05c3
No related branches found
No related tags found
No related merge requests found
Showing
with 200 additions and 248 deletions
Loading
Loading
@@ -4,6 +4,7 @@ import { ApolloLink } from 'apollo-link';
import { BatchHttpLink } from 'apollo-link-batch-http';
import { createHttpLink } from 'apollo-link-http';
import { createUploadLink } from 'apollo-upload-client';
import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
import csrf from '~/lib/utils/csrf';
import PerformanceBarService from '~/performance_bar/services/performance_bar_service';
Loading
Loading
@@ -78,6 +79,7 @@ export default (resolvers = {}, config = {}) => {
requestCounterLink,
performanceBarLink,
new StartupJSLink(),
apolloCaptchaLink,
uploadsLink,
]),
cache: new InMemoryCache({
Loading
Loading
Loading
Loading
@@ -33,7 +33,6 @@ export default {
SnippetBlobActionsEdit,
TitleField,
FormFooterActions,
CaptchaModal: () => import('~/captcha/captcha_modal.vue'),
GlButton,
GlLoadingIcon,
},
Loading
Loading
@@ -68,10 +67,6 @@ export default {
description: '',
visibilityLevel: this.selectedLevel,
},
captchaResponse: '',
needsCaptchaResponse: false,
captchaSiteKey: '',
spamLogId: '',
};
},
computed: {
Loading
Loading
@@ -103,8 +98,6 @@ export default {
description: this.snippet.description,
visibilityLevel: this.snippet.visibilityLevel,
blobActions: this.actions,
...(this.spamLogId && { spamLogId: this.spamLogId }),
...(this.captchaResponse && { captchaResponse: this.captchaResponse }),
};
},
saveButtonLabel() {
Loading
Loading
@@ -171,20 +164,14 @@ export default {
},
handleFormSubmit() {
this.isUpdating = true;
this.$apollo
.mutate(this.newSnippet ? this.createMutation() : this.updateMutation())
.then(({ data }) => {
const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet;
 
if (baseObj.needsCaptchaResponse) {
// If we need a captcha response, start process for receiving captcha response.
// We will resubmit after the response is obtained.
this.requestCaptchaResponse(baseObj.captchaSiteKey, baseObj.spamLogId);
return;
}
const errors = baseObj?.errors;
if (errors.length) {
if (errors?.length) {
this.flashAPIFailure(errors[0]);
} else {
redirectTo(baseObj.snippet.webUrl);
Loading
Loading
@@ -200,38 +187,6 @@ export default {
updateActions(actions) {
this.actions = actions;
},
/**
* Start process for getting captcha response from user
*
* @param captchaSiteKey Stored in data and used to display the captcha.
* @param spamLogId Stored in data and included when the form is re-submitted.
*/
requestCaptchaResponse(captchaSiteKey, spamLogId) {
this.captchaSiteKey = captchaSiteKey;
this.spamLogId = spamLogId;
this.needsCaptchaResponse = true;
},
/**
* Handle the captcha response from the user
*
* @param captchaResponse The captchaResponse value emitted from the modal.
*/
receivedCaptchaResponse(captchaResponse) {
this.needsCaptchaResponse = false;
this.captchaResponse = captchaResponse;
if (this.captchaResponse) {
// If the user solved the captcha, resubmit the form.
// NOTE: we do not need to clear out the captchaResponse and spamLogId
// data values after submit, because this component always does a full page reload.
// Otherwise, we would need to.
this.handleFormSubmit();
} else {
// If the user didn't solve the captcha (e.g. they just closed the modal),
// finish the update and allow them to continue editing or manually resubmit the form.
this.isUpdating = false;
}
},
},
};
</script>
Loading
Loading
@@ -249,11 +204,6 @@ export default {
class="loading-animation prepend-top-20 gl-mb-6"
/>
<template v-else>
<captcha-modal
:captcha-site-key="captchaSiteKey"
:needs-captcha-response="needsCaptchaResponse"
@receivedCaptchaResponse="receivedCaptchaResponse"
/>
<title-field
id="snippet-title"
v-model="snippet.title"
Loading
Loading
Loading
Loading
@@ -4,7 +4,5 @@ mutation CreateSnippet($input: CreateSnippetInput!) {
snippet {
webUrl
}
needsCaptchaResponse
captchaSiteKey
}
}
Loading
Loading
@@ -4,8 +4,5 @@ mutation UpdateSnippet($input: UpdateSnippetInput!) {
snippet {
webUrl
}
needsCaptchaResponse
captchaSiteKey
spamLogId
}
}
# frozen_string_literal: true
 
module Mutations
# This concern can be mixed into a mutation to provide support for spam checking,
# and optionally support the workflow to allow clients to display and solve CAPTCHAs.
# This concern is deprecated and will be deleted in 14.6
#
# Use the SpamProtection concern instead.
module CanMutateSpammable
extend ActiveSupport::Concern
include Spam::Concerns::HasSpamActionResponseFields
 
# NOTE: The arguments and fields are intentionally named with 'captcha' instead of 'recaptcha',
# so that they can be applied to future alternative CAPTCHA implementations other than
# reCAPTCHA (e.g. FriendlyCaptcha) without having to change the names and descriptions in the API.
DEPRECATION_NOTICE = {
reason: 'Use spam protection with HTTP headers instead',
milestone: '13.11'
}.freeze
included do
argument :captcha_response, GraphQL::STRING_TYPE,
required: false,
deprecated: DEPRECATION_NOTICE,
description: 'A valid CAPTCHA response value obtained by using the provided captchaSiteKey with a CAPTCHA API to present a challenge to be solved on the client. Required to resubmit if the previous operation returned "NeedsCaptchaResponse: true".'
 
argument :spam_log_id, GraphQL::INT_TYPE,
required: false,
deprecated: DEPRECATION_NOTICE,
description: 'The spam log ID which must be passed along with a valid CAPTCHA response for the operation to be completed. Required to resubmit if the previous operation returned "NeedsCaptchaResponse: true".'
 
field :spam,
GraphQL::BOOLEAN_TYPE,
null: true,
deprecated: DEPRECATION_NOTICE,
description: 'Indicates whether the operation was detected as definite spam. There is no option to resubmit the request with a CAPTCHA response.'
 
field :needs_captcha_response,
GraphQL::BOOLEAN_TYPE,
null: true,
deprecated: DEPRECATION_NOTICE,
description: 'Indicates whether the operation was detected as possible spam and not completed. If CAPTCHA is enabled, the request must be resubmitted with a valid CAPTCHA response and spam_log_id included for the operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true.'
 
field :spam_log_id,
GraphQL::INT_TYPE,
null: true,
deprecated: DEPRECATION_NOTICE,
description: 'The spam log ID which must be passed along with a valid CAPTCHA response for an operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true.'
 
field :captcha_site_key,
GraphQL::STRING_TYPE,
null: true,
deprecated: DEPRECATION_NOTICE,
description: 'The CAPTCHA site key which must be used to render a challenge for the user to solve to obtain a valid captchaResponse value. Included only when an operation was not completed because "NeedsCaptchaResponse" is true.'
end
private
# additional_spam_params -> hash
#
# Used from a spammable mutation's #resolve method to generate
# the required additional spam/recaptcha params which must be merged into the params
# passed to the constructor of a service, where they can then be used in the service
# to perform spam checking via SpamActionService.
#
# Also accesses the #context of the mutation's Resolver superclass to obtain the request.
#
# Example:
#
# existing_args.merge!(additional_spam_params)
def additional_spam_params
{
api: true,
request: context[:request]
}
end
end
end
# frozen_string_literal: true
module Mutations
# This concern can be mixed into a mutation to provide support for spam checking,
# and optionally support the workflow to allow clients to display and solve CAPTCHAs.
module SpamProtection
extend ActiveSupport::Concern
include Spam::Concerns::HasSpamActionResponseFields
SpamActionError = Class.new(GraphQL::ExecutionError)
NeedsCaptchaResponseError = Class.new(SpamActionError)
SpamDisallowedError = Class.new(SpamActionError)
NEEDS_CAPTCHA_RESPONSE_MESSAGE = "Request denied. Solve CAPTCHA challenge and retry"
SPAM_DISALLOWED_MESSAGE = "Request denied. Spam detected"
private
# additional_spam_params -> hash
#
# Used from a spammable mutation's #resolve method to generate
# the required additional spam/CAPTCHA params which must be merged into the params
# passed to the constructor of a service, where they can then be used in the service
# to perform spam checking via SpamActionService.
#
# Also accesses the #context of the mutation's Resolver superclass to obtain the request.
#
# Example:
#
# existing_args.merge!(additional_spam_params)
def additional_spam_params
{
api: true,
request: context[:request]
}
end
def spam_action_response(object)
fields = spam_action_response_fields(object)
# If the SpamActionService detected something as spam,
# this is non-recoverable and the needs_captcha_response
# should not be considered
kind = if fields[:spam]
:spam
elsif fields[:needs_captcha_response]
:needs_captcha_response
end
[kind, fields]
end
def check_spam_action_response!(object)
kind, fields = spam_action_response(object)
case kind
when :needs_captcha_response
fields.delete :spam
raise NeedsCaptchaResponseError.new(NEEDS_CAPTCHA_RESPONSE_MESSAGE, extensions: fields)
when :spam
raise SpamDisallowedError.new(SPAM_DISALLOWED_MESSAGE, extensions: { spam: true })
else
nil
end
end
end
end
Loading
Loading
@@ -5,6 +5,7 @@ module Snippets
class Create < BaseMutation
include ServiceCompatibility
include CanMutateSpammable
include Mutations::SpamProtection
 
authorize :create_snippet
 
Loading
Loading
@@ -56,12 +57,12 @@ def resolve(project_path: nil, **args)
end
 
snippet = service_response.payload[:snippet]
with_spam_action_response_fields(snippet) do
{
snippet: service_response.success? ? snippet : nil,
errors: errors_on_object(snippet)
}
end
check_spam_action_response!(snippet)
{
snippet: service_response.success? ? snippet : nil,
errors: errors_on_object(snippet)
}
end
 
private
Loading
Loading
Loading
Loading
@@ -5,6 +5,7 @@ module Snippets
class Update < Base
include ServiceCompatibility
include CanMutateSpammable
include Mutations::SpamProtection
 
graphql_name 'UpdateSnippet'
 
Loading
Loading
@@ -45,12 +46,12 @@ def resolve(id:, **args)
end
 
snippet = service_response.payload[:snippet]
with_spam_action_response_fields(snippet) do
{
snippet: service_response.success? ? snippet : snippet.reset,
errors: errors_on_object(snippet)
}
end
check_spam_action_response!(snippet)
{
snippet: service_response.success? ? snippet : snippet.reset,
errors: errors_on_object(snippet)
}
end
 
private
Loading
Loading
Loading
Loading
@@ -6,7 +6,7 @@ class CreateService < Issues::BaseService
 
def execute(skip_system_notes: false)
@request = params.delete(:request)
@spam_params = Spam::SpamActionService.filter_spam_params!(params)
@spam_params = Spam::SpamActionService.filter_spam_params!(params, @request)
 
@issue = BuildService.new(project, current_user, params).execute
 
Loading
Loading
Loading
Loading
@@ -8,7 +8,7 @@ def execute(issue)
handle_move_between_ids(issue)
 
@request = params.delete(:request)
@spam_params = Spam::SpamActionService.filter_spam_params!(params)
@spam_params = Spam::SpamActionService.filter_spam_params!(params, @request)
 
change_issue_duplicate(issue)
move_issue_to_new_project(issue) || clone_issue(issue) || update_task_event(issue) || update(issue)
Loading
Loading
Loading
Loading
@@ -6,7 +6,7 @@ def execute
# NOTE: disable_spam_action_service can be removed when the ':snippet_spam' feature flag is removed.
disable_spam_action_service = params.delete(:disable_spam_action_service) == true
@request = params.delete(:request)
@spam_params = Spam::SpamActionService.filter_spam_params!(params)
@spam_params = Spam::SpamActionService.filter_spam_params!(params, @request)
 
@snippet = build_from_params
 
Loading
Loading
Loading
Loading
@@ -10,7 +10,7 @@ def execute(snippet)
# NOTE: disable_spam_action_service can be removed when the ':snippet_spam' feature flag is removed.
disable_spam_action_service = params.delete(:disable_spam_action_service) == true
@request = params.delete(:request)
@spam_params = Spam::SpamActionService.filter_spam_params!(params)
@spam_params = Spam::SpamActionService.filter_spam_params!(params, @request)
 
return invalid_params_error(snippet) unless valid_params?
 
Loading
Loading
Loading
Loading
@@ -11,22 +11,30 @@ class SpamActionService
# Takes a hash of parameters from an incoming request to modify a model (via a controller,
# service, or GraphQL mutation). The parameters will either be camelCase (if they are
# received directly via controller params) or underscore_case (if they have come from
# a GraphQL mutation which has converted them to underscore)
# a GraphQL mutation which has converted them to underscore), or in the
# headers when using the header based flow.
#
# 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)
def self.filter_spam_params!(params, request)
# 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) || params.delete(:captchaResponse)
headers = request&.headers || {}
api = params.delete(:api)
captcha_response = read_parameter(:captcha_response, params, headers)
spam_log_id = read_parameter(:spam_log_id, params, headers)&.to_i
 
SpamParams.new(
api: params.delete(:api),
captcha_response: captcha_response,
spam_log_id: params.delete(:spam_log_id) || params.delete(:spamLogId)
)
SpamParams.new(api: api, captcha_response: captcha_response, spam_log_id: spam_log_id)
end
def self.read_parameter(name, params, headers)
[
params.delete(name),
params.delete(name.to_s.camelize(:lower).to_sym),
headers["X-GitLab-#{name.to_s.titlecase(keep_id_suffix: true).tr(' ', '-')}"]
].compact.first
end
 
attr_accessor :target, :request, :options
Loading
Loading
@@ -40,6 +48,7 @@ def initialize(spammable:, request:, user:, action:)
@options = {}
end
 
# rubocop:disable Metrics/AbcSize
def execute(spam_params:)
if request
options[:ip_address] = request.env['action_dispatch.remote_ip'].to_s
Loading
Loading
@@ -58,19 +67,20 @@ def execute(spam_params:)
)
 
if recaptcha_verified
# If it's a request which is already verified through captcha,
# If it's a request which is already verified through CAPTCHA,
# update the spam log accordingly.
SpamLog.verify_recaptcha!(user_id: user.id, id: spam_params.spam_log_id)
ServiceResponse.success(message: "Captcha was successfully verified")
ServiceResponse.success(message: "CAPTCHA successfully verified")
else
return ServiceResponse.success(message: 'Skipped spam check because user was allowlisted') if allowlisted?(user)
return ServiceResponse.success(message: 'Skipped spam check because request was not present') unless request
return ServiceResponse.success(message: 'Skipped spam check because it was not required') unless check_for_spam?
 
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")
ServiceResponse.success(message: "Spam check performed. Check #{target.class.name} spammable model for any errors or CAPTCHA requirement")
end
end
# rubocop:enable Metrics/AbcSize
 
delegate :check_for_spam?, to: :target
 
Loading
Loading
Loading
Loading
@@ -23,10 +23,10 @@ def initialize(api:, captcha_response:, 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
other.class <= self.class &&
other.api == api &&
other.captcha_response == captcha_response &&
other.spam_log_id == spam_log_id
end
end
end
Loading
Loading
@@ -70,7 +70,7 @@ possible.
The GitLab GraphQL API is [versionless](https://graphql.org/learn/best-practices/#versioning) and
changes are made to the API in a way that maintains backwards-compatibility.
 
Occassionally GitLab needs to change the GraphQL API in a way that is not backwards-compatible.
Occasionally GitLab needs to change the GraphQL API in a way that is not backwards-compatible.
These changes include the removal or renaming of fields, arguments or other parts of the schema.
 
In these situations, GitLab follows a [Deprecation and removal process](#deprecation-and-removal-process)
Loading
Loading
@@ -177,6 +177,59 @@ of a query may be altered.
 
Requests time out at 30 seconds.
 
### Spam
GraphQL mutations can be detected as spam. If this happens, a
[GraphQL top-level error](https://spec.graphql.org/June2018/#sec-Errors) is raised. For example:
```json
{
"errors": [
{
"message": "Request denied. Spam detected",
"locations": [ { "line": 6, "column": 7 } ],
"path": [ "updateSnippet" ],
"extensions": {
"spam": true
}
}
],
"data": {
"updateSnippet": {
"snippet": null
}
}
}
```
If mutation is detected as potential spam and a CAPTCHA service is configured:
- The `captchaSiteKey` should be used to obtain a CAPTCHA response value using the appropriate CAPTCHA API.
Only [Google reCAPTCHA v2](https://developers.google.com/recaptcha/docs/display) is supported.
- The request can be resubmitted with the `X-GitLab-Captcha-Response` and `X-GitLab-Spam-Log-Id` headers set.
```json
{
"errors": [
{
"message": "Request denied. Solve CAPTCHA challenge and retry",
"locations": [ { "line": 6, "column": 7 } ],
"path": [ "updateSnippet" ],
"extensions": {
"needsCaptchaResponse": true,
"captchaSiteKey": "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI",
"spamLogId": 67
}
}
],
"data": {
"updateSnippet": {
"snippet": null,
}
}
}
```
## Reference
 
The GitLab GraphQL reference [is available](reference/index.md).
Loading
Loading
Loading
Loading
@@ -1824,13 +1824,13 @@ Autogenerated return type of CreateSnippet.
 
| Field | Type | Description |
| ----- | ---- | ----------- |
| `captchaSiteKey` | [`String`](#string) | The CAPTCHA site key which must be used to render a challenge for the user to solve to obtain a valid captchaResponse value. Included only when an operation was not completed because "NeedsCaptchaResponse" is true. |
| `captchaSiteKey` **{warning-solid}** | [`String`](#string) | **Deprecated** in 13.11. Use spam protection with HTTP headers instead. |
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `needsCaptchaResponse` | [`Boolean`](#boolean) | Indicates whether the operation was detected as possible spam and not completed. If CAPTCHA is enabled, the request must be resubmitted with a valid CAPTCHA response and spam_log_id included for the operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true. |
| `needsCaptchaResponse` **{warning-solid}** | [`Boolean`](#boolean) | **Deprecated** in 13.11. Use spam protection with HTTP headers instead. |
| `snippet` | [`Snippet`](#snippet) | The snippet after mutation. |
| `spam` | [`Boolean`](#boolean) | Indicates whether the operation was detected as definite spam. There is no option to resubmit the request with a CAPTCHA response. |
| `spamLogId` | [`Int`](#int) | The spam log ID which must be passed along with a valid CAPTCHA response for an operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true. |
| `spam` **{warning-solid}** | [`Boolean`](#boolean) | **Deprecated** in 13.11. Use spam protection with HTTP headers instead. |
| `spamLogId` **{warning-solid}** | [`Int`](#int) | **Deprecated** in 13.11. Use spam protection with HTTP headers instead. |
 
### `CreateTestCasePayload`
 
Loading
Loading
@@ -6622,13 +6622,13 @@ Autogenerated return type of UpdateSnippet.
 
| Field | Type | Description |
| ----- | ---- | ----------- |
| `captchaSiteKey` | [`String`](#string) | The CAPTCHA site key which must be used to render a challenge for the user to solve to obtain a valid captchaResponse value. Included only when an operation was not completed because "NeedsCaptchaResponse" is true. |
| `captchaSiteKey` **{warning-solid}** | [`String`](#string) | **Deprecated** in 13.11. Use spam protection with HTTP headers instead. |
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `needsCaptchaResponse` | [`Boolean`](#boolean) | Indicates whether the operation was detected as possible spam and not completed. If CAPTCHA is enabled, the request must be resubmitted with a valid CAPTCHA response and spam_log_id included for the operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true. |
| `needsCaptchaResponse` **{warning-solid}** | [`Boolean`](#boolean) | **Deprecated** in 13.11. Use spam protection with HTTP headers instead. |
| `snippet` | [`Snippet`](#snippet) | The snippet after mutation. |
| `spam` | [`Boolean`](#boolean) | Indicates whether the operation was detected as definite spam. There is no option to resubmit the request with a CAPTCHA response. |
| `spamLogId` | [`Int`](#int) | The spam log ID which must be passed along with a valid CAPTCHA response for an operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true. |
| `spam` **{warning-solid}** | [`Boolean`](#boolean) | **Deprecated** in 13.11. Use spam protection with HTTP headers instead. |
| `spamLogId` **{warning-solid}** | [`Int`](#int) | **Deprecated** in 13.11. Use spam protection with HTTP headers instead. |
 
### `UsageTrendsMeasurement`
 
Loading
Loading
Loading
Loading
@@ -23,15 +23,6 @@ def spam_action_response_fields(spammable)
captcha_site_key: Gitlab::CurrentSettings.recaptcha_site_key
}
end
# with_spam_action_response_fields(spammable) { {other_fields: true} } -> hash
#
# Takes a Spammable and a block as arguments.
#
# The block passed should be a hash, which the spam_action_fields will be merged into.
def with_spam_action_response_fields(spammable)
yield.merge(spam_action_response_fields(spammable))
end
end
end
end
Loading
Loading
@@ -44,7 +44,7 @@ describe('apolloCaptchaLink', () => {
},
errors: [
{
message: 'Your Query was detected to be SPAM.',
message: 'Your Query was detected to be spam.',
path: ['user'],
locations: [{ line: 2, column: 3 }],
extensions: {
Loading
Loading
@@ -116,7 +116,7 @@ describe('apolloCaptchaLink', () => {
});
});
 
it('unresolvable SPAM errors are passed through', (done) => {
it('unresolvable spam errors are passed through', (done) => {
setupLink(SPAM_ERROR_RESPONSE);
link.request(mockOperation()).subscribe((result) => {
expect(result).toEqual(SPAM_ERROR_RESPONSE);
Loading
Loading
@@ -127,8 +127,8 @@ describe('apolloCaptchaLink', () => {
});
});
 
describe('resolvable SPAM errors', () => {
it('re-submits request with SPAM headers if the captcha modal was solved correctly', (done) => {
describe('resolvable spam errors', () => {
it('re-submits request with spam headers if the captcha modal was solved correctly', (done) => {
waitForCaptchaToBeSolved.mockResolvedValue(CAPTCHA_RESPONSE);
setupLink(CAPTCHA_ERROR_RESPONSE, SUCCESS_RESPONSE);
link.request(mockOperation()).subscribe((result) => {
Loading
Loading
Loading
Loading
@@ -5,10 +5,9 @@ import { nextTick } from 'vue';
import VueApollo, { ApolloMutation } from 'vue-apollo';
import { useFakeDate } from 'helpers/fake_date';
import createMockApollo from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql';
import CaptchaModal from '~/captcha/captcha_modal.vue';
import UnsolvedCaptchaError from '~/captcha/unsolved_captcha_error';
import { deprecatedCreateFlash as Flash } from '~/flash';
import * as urlUtils from '~/lib/utils/url_utility';
import SnippetEditApp from '~/snippets/components/edit.vue';
Loading
Loading
@@ -30,9 +29,8 @@ jest.mock('~/flash');
 
const TEST_UPLOADED_FILES = ['foo/bar.txt', 'alpha/beta.js'];
const TEST_API_ERROR = new Error('TEST_API_ERROR');
const TEST_CAPTCHA_ERROR = new UnsolvedCaptchaError();
const TEST_MUTATION_ERROR = 'Test mutation error';
const TEST_CAPTCHA_RESPONSE = 'i-got-a-captcha';
const TEST_CAPTCHA_SITE_KEY = 'abc123';
const TEST_ACTIONS = {
NO_CONTENT: merge({}, testEntries.created.diff, { content: '' }),
NO_PATH: merge({}, testEntries.created.diff, { filePath: '' }),
Loading
Loading
@@ -59,9 +57,6 @@ const createMutationResponse = (key, obj = {}) => ({
__typename: 'Snippet',
webUrl: TEST_WEB_URL,
},
spamLogId: null,
needsCaptchaResponse: false,
captchaSiteKey: null,
},
obj,
),
Loading
Loading
@@ -71,13 +66,6 @@ const createMutationResponse = (key, obj = {}) => ({
const createMutationResponseWithErrors = (key) =>
createMutationResponse(key, { errors: [TEST_MUTATION_ERROR] });
 
const createMutationResponseWithRecaptcha = (key) =>
createMutationResponse(key, {
errors: ['ignored captcha error message'],
needsCaptchaResponse: true,
captchaSiteKey: TEST_CAPTCHA_SITE_KEY,
});
const getApiData = ({
id,
title = '',
Loading
Loading
@@ -126,7 +114,6 @@ describe('Snippet Edit app', () => {
});
 
const findBlobActions = () => wrapper.find(SnippetBlobActionsEdit);
const findCaptchaModal = () => wrapper.find(CaptchaModal);
const findSubmitButton = () => wrapper.find('[data-testid="snippet-submit-btn"]');
const findCancelButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]');
const hasDisabledSubmit = () => Boolean(findSubmitButton().attributes('disabled'));
Loading
Loading
@@ -159,7 +146,6 @@ describe('Snippet Edit app', () => {
stubs: {
ApolloMutation,
FormFooterActions,
CaptchaModal: stubComponent(CaptchaModal),
},
provide: {
selectedLevel,
Loading
Loading
@@ -209,7 +195,6 @@ describe('Snippet Edit app', () => {
});
 
it('should render components', () => {
expect(wrapper.find(CaptchaModal).exists()).toBe(true);
expect(wrapper.find(TitleField).exists()).toBe(true);
expect(wrapper.find(SnippetDescriptionEdit).exists()).toBe(true);
expect(wrapper.find(SnippetVisibilityEdit).exists()).toBe(true);
Loading
Loading
@@ -338,10 +323,10 @@ describe('Snippet Edit app', () => {
},
);
 
describe('with apollo network error', () => {
describe.each([TEST_API_ERROR, TEST_CAPTCHA_ERROR])('with apollo network error', (error) => {
beforeEach(async () => {
jest.spyOn(console, 'error').mockImplementation();
mutateSpy.mockRejectedValue(TEST_API_ERROR);
mutateSpy.mockRejectedValue(error);
 
await createComponentAndSubmit();
});
Loading
Loading
@@ -353,7 +338,7 @@ describe('Snippet Edit app', () => {
it('should flash', () => {
// Apollo automatically wraps the resolver's error in a NetworkError
expect(Flash).toHaveBeenCalledWith(
`Can't update snippet: Network error: ${TEST_API_ERROR.message}`,
`Can't update snippet: Network error: ${error.message}`,
);
});
 
Loading
Loading
@@ -363,54 +348,10 @@ describe('Snippet Edit app', () => {
// eslint-disable-next-line no-console
expect(console.error).toHaveBeenCalledWith(
'[gitlab] unexpected error while updating snippet',
expect.objectContaining({ message: `Network error: ${TEST_API_ERROR.message}` }),
expect.objectContaining({ message: `Network error: ${error.message}` }),
);
});
});
describe('when needsCaptchaResponse is true', () => {
let modal;
beforeEach(async () => {
mutateSpy
.mockResolvedValueOnce(createMutationResponseWithRecaptcha('updateSnippet'))
.mockResolvedValueOnce(createMutationResponseWithErrors('updateSnippet'));
await createComponentAndSubmit();
modal = findCaptchaModal();
mutateSpy.mockClear();
});
it('should display captcha modal', () => {
expect(urlUtils.redirectTo).not.toHaveBeenCalled();
expect(modal.props()).toEqual({
needsCaptchaResponse: true,
captchaSiteKey: TEST_CAPTCHA_SITE_KEY,
});
});
describe.each`
response | expectedCalls
${null} | ${[]}
${TEST_CAPTCHA_RESPONSE} | ${[['updateSnippet', { input: { ...getApiData(createSnippet()), captchaResponse: TEST_CAPTCHA_RESPONSE } }]]}
`('when captcha response is $response', ({ response, expectedCalls }) => {
beforeEach(async () => {
modal.vm.$emit('receivedCaptchaResponse', response);
await nextTick();
});
it('sets needsCaptchaResponse to false', () => {
expect(modal.props('needsCaptchaResponse')).toEqual(false);
});
it(`expected to call times = ${expectedCalls.length}`, () => {
expect(mutateSpy.mock.calls).toEqual(expectedCalls);
});
});
});
});
});
 
Loading
Loading
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::CanMutateSpammable do
let(:mutation_class) do
Class.new(Mutations::BaseMutation) do
include Mutations::CanMutateSpammable
end
end
let(:request) { double(:request) }
let(:query) { double(:query, schema: GitlabSchema) }
let(:context) { GraphQL::Query::Context.new(query: query, object: nil, values: { request: request }) }
subject(:mutation) { mutation_class.new(object: nil, context: context, field: nil) }
describe '#additional_spam_params' do
it 'returns additional spam-related params' do
expect(subject.send(:additional_spam_params)).to eq({ api: true, request: request })
end
end
describe '#with_spam_action_fields' do
let(:spam_log) { double(:spam_log, id: 1) }
let(:spammable) { double(:spammable, spam?: true, render_recaptcha?: true, spam_log: spam_log) }
before do
allow(Gitlab::CurrentSettings).to receive(:recaptcha_site_key) { 'abc123' }
end
it 'merges in spam action fields from spammable' do
result = subject.send(:with_spam_action_response_fields, spammable) do
{ other_field: true }
end
expect(result)
.to eq({
spam: true,
needs_captcha_response: true,
spam_log_id: 1,
captcha_site_key: 'abc123',
other_field: true
})
end
end
end
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment