Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-03-16 00:07:57 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-03-16 00:07:57 +0300
commit512177dcc7369c6c3f4ae54cc8a0abcb73828f71 (patch)
tree3ceb4aba221a235c570f6f7e5398b88a381e3ead
parent7f08e6916d8259a8ed1549cb54460f0b746d9d8b (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--app/assets/javascripts/lib/utils/array_utility.js10
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue13
-rw-r--r--app/assets/javascripts/releases/components/asset_links_form.vue12
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js2
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/getters.js14
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js6
-rw-r--r--app/assets/javascripts/security_configuration/components/training_provider_list.vue11
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml9
-rw-r--r--app/views/discussions/_discussion.html.haml3
-rw-r--r--app/workers/all_queues.yml2
-rw-r--r--app/workers/ci/drop_pipeline_worker.rb2
-rw-r--r--data/deprecations/14-9-deprecate-composer-download-permissions.yml11
-rw-r--r--data/removals/14_9/removal_monitor_respond_integrated_error_tracking.yml14
-rw-r--r--doc/ci/jobs/ci_job_token.md6
-rw-r--r--doc/development/index.md1
-rw-r--r--doc/development/spam_protection_and_captcha/exploratory_testing.md360
-rw-r--r--doc/development/spam_protection_and_captcha/graphql_api.md66
-rw-r--r--doc/development/spam_protection_and_captcha/index.md53
-rw-r--r--doc/development/spam_protection_and_captcha/model_and_services.md155
-rw-r--r--doc/development/spam_protection_and_captcha/web_ui.md196
-rw-r--r--doc/update/deprecations.md14
-rw-r--r--doc/update/removals.md14
-rw-r--r--doc/user/application_security/policies/scan-execution-policies.md10
-rw-r--r--doc/user/packages/package_registry/index.md6
-rw-r--r--locale/gitlab.pot25
-rw-r--r--qa/Gemfile.lock17
-rw-r--r--qa/qa/runtime/logger.rb33
-rw-r--r--qa/spec/page/logging_spec.rb19
-rw-r--r--qa/spec/runtime/logger_spec.rb30
-rw-r--r--qa/spec/support/repeater_spec.rb6
-rw-r--r--spec/features/admin/admin_broadcast_messages_spec.rb12
-rw-r--r--spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb8
-rw-r--r--spec/frontend/lib/utils/array_utility_spec.js13
-rw-r--r--spec/frontend/notes/components/note_header_spec.js20
-rw-r--r--spec/frontend/releases/components/asset_links_form_spec.js4
-rw-r--r--spec/frontend/releases/stores/modules/detail/getters_spec.js23
-rw-r--r--spec/frontend/security_configuration/components/training_provider_list_spec.js8
-rw-r--r--spec/frontend/security_configuration/mock_data.js4
39 files changed, 1086 insertions, 128 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index 200d09d4331..fec330717c9 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-4ef97df05e54269d90fdbd4d2f59fcc29b1afcdf
+8b033b1763e80468b4f11a572e1425d1064692e2
diff --git a/app/assets/javascripts/lib/utils/array_utility.js b/app/assets/javascripts/lib/utils/array_utility.js
index 197e7790ed7..04f9cb1cdb5 100644
--- a/app/assets/javascripts/lib/utils/array_utility.js
+++ b/app/assets/javascripts/lib/utils/array_utility.js
@@ -18,3 +18,13 @@ export const swapArrayItems = (array, leftIndex = 0, rightIndex = 0) => {
copy[rightIndex] = temp;
return copy;
};
+
+/**
+ * Return an array with all duplicate items from the given array
+ *
+ * @param {Array} array - The source array
+ * @returns {Array} new array with all duplicate items
+ */
+export const getDuplicateItemsFromArray = (array) => [
+ ...new Set(array.filter((value, index) => array.indexOf(value) !== index)),
+];
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 0925195d4bb..71d767c3b95 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -6,6 +6,7 @@ import {
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import { mapActions } from 'vuex';
+import { __ } from '~/locale';
import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserNameWithStatus from '../../sidebar/components/assignees/user_name_with_status.vue';
@@ -139,6 +140,10 @@ export default {
return selectedAuthor?.availability || '';
},
},
+ i18n: {
+ showThread: __('Show thread'),
+ hideThread: __('Hide thread'),
+ },
};
</script>
@@ -148,10 +153,16 @@ export default {
<button
class="note-action-button discussion-toggle-button js-vue-toggle-button"
type="button"
+ data-testid="thread-toggle"
@click="handleToggle"
>
<gl-icon ref="chevronIcon" :name="toggleChevronIconName" />
- {{ __('Toggle thread') }}
+ <template v-if="expanded">
+ {{ $options.i18n.hideThread }}
+ </template>
+ <template v-else>
+ {{ $options.i18n.showThread }}
+ </template>
</button>
</div>
<template v-if="hasAuthor">
diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue
index b9601428850..b81da399a7b 100644
--- a/app/assets/javascripts/releases/components/asset_links_form.vue
+++ b/app/assets/javascripts/releases/components/asset_links_form.vue
@@ -56,6 +56,9 @@ export default {
hasDuplicateUrl(link) {
return Boolean(this.getLinkErrors(link).isDuplicate);
},
+ hasDuplicateName(link) {
+ return Boolean(this.getLinkErrors(link).isTitleDuplicate);
+ },
hasBadFormat(link) {
return Boolean(this.getLinkErrors(link).isBadFormat);
},
@@ -72,7 +75,7 @@ export default {
return !this.hasDuplicateUrl(link) && !this.hasBadFormat(link) && !this.hasEmptyUrl(link);
},
isNameValid(link) {
- return !this.hasEmptyName(link);
+ return !this.hasEmptyName(link) && !this.hasDuplicateName(link);
},
/**
@@ -121,7 +124,7 @@ export default {
<p>
{{
__(
- 'Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance. Duplicate URLs are not allowed.',
+ 'Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance. Each URL and link title must be unique.',
)
}}
</p>
@@ -165,7 +168,7 @@ export default {
</gl-sprintf>
</span>
<span v-else-if="hasDuplicateUrl(link)" class="invalid-feedback d-inline">
- {{ __('This URL is already used for another link; duplicate URLs are not allowed') }}
+ {{ __('This URL already exists.') }}
</span>
</template>
</gl-form-group>
@@ -191,6 +194,9 @@ export default {
<span v-if="hasEmptyName(link)" class="invalid-feedback d-inline">
{{ __('Link title is required') }}
</span>
+ <span v-else-if="hasDuplicateName(link)" class="invalid-feedback d-inline">
+ {{ __('This title already exists.') }}
+ </span>
</template>
</gl-form-group>
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
index 576f099248e..b3ba4f9263a 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -162,7 +162,7 @@ const createReleaseLink = async ({ state, link }) => {
input: {
projectPath: state.projectPath,
tagName: state.tagName,
- name: link.name,
+ name: link.name.trim(),
url: link.url,
linkType: link.linkType.toUpperCase(),
directAssetPath: link.directAssetPath,
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
index d83ec05872a..d4f49e53619 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
@@ -1,5 +1,6 @@
import { isEmpty } from 'lodash';
import { hasContent } from '~/lib/utils/text_utility';
+import { getDuplicateItemsFromArray } from '~/lib/utils/array_utility';
/**
* @returns {Boolean} `true` if the app is editing an existing release.
@@ -95,6 +96,17 @@ export const validationErrors = (state) => {
}
});
+ // check for duplicated Link Titles
+ const linkTitles = state.release.assets.links.map((link) => link.name.trim());
+ const duplicatedTitles = getDuplicateItemsFromArray(linkTitles);
+
+ // add a validation error for each link that shares Link Title
+ state.release.assets.links.forEach((link) => {
+ if (hasContent(link.name) && duplicatedTitles.includes(link.name.trim())) {
+ errors.assets.links[link.id].isTitleDuplicate = true;
+ }
+ });
+
return errors;
};
@@ -131,7 +143,7 @@ export const releaseCreateMutatationVariables = (state, getters) => {
ref: state.createFrom,
assets: {
links: getters.releaseLinksToCreate.map(({ name, url, linkType }) => ({
- name,
+ name: name.trim(),
url,
linkType: linkType.toUpperCase(),
})),
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index c498c87e72f..6a4009b222d 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -16,6 +16,8 @@ import {
REPORT_TYPE_LICENSE_COMPLIANCE,
} from '~/vue_shared/security_reports/constants';
+import kontraLogo from 'images/vulnerability/kontra-logo.svg';
+import scwLogo from 'images/vulnerability/scw-logo.svg';
import configureSastMutation from '../graphql/configure_sast.mutation.graphql';
import configureSastIacMutation from '../graphql/configure_iac.mutation.graphql';
import configureSecretDetectionMutation from '../graphql/configure_secret_detection.mutation.graphql';
@@ -284,9 +286,9 @@ export const AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY =
// https://gitlab.com/gitlab-org/gitlab/-/issues/346899
export const TEMP_PROVIDER_LOGOS = {
Kontra: {
- svg: '/assets/vulnerability/kontra-logo.svg',
+ svg: kontraLogo,
},
[__('Secure Code Warrior')]: {
- svg: '/assets/vulnerability/scw-logo.svg',
+ svg: scwLogo,
},
};
diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
index ea3eedc6e90..6f130bec15e 100644
--- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue
+++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
@@ -7,6 +7,7 @@ import {
GlLink,
GlSkeletonLoader,
GlIcon,
+ GlSafeHtmlDirective,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import Tracking from '~/tracking';
@@ -50,6 +51,7 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
+ SafeHtml: GlSafeHtmlDirective,
},
mixins: [Tracking.mixin()],
inject: ['projectFullPath'],
@@ -233,11 +235,12 @@ export default {
@change="toggleProvider(provider)"
/>
<div v-if="$options.TEMP_PROVIDER_LOGOS[provider.name]" class="gl-ml-4">
- <img
- :src="$options.TEMP_PROVIDER_LOGOS[provider.name].svg"
- width="18"
+ <div
+ v-safe-html="$options.TEMP_PROVIDER_LOGOS[provider.name].svg"
+ data-testid="provider-logo"
+ style="width: 18px"
role="presentation"
- />
+ ></div>
</div>
<div class="gl-ml-3">
<h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ provider.name }}</h3>
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
index d81ebb8a2bb..3e698f0508c 100644
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ b/app/views/admin/broadcast_messages/_form.html.haml
@@ -16,7 +16,7 @@
- else
= _('Your message here')
-= form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f|
+= gitlab_ui_form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f|
= form_errors(@broadcast_message)
.form-group.row.mt-4
@@ -52,15 +52,14 @@
.col-sm-2.col-form-label.pt-0
= f.label :starts_at, _("Dismissable")
.col-sm-10
- = f.check_box :dismissable
- = f.label :dismissable do
- = _('Allow users to dismiss the broadcast message')
+ = f.gitlab_ui_checkbox_component :dismissable, _('Allow users to dismiss the broadcast message')
- if Feature.enabled?(:role_targeted_broadcast_messages, default_enabled: :yaml)
.form-group.row
.col-sm-2.col-form-label
= f.label :target_access_levels, _('Target roles')
.col-sm-10
- = f.select :target_access_levels, target_access_level_options, { include_hidden: false }, multiple: true, class: 'form-control'
+ - target_access_level_options.each do |human_access_level, access_level|
+ = f.gitlab_ui_checkbox_component :target_access_levels, human_access_level, checked_value: access_level, unchecked_value: false, checkbox_options: { multiple: true }
.form-text.text-muted
= _('The broadcast message displays only to users in projects and groups who have these roles.')
.form-group.row.js-toggle-colors-container.toggle-colors.hide
diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml
index 075eb99fc36..477f6c73388 100644
--- a/app/views/discussions/_discussion.html.haml
+++ b/app/views/discussions/_discussion.html.haml
@@ -11,7 +11,8 @@
%button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button", class: ("js-toggle-lazy-diff" unless expanded) }
= sprite_icon('chevron-up', css_class: "js-sidebar-collapse #{'hidden' unless expanded}")
= sprite_icon('chevron-down', css_class: "js-sidebar-expand #{'hidden' if expanded}")
- = _('Toggle thread')
+ %span.js-sidebar-collapse{ class: "#{'hidden' unless expanded}" }= _('Hide thread')
+ %span.js-sidebar-expand{ class: "#{'hidden' if expanded}" }= _('Show thread')
= link_to_member(@project, discussion.author, avatar: false)
.inline.discussion-headline-light
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 367b118ec71..08e37308936 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -1663,7 +1663,7 @@
:worker_name: Ci::DropPipelineWorker
:feature_category: :continuous_integration
:has_external_dependencies:
- :urgency: :low
+ :urgency: :high
:resource_boundary: :unknown
:weight: 3
:idempotent: true
diff --git a/app/workers/ci/drop_pipeline_worker.rb b/app/workers/ci/drop_pipeline_worker.rb
index edb97c3cac5..6018290b3a2 100644
--- a/app/workers/ci/drop_pipeline_worker.rb
+++ b/app/workers/ci/drop_pipeline_worker.rb
@@ -9,6 +9,8 @@ module Ci
sidekiq_options retry: 3
include PipelineQueue
+ urgency :high
+
idempotent!
def perform(pipeline_id, failure_reason)
diff --git a/data/deprecations/14-9-deprecate-composer-download-permissions.yml b/data/deprecations/14-9-deprecate-composer-download-permissions.yml
new file mode 100644
index 00000000000..79cb2a81c53
--- /dev/null
+++ b/data/deprecations/14-9-deprecate-composer-download-permissions.yml
@@ -0,0 +1,11 @@
+- name: "Permissions change for downloading Composer dependencies"
+ announcement_milestone: "14.9"
+ announcement_date: "2022-03-22"
+ removal_milestone: "15.0"
+ removal_date: "2022-05-22"
+ breaking_change: true
+ reporter: trizzi
+ body: | # Do not modify this line, instead modify the lines below.
+ The GitLab Composer repository can be used to push, search, fetch metadata about, and download PHP dependencies. All these actions require authentication, except for downloading dependencies.
+
+ Downloading Composer dependencies without authentication is deprecated in GitLab 14.9, and will be removed in GitLab 15.0. Starting with GitLab 15.0, you must authenticate to download Composer dependencies.
diff --git a/data/removals/14_9/removal_monitor_respond_integrated_error_tracking.yml b/data/removals/14_9/removal_monitor_respond_integrated_error_tracking.yml
new file mode 100644
index 00000000000..a0467a56f4f
--- /dev/null
+++ b/data/removals/14_9/removal_monitor_respond_integrated_error_tracking.yml
@@ -0,0 +1,14 @@
+- name: "Integrated error tracking disabled by default"
+ announcement_milestone: "14.9"
+ announcement_date: "2022-02-23" # This is the date customers were notified about the change in rate limits, making integrated error tracking unusable, see https://gitlab.com/groups/gitlab-org/-/epics/7580#communication-to-rate-limit-impacted-users
+ removal_milestone: "14.9"
+ removal_date: "2022-03-10" # The MR was merged on this date, outside of the normal release cycle, https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81767
+ breaking_change: true
+ reporter: abellucci
+ body: |
+ In GitLab 14.4, GitLab released an integrated error tracking backend that replaces Sentry. This feature caused database performance issues. In GitLab 14.9, integrated error tracking is removed from GitLab.com, and turned off by default in GitLab self-managed. While we explore the future development of this feature, please consider switching to the Sentry backend by [changing your error tracking to Sentry in your project settings](https://docs.gitlab.com/ee/operations/error_tracking.html#sentry-error-tracking).
+
+ For additional background on this removal, please reference [Disable Integrated Error Tracking by Default](https://gitlab.com/groups/gitlab-org/-/epics/7580). If you have feedback please add a comment to [Feedback: Removal of Integrated Error Tracking](https://gitlab.com/gitlab-org/gitlab/-/issues/355493).
+ stage: monitor
+ tiers: [Free, Silver, Gold, Core, Premium, Ultimate]
+ issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/353639
diff --git a/doc/ci/jobs/ci_job_token.md b/doc/ci/jobs/ci_job_token.md
index a62f670cb12..b0002f559ba 100644
--- a/doc/ci/jobs/ci_job_token.md
+++ b/doc/ci/jobs/ci_job_token.md
@@ -23,6 +23,12 @@ You can use a GitLab CI/CD job token to authenticate with specific API endpoints
- [Releases](../../api/releases/index.md).
- [Terraform plan](../../user/infrastructure/index.md).
+NOTE:
+There's an open issue,
+[GitLab-#333444](https://gitlab.com/gitlab-org/gitlab/-/issues/333444),
+which prevents you from using a job token with internal projects. This bug only impacts self-managed
+GitLab instances.
+
The token has the same permissions to access the API as the user that caused the
job to run. A user can cause a job to run by pushing a commit, triggering a manual job,
being the owner of a scheduled pipeline, and so on. Therefore, this user must be assigned to
diff --git a/doc/development/index.md b/doc/development/index.md
index ff95240ce3a..048112215fc 100644
--- a/doc/development/index.md
+++ b/doc/development/index.md
@@ -343,6 +343,7 @@ See [database guidelines](database/index.md).
- [Dashboards for stage groups](stage_group_dashboards.md)
- [Preventing transient bugs](transient/prevention-patterns.md)
- [GitLab Application SLIs](application_slis/index.md)
+- [Spam protection and CAPTCHA development guide](spam_protection_and_captcha/index.md)
## Other GitLab Development Kit (GDK) guides
diff --git a/doc/development/spam_protection_and_captcha/exploratory_testing.md b/doc/development/spam_protection_and_captcha/exploratory_testing.md
new file mode 100644
index 00000000000..e508265cf83
--- /dev/null
+++ b/doc/development/spam_protection_and_captcha/exploratory_testing.md
@@ -0,0 +1,360 @@
+---
+stage: Manage
+group: Authentication and Authorization
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
+---
+
+# Exploratory testing of CAPTCHAs
+
+You can reliably test CAPTCHA on review apps, and in your local development environment (GDK).
+You can always:
+
+- Force a reCAPTCHA to appear where it is supported.
+- Force a checkbox to display, instead of street sign images to find and select.
+
+To set up testing, follow the configuration on this page.
+
+## Use appropriate test data
+
+Make sure you are testing a scenario which has spam/CAPTCHA enabled. For example:
+make sure you are editing a _public_ snippet, as only public snippets are checked for spam.
+
+## Enable feature flags
+
+Enable any relevant feature flag, if the spam/CAPTCHA support is behind a feature flag.
+
+## Set up Akismet and reCAPTCHA
+
+1. To set up reCAPTCHA:
+ 1. Review the [GitLab reCAPTCHA documentation](../../integration/recaptcha.md).
+ 1. Get Google's official test reCAPTCHA credentials using the instructions from
+ [Google's reCAPTCHA documentation](https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do).
+ 1. For **Site key**, use: `6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI`
+ 1. For **Secret key**, use: `6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe`
+ 1. Go to **Admin -> Settings -> Reporting** settings: `http://gdk.test:3000/admin/application_settings/reporting#js-spam-settings`
+ 1. Select **Enable reCAPTCHA**. Enabling for login is not required unless you are testing that feature.
+ 1. Enter the **Site key** and **Secret key**.
+1. To set up Akismet:
+ 1. Review the [GitLab documentation on Akismet](../../integration/akismet.md).
+ 1. Get an Akismet API key. You can sign up for [a testing key from Akismet](https://akismet.com).
+ You must enter your local host (such as`gdk.test`) and email when signing up.
+ 1. Go to GitLab Akismet settings page, for example:
+ `http://gdk.test:3000/admin/application_settings/reporting#js-spam-settings`
+ 1. Enable Akismet and enter your Akismet **API key**.
+1. To force an Akismet false-positive spam check, refer to the
+ [Akismet API documentation](https://akismet.com/development/api/#comment-check) and
+ [Akismet Getting Started documentation](https://docs.akismet.com/getting-started/confirm/) for more details:
+ 1. You can use `akismet-guaranteed-spam@example.com` as the author email to force spam using the following steps:
+ 1. Go to user email settings: `http://gdk.test:3000/-/profile/emails`
+ 1. Add `akismet-guaranteed-spam@example.com` as a secondary email for the administrator user.
+ 1. Confirm it in the Rails console: `bin/rails c` -> `User.find_by_username('root').emails.last.confirm`
+ 1. Switch this verified email to be your primary email:
+ 1. Go to **Avatar dropdown list -> Edit Profile -> Main Settings**.
+ 1. For **Email**, enter `akismet-guaranteed-spam@example.com` to replace `admin@example.com`.
+ 1. Select **Update Profile Settings** to save your changes.
+
+## Test in the web UI
+
+After you have all the above configuration in place, you can test CAPTCHAs. Test
+in an area of the application which already has CAPTCHA support, such as:
+
+- Creating or editing an issue.
+- Creating or editing a public snippet. Only **public** snippets are checked for spam.
+
+## Test in a development environment
+
+After you force Spam Flagging + CAPTCHA using the steps above, you can test the
+behavior with any spam-protected model/controller action.
+
+### Test with CAPTCHA enabled (CONDITIONAL_ALLOW verdict)
+
+If CAPTCHA is enabled in these areas, you must solve the CAPTCHA popup modal before you can resubmit the form:
+
+- **Admin -> Settings -> Reporting -> Spam**
+- **Anti-bot Protection -> Enable reCAPTCHA**
+
+<!-- vale gitlab.Substitutions = NO -->
+
+### Testing with CAPTCHA disabled ("DISALLOW" verdict)
+
+<!-- vale gitlab.Substitutions = YES -->
+
+If CAPTCHA is disabled in **Admin -> Settings -> Reporting -> Spam** and **Anti-bot Protection -> Enable reCAPTCHA**,
+no CAPTCHA popup displays. You are prevented from submitting the form at all.
+
+### HTML page to render reCAPTCHA
+
+NOTE:
+If you use **Google's official test reCAPTCHA credentials** listed in
+[Set up Akismet and reCAPTCHA](#set-up-akismet-and-recaptcha), the
+CAPTCHA response string does not matter. It can be any string. If you use a
+real, valid key pair, you must solve the CAPTCHA to obtain a
+valid CAPTCHA response to use. You can do this once only, and only before it expires.
+
+To directly test the GraphQL API via [GraphQL Explorer](http://gdk.test:3000/-/graphql-explorer),
+get a reCAPTCHA response string via this form: `public/recaptcha.html` (`http://gdk.test:3000/recaptcha.html`):
+
+```html
+<html>
+<head>
+ <title>reCAPTCHA demo: Explicit render after an onload callback</title>
+ <script type="text/javascript">
+ var onloadCallback = function() {
+ grecaptcha.render('html_element', {
+ 'sitekey' : '6Ld05AsaAAAAAMsm1yTUp4qsdFARN15rQJPPqv6i'
+ });
+ };
+ function onSubmit() {
+ window.document.getElementById('recaptchaResponse').innerHTML = grecaptcha.getResponse();
+ return false;
+ }
+ </script>
+</head>
+<body>
+<form onsubmit="return onSubmit()">
+ <div id="html_element"></div>
+ <br>
+ <input type="submit" value="Submit">
+</form>
+<div>
+ <h1>recaptchaResponse:</h1>
+ <div id="recaptchaResponse"></div>
+</div>
+<script src="https://www.google.com/recaptcha/api.js?onload=onloadCallback&render=explicit"
+ async defer>
+</script>
+</body>
+</html>
+```
+
+## Spam/CAPTCHA API exploratory testing examples
+
+These sections describe the steps needed to perform manual exploratory testing of
+various scenarios of the Spam and CAPTCHA behavior for the REST and GraphQL APIs.
+
+For the prerequisites, you must:
+
+1. Perform all the steps listed above to enable Spam and CAPTCHA in the development environment,
+ and force form submissions to require a CAPTCHA.
+1. Ensure you have created an HTML page to render CAPTCHA under the `/public` directory,
+ with a page that contains a form to manually generate a valid CAPTCHA response string.
+ If you use **Google's official test reCAPTCHA credentials** listed in
+ [Set up Akismet and reCAPTCHA](#set-up-akismet-and-recaptcha), the contents of the
+ CAPTCHA response string don't matter.
+1. Go to **Admin -> Settings -> Reporting -> Spam and Anti-bot protection**.
+1. Select or clear **Enable reCAPTCHA** and **Enable Akismet** according to your
+ scenario's needs.
+
+The following examples use snippet creation as an example. You could also use
+snippet updates, issue creation, or issue updates. Issues and snippets are the
+only models with full Spam and CAPTCHA support.
+
+### Initial setup
+
+1. Create an API token.
+1. Export it in your terminal for the REST commands: `export PRIVATE_TOKEN=<your_api_token>`
+1. Ensure you are logged into GitLab development environment at `localhost:3000` before using GraphiQL explorer,
+ because it uses your logged-in user as authorization for running GraphQL queries.
+1. For the GraphQL examples, use the GraphiQL explorer at `http://localhost:3000/-/graphql-explorer`.
+1. Use the `--include` (`-i`) option to `curl` to print the HTTP response headers, including the status code.
+
+### Scenario: Akismet and CAPTCHA enabled
+
+In this example, Akismet and CAPTCHA are enabled:
+
+1. [Initial request](#initial-request).
+
+<!-- TODO in future edit
+
+Some example videos:
+
+- REST API:
+
+![CAPTCHA REST API](/uploads/b148cbe45496e6f4a4f63d00bb9fbd8a/captcha_rest_api.mov)
+
+GraphQL API:
+
+![CAPTCHA GraphQL API](/uploads/3c7ef0fad0b84bd588572bae51519463/captcha_graphql_api.mov)
+
+-->
+
+#### Initial request
+
+This initial request fails because no CAPTCHA response is provided.
+
+REST request:
+
+```shell
+curl --request POST --header "PRIVATE-TOKEN: $PRIVATE_TOKEN" "http://localhost:3000/api/v4/snippets?title=Title&file_name=FileName&content=Content&visibility=public"
+```
+
+REST response:
+
+```shell
+{"needs_captcha_response":true,"spam_log_id":42,"captcha_site_key":"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX","message":{"error":"Your snippet has been recognized as spam. Please, change the content or solve the reCAPTCHA to proceed."}}
+```
+
+GraphQL request:
+
+```graphql
+mutation {
+ createSnippet(input: {
+ title: "Title"
+ visibilityLevel: public
+ blobActions: [
+ {
+ action: create
+ filePath: "BlobPath"
+ content: "BlobContent"
+ }
+ ]
+ }) {
+ snippet {
+ id
+ title
+ }
+ errors
+ }
+}
+```
+
+GraphQL response:
+
+```json
+{
+ "data": {
+ "createSnippet": null
+ },
+ "errors": [
+ {
+ "message": "Request denied. Solve CAPTCHA challenge and retry",
+ "locations": [
+ {
+ "line": 22,
+ "column": 5
+ }
+ ],
+ "path": [
+ "createSnippet"
+ ],
+ "extensions": {
+ "needs_captcha_response": true,
+ "spam_log_id": 140,
+ "captcha_site_key": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
+ }
+ }
+ ]
+}
+```
+
+#### Second request
+
+This request succeeds because a CAPTCHA response is provided.
+
+REST request:
+
+```shell
+export CAPTCHA_RESPONSE="<CAPTCHA response obtained from HTML page to render CAPTCHA>"
+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" "http://localhost:3000/api/v4/snippets?title=Title&file_name=FileName&content=Content&visibility=public"
+```
+
+REST response:
+
+```shell
+{"id":42,"title":"Title","description":null,"visibility":"public", "other_fields": "..."}
+```
+
+GraphQL request:
+
+NOTE:
+The GitLab GraphiQL implementation doesn't allow passing of headers, so we must write
+this as a `curl` query. Here, `--data-binary` is used to properly handle escaped double quotes
+in the JSON-embedded query.
+
+```shell
+export CAPTCHA_RESPONSE="<CAPTCHA response obtained from HTML page to render CAPTCHA>"
+export SPAM_LOG_ID="<spam_log_id obtained from initial REST response>"
+curl --include "http://localhost:3000/api/graphql" --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 }}"}'
+```
+
+GraphQL response:
+
+```json
+{"data":{"createSnippet":{"snippet":{"id":"gid://gitlab/PersonalSnippet/42","title":"Title"},"errors":[]}}}
+```
+
+### Scenario: Akismet enabled, CAPTCHA disabled
+
+For this scenario, ensure you clear **Enable reCAPTCHA** in the Admin Area settings as described above.
+If CAPTCHA is not enabled, any request flagged as potential spam fails with no chance to resubmit,
+even if it could otherwise be resubmitted if CAPTCHA were enabled and successfully solved.
+
+The REST request is the same as if CAPTCHA was enabled:
+
+```shell
+curl --request POST --header "PRIVATE-TOKEN: $PRIVATE_TOKEN" "http://localhost:3000/api/v4/snippets?title=Title&file_name=FileName&content=Content&visibility=public"
+```
+
+REST response:
+
+```shell
+{"message":{"error":"Your snippet has been recognized as spam and has been discarded."}}
+```
+
+GraphQL request:
+
+```graphql
+mutation {
+ createSnippet(input: {
+ title: "Title"
+ visibilityLevel: public
+ blobActions: [
+ {
+ action: create
+ filePath: "BlobPath"
+ content: "BlobContent"
+ }
+ ]
+ }) {
+ snippet {
+ id
+ title
+ }
+ errors
+ }
+}
+```
+
+GraphQL response:
+
+```json
+{
+ "data": {
+ "createSnippet": null
+ },
+ "errors": [
+ {
+ "message": "Request denied. Spam detected",
+ "locations": [
+ {
+ "line": 22,
+ "column": 5
+ }
+ ],
+ "path": [
+ "createSnippet"
+ ],
+ "extensions": {
+ "spam": true
+ }
+ }
+ ]
+}
+```
+
+### Scenario: allow_possible_spam feature flag enabled
+
+With the `allow_possible_spam` feature flag enabled, the API returns a 200 response. Any
+valid request is successful and no CAPTCHA is presented, even if the request is considered
+spam.
diff --git a/doc/development/spam_protection_and_captcha/graphql_api.md b/doc/development/spam_protection_and_captcha/graphql_api.md
new file mode 100644
index 00000000000..b47e3f84320
--- /dev/null
+++ b/doc/development/spam_protection_and_captcha/graphql_api.md
@@ -0,0 +1,66 @@
+---
+stage: Manage
+group: Authentication and Authorization
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
+---
+
+# GraphQL API spam protection and CAPTCHA support
+
+If the model can be modified via the GraphQL API, you must also add support to all of the
+relevant GraphQL mutations which may modify spammable or spam-related attributes. This
+definitely includes the `Create` and `Update` mutations, but may also include others, such as those
+related to changing a model's confidential/public flag.
+
+## Add support to the GraphQL mutations
+
+This implementation is very similar to the controller implementation. You create a `spam_params`
+instance based on the request, and pass it to the relevant Service class constructor.
+
+The three main differences from the controller implementation are:
+
+1. Use `include Mutations::SpamProtection` instead of `...JsonFormatActionsSupport`.
+1. Obtain the request from the context via `context[:request]` when creating the `SpamParams`
+ instance.
+1. After you create or updated the `Spammable` model instance, call `#check_spam_action_response!`
+ and pass it the model instance. This call will:
+ 1. Perform the necessary spam checks on the model.
+ 1. If spam is detected:
+ - Raise a `GraphQL::ExecutionError` exception.
+ - Include the relevant information added as error fields to the response via the `extensions:` parameter.
+ For more details on these fields, refer to the section on
+ [Spam and CAPTCHA support in the GraphQL API](../../api/graphql/index.md#resolve-mutations-detected-as-spam).
+
+ NOTE:
+ If you use the standard ApolloLink or Axios interceptor CAPTCHA support described
+ above, the field details are unimportant. They become important if you
+ attempt to use the GraphQL API directly to process a failed check for potential spam, and
+ resubmit the request with a solved CAPTCHA response.
+
+For example:
+
+```ruby
+module Mutations
+ module Widgets
+ class Create < BaseMutation
+ include Mutations::SpamProtection
+
+ def resolve(args)
+ spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
+
+ service_response = ::Widgets::CreateService.new(
+ project: project,
+ current_user: current_user,
+ params: args,
+ spam_params: spam_params
+ ).execute
+
+ widget = service_response.payload[:widget]
+ check_spam_action_response!(widget)
+
+ # If possible spam wasdetected, an exception would have been thrown by
+ # `#check_spam_action_response!`, so the normal resolve return logic can follow below.
+ end
+ end
+ end
+end
+```
diff --git a/doc/development/spam_protection_and_captcha/index.md b/doc/development/spam_protection_and_captcha/index.md
new file mode 100644
index 00000000000..9b195df536d
--- /dev/null
+++ b/doc/development/spam_protection_and_captcha/index.md
@@ -0,0 +1,53 @@
+---
+stage: Manage
+group: Authentication and Authorization
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
+---
+
+# Spam protection and CAPTCHA
+
+This guide provides an overview of how to add spam protection and CAPTCHA support to new areas of the
+GitLab application.
+
+## Add spam protection and CAPTCHA support to a new area
+
+To add this support, you must implement the following areas as applicable:
+
+1. [Model and Services](model_and_services.md): The basic prerequisite
+ changes to the backend code which are required to add spam or CAPTCHA API and UI support
+ for a feature which does not yet have support.
+1. REST API (Supported, documentation coming soon): The changes needed to add
+ spam or CAPTCHA support to Grape REST API endpoints. Refer to the related
+ [REST API documentation](../../api/index.md#resolve-requests-detected-as-spam).
+1. [GraphQL API](graphql_api.md): The changes needed to add spam or CAPTCHA support to GraphQL
+ mutations. Refer to the related
+ [GraphQL API documentation](../../api/graphql/index.md#resolve-mutations-detected-as-spam).
+1. [Web UI](web_ui.md): The various possible scenarios encountered when adding
+ spam/CAPTCHA support to the web UI, depending on whether the UI is JavaScript API-based (Vue or
+ plain JavaScript) or HTML-form (HAML) based.
+
+You should also perform manual exploratory testing of the new feature. Refer to
+[Exploratory testing](exploratory_testing.md) for more information.
+
+## Spam-related model and API fields
+
+Multiple levels of spam flagging determine how spam is handled. These levels are referenced in
+[`Spam::SpamConstants`](https://gitlab.com/gitlab-org/gitlab/blob/master/app/services/spam/spam_constants.rb#L4-4),
+and used various places in the application, such as
+[`Spam::SpamActionService#perform_spam_service_check`](https://gitlab.com/gitlab-org/gitlab/blob/d7585b56c9e7dc69414af306d82906e28befe7da/app/services/spam/spam_action_service.rb#L61-61).
+
+The possible values include:
+
+- `BLOCK_USER`
+- `DISALLOW`
+- `CONDITIONAL_ALLOW`
+- `OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM`
+- `ALLOW`
+- `NOOP`
+
+## Related topics
+
+- [Spam and CAPTCHA support in the GraphQL API](../../api/graphql/index.md#resolve-mutations-detected-as-spam)
+- [Spam and CAPTCHA support in the REST API](../../api/index.md#resolve-requests-detected-as-spam)
+- [reCAPTCHA Spam and Anti-bot Protection](../../integration/recaptcha.md)
+- [Akismet and Spam Logs](../../integration/akismet.md)
diff --git a/doc/development/spam_protection_and_captcha/model_and_services.md b/doc/development/spam_protection_and_captcha/model_and_services.md
new file mode 100644
index 00000000000..d0519cba68f
--- /dev/null
+++ b/doc/development/spam_protection_and_captcha/model_and_services.md
@@ -0,0 +1,155 @@
+---
+stage: Manage
+group: Authentication and Authorization
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
+---
+
+# Model and services spam protection and CAPTCHA support
+
+Before adding any spam or CAPTCHA support to the REST API, GraphQL API, or Web UI, you must
+first add the necessary support to:
+
+1. The backend ActiveRecord models.
+1. The services layer.
+
+All or most of the following changes are required, regardless of the type of spam or CAPTCHA request
+implementation you are supporting. Some newer features which are completely based on the GraphQL API
+may not have any controllers, and don't require you to add the `mark_as_spam` action to the controller.
+
+To do this:
+
+1. [Add `Spammable` support to the ActiveRecord model](#add-spammable-support-to-the-activerecord-model).
+1. [Add support for the `mark_as_spam` action to the controller](#add-support-for-the-mark_as_spam-action-to-the-controller).
+1. [Add a call to SpamActionService to the execute method of services](#add-a-call-to-spamactionservice-to-the-execute-method-of-services).
+
+## Add `Spammable` support to the ActiveRecord model
+
+1. Include the `Spammable` module in the model class:
+
+ ```ruby
+ include Spammable
+ ```
+
+1. Add: `attr_spammable` to indicate which fields can be checked for spam. Up to
+ two fields per model are supported: a "`title`" and a "`description`". You can
+ designate which fields to consider the "`title`" or "`description`". For example,
+ this line designates the `content` field as the `description`:
+
+ ```ruby
+ attr_spammable :content, spam_description: true
+ ```
+
+1. Add a `#check_for_spam?` method implementation:
+
+ ```ruby
+ def check_for_spam?(user:)
+ # Return a boolean result based on various applicable checks, which may include
+ # which attributes have changed, the type of user, whether the data is publicly
+ # visible, and other criteria. This may vary based on the type of model, and
+ # may change over time as spam checking requirements evolve.
+ end
+ ```
+
+ Refer to other existing `Spammable` models'
+ implementations of this method for examples of the required logic checks.
+
+## Add support for the `mark_as_spam` action to the controller
+
+The `SpammableActions::AkismetMarkAsSpamAction` module adds support for a `#mark_as_spam` action
+to a controller. This controller allows administrators to manage spam for the associated
+`Spammable` model in the [Spam Log section](../../integration/akismet.md) of the Admin Area page.
+
+1. Include the `SpammableActions::AkismetMarkAsSpamAction` module in the controller.
+
+ ```ruby
+ include SpammableActions::AkismetMarkAsSpamAction
+ ```
+
+1. Add a `#spammable_path` method implementation. The spam administration page redirects
+ to this page after edits. Refer to other existing controllers' implementations
+ of this method for examples of the type of path logic required. In general, it should
+ be the `#show` action for the `Spammable` model's controller.
+
+ ```ruby
+ def spammable_path
+ widget_path(widget)
+ end
+ ```
+
+NOTE:
+There may be other changes needed to controllers, depending on how the feature is
+implemented. See [Web UI](web_ui.md) for more details.
+
+## Add a call to SpamActionService to the execute method of services
+
+This approach applies to any service which can persist spammable attributes:
+
+1. In the relevant Create or Update service under `app/services`, pass in a populated
+ `Spam::SpamParams` instance. (Refer to instructions later on in this page.)
+1. Use it and the `Spammable` model instance to execute a `Spam::SpamActionService` instance.
+1. If the spam check fails:
+ - An error is added to the model, which causes it to be invalid and prevents it from being saved.
+ - The `needs_recaptcha` property is set to `true`.
+
+ These changes to the model enable it for handling by the subsequent backend and frontend CAPTCHA logic.
+
+Make these changes to each relevant service:
+
+1. Change the constructor to take a `spam_params:` argument as a required named argument.
+
+ Using named arguments for the constructor helps you identify all the calls to
+ the constructor that need changing. It's less risky because the interpreter raises
+ type errors unless the caller is changed to pass the `spam_params` argument.
+ If you use an IDE (such as RubyMine) which supports this, your
+ IDE flags it as an error in the editor.
+
+1. In the constructor, set the `@spam_params` instance variable from the `spam_params` constructor
+ argument. Add an `attr_reader: :spam_params` in the `private` section of the class.
+
+1. In the `execute` method, add a call to execute the `Spam::SpamActionService`.
+ (You can also use `before_create` or `before_update`, if the service
+ uses that pattern.) This method uses named arguments, so its usage is clear if
+ you refer to existing examples. However, two important considerations exist:
+ 1. The `SpamActionService` must be executed _after_ all necessary changes are made to
+ the unsaved (and dirty) `Spammable` model instance. This ordering ensures
+ spammable attributes exist to be spam-checked.
+ 1. The `SpamActionService` must be executed _before_ the model is checked for errors and
+ attempting a `save`. If potential spam is detected in the model's changed attributes, we must prevent a save.
+
+```ruby
+module Widget
+ class CreateService < ::Widget::BaseService
+ # NOTE: We require the spam_params and do not default it to nil, because
+ # spam_checking is likely to be necessary. However, if there is not a request available in scope
+ # in the caller (for example, a note created via email) and the required arguments to the
+ # SpamParams constructor are not otherwise available, spam_params: must be explicitly passed as nil.
+ def initialize(project:, current_user: nil, params: {}, spam_params:)
+ super(project: project, current_user: current_user, params: params)
+
+ @spam_params = spam_params
+ end
+
+ def execute
+ widget = Widget::BuildService.new(project, current_user, params).execute
+
+ # More code that may manipulate dirty model before it is spam checked.
+
+ # NOTE: do this AFTER the spammable model is instantiated, but BEFORE
+ # it is validated or saved.
+ Spam::SpamActionService.new(
+ spammable: widget,
+ spam_params: spam_params,
+ user: current_user,
+ # Or `action: :update` for a UpdateService or service for an existing model.
+ action: :create
+ ).execute
+
+ # Possibly more code related to saving model, but should not change any attributes.
+
+ widget.save
+ end
+
+ private
+
+ attr_reader :spam_params
+```
diff --git a/doc/development/spam_protection_and_captcha/web_ui.md b/doc/development/spam_protection_and_captcha/web_ui.md
new file mode 100644
index 00000000000..6aa01f401bd
--- /dev/null
+++ b/doc/development/spam_protection_and_captcha/web_ui.md
@@ -0,0 +1,196 @@
+---
+stage: Manage
+group: Authentication and Authorization
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
+---
+
+# Web UI spam protection and CAPTCHA support
+
+The approach for adding spam protection and CAPTCHA support to a new UI area of the GitLab application
+depends upon how the existing code is implemented.
+
+## Supported scenarios of request submissions
+
+Three different scenarios are supported. Two are used with JavaScript XHR/Fetch requests
+for either Apollo or Axios, and one is used only with standard HTML form requests:
+
+1. A JavaScript-based submission (possibly via Vue)
+ 1. Using Apollo (GraphQL API via Fetch/XHR request)
+ 1. Using Axios (REST API via Fetch/XHR request)
+1. A standard HTML form submission (HTML request)
+
+Some parts of the implementation depend upon which of these scenarios you must support.
+
+## Implementation tasks specific to JavaScript XHR/Fetch requests
+
+Two approaches are fully supported:
+
+1. Apollo, using the GraphQL API.
+1. Axios, using either the GraphQL API.
+
+The spam and CAPTCHA-related data communication between the frontend and backend requires no
+additional fields being added to the models. Instead, communication is handled:
+
+- Through custom header values in the request.
+- Through top-level JSON fields in the response.
+
+The spam and CAPTCHA-related logic is also cleanly abstracted into reusable modules and helper methods
+which can wrap existing logic, and only alter the existing flow if potential spam
+is detected or a CAPTCHA display is needed. This approach allows the spam and CAPTCHA
+support to be easily added to new areas of the application with minimal changes to
+existing logic. In the case of the frontend, potentially **zero** changes are needed!
+
+On the frontend, this is handled abstractly and transparently using `ApolloLink` for Apollo, and an
+Axios interceptor for Axios. The CAPTCHA display is handled by a standard GitLab UI / Pajamas modal
+component. You can find all the relevant frontend code under `app/assets/javascripts/captcha`.
+
+However, even though the actual handling of the request interception and
+modal is transparent, without any mandatory changes to the involved JavaScript or Vue components
+for the form or page, changes in request or error handling may be required. Changes are needed
+because the existing behavior may not work correctly: for example, if a failed or cancelled
+CAPTCHA display interrupts the normal request flow or UI updates.
+Careful exploratory testing of all scenarios is important to uncover any potential
+problems.
+
+This sequence diagram illustrates the normal CAPTCHA flow for JavaScript XHR/Fetch requests
+on the frontend:
+
+```mermaid
+sequenceDiagram
+ participant U as User
+ participant V as Vue/JS Application
+ participant A as ApolloLink or Axios Interceptor
+ participant G as GitLab API
+ U->>V: Save model
+ V->>A: Request
+ A->>G: Request
+ G--xA: Response with error and spam/CAPTCHA related fields
+ A->>U: CAPTCHA presented in modal
+ U->>A: CAPTCHA solved to obtain valid CAPTCHA response
+ A->>G: Request with valid CAPTCHA response and SpamLog ID in headers
+ G-->>A: Response with success
+ A-->>V: Response with success
+```
+
+The backend is also cleanly abstracted via mixin modules and helper methods. The three main
+changes required to the relevant backend controller actions (normally just `create`/`update`) are:
+
+1. Create a `SpamParams` parameter object instance based on the request, using the simple static
+ `#new_from_request` factory method. This method takes a request, and returns a `SpamParams` instance.
+1. Pass the created `SpamParams` instance as the `spam_params` named argument to the
+ Service class constructor, which you should have already added. If the spam check indicates
+ the changes to the model are possibly spam, then:
+ - An error is added to the model.
+ - The `needs_recaptcha` property on the model is set to true.
+1. Wrap the existing controller action return value (rendering or redirecting) in a block passed to
+ a `#with_captcha_check_json_format` helper method, which transparently handles:
+ 1. Check if CAPTCHA is enabled, and if so, proceeding with the next step.
+ 1. Checking if there the model contains an error, and the `needs_recaptcha` flag is true.
+ - If yes: Add the appropriate spam or CAPTCHA fields to the JSON response, and return
+ a `409 - Conflict` HTTP status code.
+ - If no (if CAPTCHA is disabled or if no spam was detected): The normal request return
+ logic passed in the block is run.
+
+Thanks to the abstractions, it's more straightforward to implement than it is to explain it.
+You don't have to worry much about the hidden details!
+
+Make these changes:
+
+## Add support to the controller actions
+
+If the feature's frontend submits directly to controller actions, and does not only use the GraphQL
+API, then you must add support to the appropriate controllers.
+
+The action methods may be directly in the controller class, or they may be abstracted
+to a module included in the controller class. Our example uses a module. The
+only difference when directly modifying the controller:
+`extend ActiveSupport::Concern` is not required.
+
+```ruby
+module WidgetsActions
+ # NOTE: This `extend` probably already exists, but it MUST be moved to occur BEFORE all
+ # `include` statements. Otherwise, confusing bugs may occur in which the methods
+ # in the included modules cannot be found.
+ extend ActiveSupport::Concern
+
+ include SpammableActions::CaptchaCheck::JsonFormatActionsSupport
+
+ def create
+ spam_params = ::Spam::SpamParams.new_from_request(request: request)
+ widget = ::Widgets::CreateService.new(
+ project: project,
+ current_user: current_user,
+ params: params,
+ spam_params: spam_params
+ ).execute
+
+ respond_to do |format|
+ format.json do
+ with_captcha_check_json_format do
+ # The action's existing `render json: ...` (or wrapper method) and related logic. Possibly
+ # including different rendering cases if the model is valid or not. It's all wrapped here
+ # within the `with_captcha_check_json_format` block. For example:
+ if widget.valid?
+ render json: serializer.represent(widget)
+ else
+ render json: { errors: widget.errors.full_messages }, status: :unprocessable_entity
+ end
+ end
+ end
+ end
+ end
+end
+```
+
+## Implementation tasks specific to HTML form requests
+
+Some areas of the application have not been converted to use the GraphQL API via
+a JavaScript client, but instead rely on standard Rails HAML form submissions via an
+`HTML` MIME type request. In these areas, the action returns a pre-rendered HTML (HAML) page
+as the response body. Unfortunately, in this case
+[it is not possible](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/66427#note_636989204)
+to use any of the JavaScript-based frontend support as described above. Instead we must use an
+alternate approach which handles the rendering of the CAPTCHA form via a HAML template.
+
+Everything is still cleanly abstracted, and the implementation in the backend
+controllers is virtually identical to the JavaScript/JSON based approach. Replace the
+word `JSON` with `HTML` (using the appropriate case) in the module names and helper methods.
+
+The action methods might be directly in the controller, or they
+might be in a module. In this example, they are directly in the
+controller, and we also do an `update` method instead of `create`:
+
+```ruby
+class WidgetsController < ApplicationController
+ include SpammableActions::CaptchaCheck::HtmlFormatActionsSupport
+
+ def update
+ # Existing logic to find the `widget` model instance...
+
+ spam_params = ::Spam::SpamParams.new_from_request(request: request)
+ ::Widgets::UpdateService.new(
+ project: project,
+ current_user: current_user,
+ params: params,
+ spam_params: spam_params
+ ).execute(widget)
+
+ respond_to do |format|
+ format.html do
+ if widget.valid?
+ # NOTE: `spammable_path` is required by the `SpammableActions::AkismetMarkAsSpamAction`
+ # module, and it should have already been implemented on this controller according to
+ # the instructions above. It is reused here to avoid duplicating the route helper call.
+ redirect_to spammable_path
+ else
+ # If we got here, there were errors on the model instance - from a failed spam check
+ # and/or other validation errors on the model. Either way, we'll re-render the form,
+ # and if a CAPTCHA render is necessary, it will be automatically handled by
+ # `with_captcha_check_html_format`
+ with_captcha_check_html_format { render :edit }
+ end
+ end
+ end
+ end
+end
+```
diff --git a/doc/update/deprecations.md b/doc/update/deprecations.md
index 2b3c680e1ea..c2409af70e6 100644
--- a/doc/update/deprecations.md
+++ b/doc/update/deprecations.md
@@ -75,6 +75,20 @@ The [`custom_hooks_dir`](https://docs.gitlab.com/ee/administration/server_hooks.
**Planned removal milestone: 15.0 ()**
+### Permissions change for downloading Composer dependencies
+
+WARNING:
+This feature will be changed or removed in 15.0
+as a [breaking change](https://docs.gitlab.com/ee/development/contributing/#breaking-changes).
+Before updating GitLab, review the details carefully to determine if you need to make any
+changes to your code, settings, or workflow.
+
+The GitLab Composer repository can be used to push, search, fetch metadata about, and download PHP dependencies. All these actions require authentication, except for downloading dependencies.
+
+Downloading Composer dependencies without authentication is deprecated in GitLab 14.9, and will be removed in GitLab 15.0. Starting with GitLab 15.0, you must authenticate to download Composer dependencies.
+
+**Planned removal milestone: 15.0 (2022-05-22)**
+
### htpasswd Authentication for the Container Registry
WARNING:
diff --git a/doc/update/removals.md b/doc/update/removals.md
index 09c8d584e9b..7e2b4f84fa1 100644
--- a/doc/update/removals.md
+++ b/doc/update/removals.md
@@ -28,6 +28,20 @@ For removal reviewers (Technical Writers only):
https://about.gitlab.com/handbook/marketing/blog/release-posts/#update-the-removals-doc
-->
+## 14.9
+
+### Integrated error tracking disabled by default
+
+WARNING:
+This feature was changed or removed in 14.9
+as a [breaking change](https://docs.gitlab.com/ee/development/contributing/#breaking-changes).
+Before updating GitLab, review the details carefully to determine if you need to make any
+changes to your code, settings, or workflow.
+
+In GitLab 14.4, GitLab released an integrated error tracking backend that replaces Sentry. This feature caused database performance issues. In GitLab 14.9, integrated error tracking is removed from GitLab.com, and turned off by default in GitLab self-managed. While we explore the future development of this feature, please consider switching to the Sentry backend by [changing your error tracking to Sentry in your project settings](https://docs.gitlab.com/ee/operations/error_tracking.html#sentry-error-tracking).
+
+For additional background on this removal, please reference [Disable Integrated Error Tracking by Default](https://gitlab.com/groups/gitlab-org/-/epics/7580). If you have feedback please add a comment to [Feedback: Removal of Integrated Error Tracking](https://gitlab.com/gitlab-org/gitlab/-/issues/355493).
+
## 14.6
### Limit the number of triggered pipeline to 25K in free tier
diff --git a/doc/user/application_security/policies/scan-execution-policies.md b/doc/user/application_security/policies/scan-execution-policies.md
index 4e44162d5c5..c3778ac97de 100644
--- a/doc/user/application_security/policies/scan-execution-policies.md
+++ b/doc/user/application_security/policies/scan-execution-policies.md
@@ -8,14 +8,20 @@ info: To determine the technical writer assigned to the Stage/Group associated w
Project owners can use scan execution policies to require that security scans run on a specified
schedule or with the project pipeline. Required scans are injected into the CI pipeline as new jobs
-with a long, random job name. In the unlikely event of a job name collision, the security policy job
-overwrites any pre-existing job in the pipeline.
+with a long, random job name. In the unlikely event of a job name collision, the security policy job overwrites
+any pre-existing job in the pipeline.
This feature has some overlap with [compliance framework pipelines](../../project/settings/#compliance-pipeline-configuration),
as we have not [unified the user experience for these two features](https://gitlab.com/groups/gitlab-org/-/epics/7312).
For details on the similarities and differences between these features, see
[Enforce scan execution](../#enforce-scan-execution).
+NOTE:
+Policy jobs are created in the `test` stage of the pipeline. If you modify the default pipeline
+[`stages`](../../../ci/yaml/index.md#stages),
+you must ensure that the `test` stage exists in the list. Otherwise, the pipeline fails to run and
+an error appears that states `chosen stage does not exist`.
+
## Scan execution policy editor
NOTE:
diff --git a/doc/user/packages/package_registry/index.md b/doc/user/packages/package_registry/index.md
index a2228148447..b980f6a5694 100644
--- a/doc/user/packages/package_registry/index.md
+++ b/doc/user/packages/package_registry/index.md
@@ -60,6 +60,12 @@ For most package types, the following credential types are valid:
allows access to packages in the project running the job for the users running the pipeline.
Access to other external projects can be configured.
+ NOTE:
+ There's an open issue,
+ [GitLab-#333444](https://gitlab.com/gitlab-org/gitlab/-/issues/333444),
+ which prevents you from using a job token with internal projects. This bug only impacts self-managed
+ GitLab instances.
+
## Use GitLab CI/CD to build packages
You can use [GitLab CI/CD](../../../ci/index.md) to build packages.
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index ea75cdcf984..c1f6429f340 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -2164,6 +2164,9 @@ msgstr ""
msgid "Add comment to design"
msgstr ""
+msgid "Add comment..."
+msgstr ""
+
msgid "Add commit messages as comments to Asana tasks. %{docs_link}"
msgstr ""
@@ -6880,6 +6883,12 @@ msgstr ""
msgid "Changing any setting here requires an application restart"
msgstr ""
+msgid "Characters left"
+msgstr ""
+
+msgid "Characters over limit"
+msgstr ""
+
msgid "Charts can't be displayed as the request for data has timed out. %{documentationLink}"
msgstr ""
@@ -18163,6 +18172,9 @@ msgstr ""
msgid "Hide shared projects"
msgstr ""
+msgid "Hide thread"
+msgstr ""
+
msgid "Hide tooltips or popovers"
msgstr ""
@@ -27708,7 +27720,7 @@ msgstr ""
msgid "Pods in use"
msgstr ""
-msgid "Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance. Duplicate URLs are not allowed."
+msgid "Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance. Each URL and link title must be unique."
msgstr ""
msgid "Policies"
@@ -34147,6 +34159,9 @@ msgstr ""
msgid "Show the Open list"
msgstr ""
+msgid "Show thread"
+msgstr ""
+
msgid "Show whitespace changes"
msgstr ""
@@ -37564,7 +37579,7 @@ msgstr ""
msgid "This Project is currently archived and read-only. Please unarchive the project first if you want to resume Pull mirroring"
msgstr ""
-msgid "This URL is already used for another link; duplicate URLs are not allowed"
+msgid "This URL already exists."
msgstr ""
msgid "This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention."
@@ -38059,6 +38074,9 @@ msgstr ""
msgid "This suggestion already matches its content."
msgstr ""
+msgid "This title already exists."
+msgstr ""
+
msgid "This user cannot be unlocked manually from GitLab"
msgstr ""
@@ -38783,9 +38801,6 @@ msgstr ""
msgid "Toggle the Performance Bar"
msgstr ""
-msgid "Toggle thread"
-msgstr ""
-
msgid "Toggled :%{name}: emoji award."
msgstr ""
diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock
index 369ab0860ed..4be8adaef33 100644
--- a/qa/Gemfile.lock
+++ b/qa/Gemfile.lock
@@ -2,7 +2,7 @@ GEM
remote: https://rubygems.org/
specs:
abstract_type (0.0.7)
- activesupport (6.1.4.6)
+ activesupport (6.1.4.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -88,7 +88,7 @@ GEM
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
- ffi (1.15.4)
+ ffi (1.15.5)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
@@ -121,11 +121,12 @@ GEM
gitlab (4.18.0)
httparty (~> 0.18)
terminal-table (>= 1.5.1)
- gitlab-qa (7.17.1)
+ gitlab-qa (7.24.4)
activesupport (~> 6.1)
gitlab (~> 4.18.0)
http (~> 5.0)
nokogiri (~> 1.10)
+ rainbow (~> 3.0.0)
table_print (= 1.5.7)
google-apis-compute_v1 (0.21.0)
google-apis-core (>= 0.4, < 2.a)
@@ -172,7 +173,7 @@ GEM
mime-types (~> 3.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
- i18n (1.8.11)
+ i18n (1.10.0)
concurrent-ruby (~> 1.0)
ice_nine (0.11.2)
influxdb-client (1.17.0)
@@ -195,14 +196,14 @@ GEM
mime-types-data (~> 3.2015)
mime-types-data (3.2022.0105)
mini_mime (1.1.0)
- mini_portile2 (2.6.1)
+ mini_portile2 (2.8.0)
minitest (5.15.0)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.1.1)
netrc (0.11.0)
- nokogiri (1.12.5)
- mini_portile2 (~> 2.6.1)
+ nokogiri (1.13.3)
+ mini_portile2 (~> 2.8.0)
racc (~> 1.4)
octokit (4.21.0)
faraday (>= 0.9)
@@ -328,7 +329,7 @@ GEM
uber (0.1.0)
unf (0.1.4)
unf_ext
- unf_ext (0.0.8)
+ unf_ext (0.0.8.1)
unicode-display_width (2.1.0)
unparser (0.4.7)
abstract_type (~> 0.0.7)
diff --git a/qa/qa/runtime/logger.rb b/qa/qa/runtime/logger.rb
index d538a3d1a3f..1f17146303a 100644
--- a/qa/qa/runtime/logger.rb
+++ b/qa/qa/runtime/logger.rb
@@ -1,40 +1,19 @@
# frozen_string_literal: true
-require 'logger'
require 'forwardable'
module QA
module Runtime
- module Logger
+ class Logger
extend SingleForwardable
- LEVEL_COLORS = {
- "DEBUG" => :magenta,
- "INFO" => :green,
- "WARN" => :yellow,
- "ERROR" => :indianred,
- "FATAL" => :red
- }.freeze
-
def_delegators :logger, :debug, :info, :warn, :error, :fatal, :unknown
- singleton_class.module_eval do
- attr_writer :logger
-
- def logger
- Rainbow.enabled = Runtime::Env.colorized_logs?
-
- @logger ||= ::Logger.new(Runtime::Env.log_destination).tap do |logger|
- logger.level = Runtime::Env.debug? ? ::Logger::DEBUG : ::Logger::ERROR
-
- logger.formatter = proc do |severity, datetime, progname, msg|
- date_format = datetime.strftime("%Y-%m-%d %H:%M:%S")
- msg_prefix = "[date=#{date_format} from=QA Tests] #{severity.ljust(5)} -- "
-
- Rainbow(msg_prefix).send(LEVEL_COLORS.fetch(severity, :yellow)) + "#{msg}\n"
- end
- end
- end
+ def self.logger
+ @logger ||= Gitlab::QA::TestLogger.logger(
+ level: Runtime::Env.debug? ? ::Logger::DEBUG : ::Logger::INFO,
+ source: 'QA Tests'
+ )
end
end
end
diff --git a/qa/spec/page/logging_spec.rb b/qa/spec/page/logging_spec.rb
index 98326ecd343..054332eea29 100644
--- a/qa/spec/page/logging_spec.rb
+++ b/qa/spec/page/logging_spec.rb
@@ -4,11 +4,10 @@ require 'capybara/dsl'
RSpec.describe QA::Support::Page::Logging do
let(:page) { double.as_null_object }
+ let(:logger) { Gitlab::QA::TestLogger.logger(level: ::Logger::DEBUG, source: 'QA Tests') }
before do
- logger = ::Logger.new $stdout
- logger.level = ::Logger::DEBUG
- QA::Runtime::Logger.logger = logger
+ allow(QA::Runtime::Logger).to receive(:logger).and_return(logger)
allow(Capybara).to receive(:current_session).and_return(page)
allow(page).to receive(:find).and_return(page)
@@ -59,7 +58,7 @@ RSpec.describe QA::Support::Page::Logging do
it 'logs find_element with class' do
expect { subject.find_element(:element, class: 'active') }
- .to output(/finding :element with args {:class=>\"active\"}/).to_stdout_from_any_process
+ .to output(/finding :element with args {:class=>"active"}/).to_stdout_from_any_process
end
it 'logs click_element' do
@@ -74,40 +73,40 @@ RSpec.describe QA::Support::Page::Logging do
it 'logs has_element?' do
expect { subject.has_element?(:element) }
- .to output(/has_element\? :element \(wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned: true/).to_stdout_from_any_process
+ .to output(/has_element\? :element \(wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned: true/o).to_stdout_from_any_process
end
it 'logs has_element? with text' do
expect { subject.has_element?(:element, text: "some text") }
- .to output(/has_element\? :element with text \"some text\" \(wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned: true/).to_stdout_from_any_process
+ .to output(/has_element\? :element with text "some text" \(wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned: true/o).to_stdout_from_any_process
end
it 'logs has_no_element?' do
allow(page).to receive(:has_no_css?).and_return(true)
expect { subject.has_no_element?(:element) }
- .to output(/has_no_element\? :element \(wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned: true/).to_stdout_from_any_process
+ .to output(/has_no_element\? :element \(wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned: true/o).to_stdout_from_any_process
end
it 'logs has_no_element? with text' do
allow(page).to receive(:has_no_css?).and_return(true)
expect { subject.has_no_element?(:element, text: "more text") }
- .to output(/has_no_element\? :element with text \"more text\" \(wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned: true/).to_stdout_from_any_process
+ .to output(/has_no_element\? :element with text "more text" \(wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned: true/o).to_stdout_from_any_process
end
it 'logs has_text?' do
allow(page).to receive(:has_text?).and_return(true)
expect { subject.has_text? 'foo' }
- .to output(/has_text\?\('foo', wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned true/).to_stdout_from_any_process
+ .to output(/has_text\?\('foo', wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned true/o).to_stdout_from_any_process
end
it 'logs has_no_text?' do
allow(page).to receive(:has_no_text?).with('foo', any_args).and_return(true)
expect { subject.has_no_text? 'foo' }
- .to output(/has_no_text\?\('foo', wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned true/).to_stdout_from_any_process
+ .to output(/has_no_text\?\('foo', wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned true/o).to_stdout_from_any_process
end
it 'logs finished_loading?' do
diff --git a/qa/spec/runtime/logger_spec.rb b/qa/spec/runtime/logger_spec.rb
index a888bf1452b..f0fcfa0564e 100644
--- a/qa/spec/runtime/logger_spec.rb
+++ b/qa/spec/runtime/logger_spec.rb
@@ -1,33 +1,7 @@
# frozen_string_literal: true
RSpec.describe QA::Runtime::Logger do
- before do
- logger = Logger.new $stdout
- logger.level = ::Logger::DEBUG
- described_class.logger = logger
- end
-
- it 'logs debug' do
- expect { described_class.debug('test') }.to output(/DEBUG -- : test/).to_stdout_from_any_process
- end
-
- it 'logs info' do
- expect { described_class.info('test') }.to output(/INFO -- : test/).to_stdout_from_any_process
- end
-
- it 'logs warn' do
- expect { described_class.warn('test') }.to output(/WARN -- : test/).to_stdout_from_any_process
- end
-
- it 'logs error' do
- expect { described_class.error('test') }.to output(/ERROR -- : test/).to_stdout_from_any_process
- end
-
- it 'logs fatal' do
- expect { described_class.fatal('test') }.to output(/FATAL -- : test/).to_stdout_from_any_process
- end
-
- it 'logs unknown' do
- expect { described_class.unknown('test') }.to output(/ANY -- : test/).to_stdout_from_any_process
+ it 'returns logger instance' do
+ expect(described_class.logger).to be_an_instance_of(::Logger)
end
end
diff --git a/qa/spec/support/repeater_spec.rb b/qa/spec/support/repeater_spec.rb
index 4fa3bcde5e7..96e780fc9bd 100644
--- a/qa/spec/support/repeater_spec.rb
+++ b/qa/spec/support/repeater_spec.rb
@@ -3,12 +3,6 @@
require 'active_support/core_ext/integer/time'
RSpec.describe QA::Support::Repeater do
- before do
- logger = ::Logger.new $stdout
- logger.level = ::Logger::DEBUG
- QA::Runtime::Logger.logger = logger
- end
-
subject do
Module.new do
extend QA::Support::Repeater
diff --git a/spec/features/admin/admin_broadcast_messages_spec.rb b/spec/features/admin/admin_broadcast_messages_spec.rb
index 94111841562..e40f4c4678c 100644
--- a/spec/features/admin/admin_broadcast_messages_spec.rb
+++ b/spec/features/admin/admin_broadcast_messages_spec.rb
@@ -26,8 +26,8 @@ RSpec.describe 'Admin Broadcast Messages' do
fill_in 'broadcast_message_target_path', with: '*/user_onboarded'
fill_in 'broadcast_message_font', with: '#b94a48'
select Date.today.next_year.year, from: 'broadcast_message_ends_at_1i'
- select 'Guest', from: 'broadcast_message_target_access_levels'
- select 'Owner', from: 'broadcast_message_target_access_levels'
+ check 'Guest'
+ check 'Owner'
click_button 'Add broadcast message'
expect(page).to have_current_path admin_broadcast_messages_path, ignore_query: true
@@ -43,9 +43,9 @@ RSpec.describe 'Admin Broadcast Messages' do
fill_in 'broadcast_message_target_path', with: '*/user_onboarded'
select 'Notification', from: 'broadcast_message_broadcast_type'
select Date.today.next_year.year, from: 'broadcast_message_ends_at_1i'
- select 'Reporter', from: 'broadcast_message_target_access_levels'
- select 'Developer', from: 'broadcast_message_target_access_levels'
- select 'Maintainer', from: 'broadcast_message_target_access_levels'
+ check 'Reporter'
+ check 'Developer'
+ check 'Maintainer'
click_button 'Add broadcast message'
expect(page).to have_current_path admin_broadcast_messages_path, ignore_query: true
@@ -59,7 +59,7 @@ RSpec.describe 'Admin Broadcast Messages' do
it 'edit an existing broadcast message' do
click_link 'Edit'
fill_in 'broadcast_message_message', with: 'Application update RIGHT NOW'
- select 'Reporter', from: 'broadcast_message_target_access_levels'
+ check 'Reporter'
click_button 'Update broadcast message'
expect(page).to have_current_path admin_broadcast_messages_path, ignore_query: true
diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
index 8343e04aef1..231722c166d 100644
--- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
+++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
@@ -140,7 +140,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
describe 'reply form' do
before do
- click_button 'Toggle thread'
+ click_button _('Show thread')
end
it 'allows user to comment' do
@@ -362,7 +362,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
it 'displays next thread even if hidden' do
page.all('.note-discussion', count: 2).each do |discussion|
page.within discussion do
- click_button 'Toggle thread'
+ click_button _('Hide thread')
end
end
@@ -549,13 +549,13 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
it 'shows resolved icon' do
expect(page).to have_content 'All threads resolved'
- click_button 'Toggle thread'
+ click_button _('Show thread')
expect(page).to have_selector('.line-resolve-btn.is-active')
end
it 'does not allow user to click resolve button' do
expect(page).to have_selector('.line-resolve-btn.is-active')
- click_button 'Toggle thread'
+ click_button _('Show thread')
expect(page).to have_selector('.line-resolve-btn.is-active')
end
diff --git a/spec/frontend/lib/utils/array_utility_spec.js b/spec/frontend/lib/utils/array_utility_spec.js
index b95286ff254..64ddd400114 100644
--- a/spec/frontend/lib/utils/array_utility_spec.js
+++ b/spec/frontend/lib/utils/array_utility_spec.js
@@ -29,4 +29,17 @@ describe('array_utility', () => {
},
);
});
+
+ describe('getDuplicateItemsFromArray', () => {
+ it.each`
+ array | result
+ ${[]} | ${[]}
+ ${[1, 2, 2, 3, 3, 4]} | ${[2, 3]}
+ ${[1, 2, 3, 2, 3, 4]} | ${[2, 3]}
+ ${['foo', 'bar', 'bar', 'foo', 'baz']} | ${['bar', 'foo']}
+ ${['foo', 'foo', 'bar', 'foo', 'bar']} | ${['foo', 'bar']}
+ `('given $array will return $result', ({ array, result }) => {
+ expect(arrayUtils.getDuplicateItemsFromArray(array)).toEqual(result);
+ });
+ });
});
diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js
index 8d82cf3d2c7..4671d33219d 100644
--- a/spec/frontend/notes/components/note_header_spec.js
+++ b/spec/frontend/notes/components/note_header_spec.js
@@ -1,7 +1,7 @@
import { GlSprintf } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import NoteHeader from '~/notes/components/note_header.vue';
import { AVAILABILITY_STATUS } from '~/set_status_modal/utils';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
@@ -16,11 +16,12 @@ describe('NoteHeader component', () => {
let wrapper;
const findActionsWrapper = () => wrapper.find({ ref: 'discussionActions' });
+ const findToggleThreadButton = () => wrapper.findByTestId('thread-toggle');
const findChevronIcon = () => wrapper.find({ ref: 'chevronIcon' });
const findActionText = () => wrapper.find({ ref: 'actionText' });
const findTimestampLink = () => wrapper.find({ ref: 'noteTimestampLink' });
const findTimestamp = () => wrapper.find({ ref: 'noteTimestamp' });
- const findConfidentialIndicator = () => wrapper.find('[data-testid="confidentialIndicator"]');
+ const findConfidentialIndicator = () => wrapper.findByTestId('confidentialIndicator');
const findSpinner = () => wrapper.find({ ref: 'spinner' });
const findAuthorStatus = () => wrapper.find({ ref: 'authorStatus' });
@@ -40,7 +41,7 @@ describe('NoteHeader component', () => {
};
const createComponent = (props) => {
- wrapper = shallowMount(NoteHeader, {
+ wrapper = shallowMountExtended(NoteHeader, {
store: new Vuex.Store({
actions,
}),
@@ -98,6 +99,19 @@ describe('NoteHeader component', () => {
expect(findChevronIcon().props('name')).toBe('chevron-down');
});
+
+ it.each`
+ text | expanded
+ ${NoteHeader.i18n.showThread} | ${false}
+ ${NoteHeader.i18n.hideThread} | ${true}
+ `('toggle button has text $text is expanded is $expanded', ({ text, expanded }) => {
+ createComponent({
+ includeToggle: true,
+ expanded,
+ });
+
+ expect(findToggleThreadButton().text()).toBe(text);
+ });
});
it('renders an author link if author is passed to props', () => {
diff --git a/spec/frontend/releases/components/asset_links_form_spec.js b/spec/frontend/releases/components/asset_links_form_spec.js
index c0f7738bec5..17f079ba5a6 100644
--- a/spec/frontend/releases/components/asset_links_form_spec.js
+++ b/spec/frontend/releases/components/asset_links_form_spec.js
@@ -256,9 +256,7 @@ describe('Release edit component', () => {
},
});
- expect(findUrlValidationMessage().text()).toBe(
- 'This URL is already used for another link; duplicate URLs are not allowed',
- );
+ expect(findUrlValidationMessage().text()).toBe('This URL already exists.');
});
it('shows a validation error message when a URL has a bad format', () => {
diff --git a/spec/frontend/releases/stores/modules/detail/getters_spec.js b/spec/frontend/releases/stores/modules/detail/getters_spec.js
index 66f24ac9559..c32969c131e 100644
--- a/spec/frontend/releases/stores/modules/detail/getters_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/getters_spec.js
@@ -134,6 +134,14 @@ describe('Release edit/new getters', () => {
// Missing title
{ id: 7, url: 'https://example.com/valid/1', name: '' },
{ id: 8, url: 'https://example.com/valid/2', name: ' ' },
+
+ // Duplicate title
+ { id: 9, url: 'https://example.com/1', name: 'Link 7' },
+ { id: 10, url: 'https://example.com/2', name: 'Link 7' },
+
+ // title validation ignores leading/trailing whitespace
+ { id: 11, url: 'https://example.com/3', name: ' Link 7\t ' },
+ { id: 12, url: 'https://example.com/4', name: ' Link 7\n\r\n ' },
],
},
},
@@ -201,6 +209,21 @@ describe('Release edit/new getters', () => {
expect(actualErrors).toMatchObject(expectedErrors);
});
+
+ it('returns a validation error if links share a title', () => {
+ const expectedErrors = {
+ assets: {
+ links: {
+ 9: { isTitleDuplicate: true },
+ 10: { isTitleDuplicate: true },
+ 11: { isTitleDuplicate: true },
+ 12: { isTitleDuplicate: true },
+ },
+ },
+ };
+
+ expect(actualErrors).toMatchObject(expectedErrors);
+ });
});
});
diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js
index 5e2efa2425c..db56f77b60e 100644
--- a/spec/frontend/security_configuration/components/training_provider_list_spec.js
+++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js
@@ -89,7 +89,7 @@ describe('TrainingProviderList component', () => {
const findPrimaryProviderRadios = () => wrapper.findAllByTestId('primary-provider-radio');
const findLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findErrorAlert = () => wrapper.findComponent(GlAlert);
- const findLogos = () => wrapper.findAll('img');
+ const findLogos = () => wrapper.findAllByTestId('provider-logo');
const toggleFirstProvider = () => findFirstToggle().vm.$emit('change', testProviderIds[0]);
@@ -205,15 +205,15 @@ describe('TrainingProviderList component', () => {
const providerIndexArray = [0, 1];
it.each(providerIndexArray)('displays the correct width for provider %s', (provider) => {
- expect(findLogos().at(provider).attributes('width')).toBe('18');
+ expect(findLogos().at(provider).attributes('style')).toBe('width: 18px;');
});
it.each(providerIndexArray)('has a11y decorative attribute for provider %s', (provider) => {
expect(findLogos().at(provider).attributes('role')).toBe('presentation');
});
- it.each(providerIndexArray)('displays the correct svg path for provider %s', (provider) => {
- expect(findLogos().at(provider).attributes('src')).toBe(
+ it.each(providerIndexArray)('renders the svg content for provider %s', (provider) => {
+ expect(findLogos().at(provider).html()).toContain(
tempProviderLogos[testProviderName[provider]].svg,
);
});
diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js
index 21d7140bf89..55f5c20e45d 100644
--- a/spec/frontend/security_configuration/mock_data.js
+++ b/spec/frontend/security_configuration/mock_data.js
@@ -105,9 +105,9 @@ export const updateSecurityTrainingProvidersErrorResponse = {
// https://gitlab.com/gitlab-org/gitlab/-/issues/346899
export const tempProviderLogos = {
[testProviderName[0]]: {
- svg: '/assets/illustrations/vulnerability/vendor-1.svg',
+ svg: `<svg>${[testProviderName[0]]}</svg>`,
},
[testProviderName[1]]: {
- svg: '/assets/illustrations/vulnerability/vendor-2.svg',
+ svg: `<svg>${[testProviderName[1]]}</svg>`,
},
};